mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
285 lines
9.1 KiB
Go
285 lines
9.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errclass
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
|
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
|
// depend on internal/core (which would create an import cycle).
|
|
type ClassifyContext struct {
|
|
Brand string // "feishu" | "lark" — drives console_url host
|
|
AppID string // placed in console_url
|
|
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
|
}
|
|
|
|
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
|
// Returns nil when resp is nil or resp["code"] is 0.
|
|
//
|
|
// Routing by Category:
|
|
//
|
|
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
|
// Authentication → *errs.AuthenticationError
|
|
// Config → *errs.ConfigError
|
|
// Policy → *errs.SecurityPolicyError
|
|
// Validation → *errs.ValidationError
|
|
// Network → *errs.NetworkError
|
|
// Internal → *errs.InternalError
|
|
// Confirmation → *errs.ConfirmationRequiredError
|
|
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
|
|
//
|
|
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
|
// CategoryAPI + SubtypeUnknown.
|
|
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
code := intFromAny(resp["code"])
|
|
if code == 0 {
|
|
return nil
|
|
}
|
|
msg, _ := resp["msg"].(string)
|
|
if msg == "" {
|
|
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
|
// so the typed wire envelope still carries a human-readable signal.
|
|
msg = fmt.Sprintf("API error: [%d]", code)
|
|
}
|
|
// Lark API responses sometimes carry log_id at the top level
|
|
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
|
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
|
// back to the nested location so log_id always surfaces on the typed
|
|
// envelope.
|
|
logID, _ := resp["log_id"].(string)
|
|
if logID == "" {
|
|
if errBlock, ok := resp["error"].(map[string]any); ok {
|
|
if nested, ok := errBlock["log_id"].(string); ok {
|
|
logID = nested
|
|
}
|
|
}
|
|
}
|
|
|
|
meta, ok := LookupCodeMeta(code)
|
|
if !ok {
|
|
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
|
}
|
|
|
|
base := errs.Problem{
|
|
Category: meta.Category,
|
|
Subtype: meta.Subtype,
|
|
Code: code,
|
|
Message: msg,
|
|
LogID: logID,
|
|
Retryable: meta.Retryable,
|
|
}
|
|
|
|
switch meta.Category {
|
|
case errs.CategoryAuthorization:
|
|
return buildPermissionError(base, resp, cc)
|
|
case errs.CategoryAuthentication:
|
|
return &errs.AuthenticationError{Problem: base}
|
|
case errs.CategoryConfig:
|
|
return &errs.ConfigError{Problem: base}
|
|
case errs.CategoryPolicy:
|
|
return buildSecurityPolicyError(base, resp)
|
|
case errs.CategoryValidation:
|
|
return &errs.ValidationError{Problem: base}
|
|
case errs.CategoryNetwork:
|
|
return &errs.NetworkError{Problem: base}
|
|
case errs.CategoryInternal:
|
|
return &errs.InternalError{Problem: base}
|
|
case errs.CategoryConfirmation:
|
|
return &errs.ConfirmationRequiredError{Problem: base}
|
|
default:
|
|
return &errs.APIError{Problem: base, Detail: resp}
|
|
}
|
|
}
|
|
|
|
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
|
// response's data block, so the typed SecurityPolicyError carries the same
|
|
// browser-challenge information that internal/auth/transport.go surfaces at
|
|
// the HTTP layer.
|
|
//
|
|
// Data shapes accepted (whichever the upstream sends):
|
|
//
|
|
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
|
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
|
//
|
|
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
|
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
|
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
|
// either spelling surfaces, matching the transport layer.
|
|
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
|
dataMap, _ := resp["data"].(map[string]any)
|
|
if dataMap == nil {
|
|
if errBlock, ok := resp["error"].(map[string]any); ok {
|
|
dataMap, _ = errBlock["data"].(map[string]any)
|
|
}
|
|
}
|
|
if dataMap == nil {
|
|
return &errs.SecurityPolicyError{Problem: p}
|
|
}
|
|
|
|
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
|
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
|
challengeURL = ""
|
|
}
|
|
|
|
hint := stringFromAny(dataMap["hint"])
|
|
if hint == "" {
|
|
hint = stringFromAny(dataMap["cli_hint"])
|
|
}
|
|
if hint != "" {
|
|
p.Hint = hint
|
|
}
|
|
|
|
return &errs.SecurityPolicyError{
|
|
Problem: p,
|
|
ChallengeURL: challengeURL,
|
|
}
|
|
}
|
|
|
|
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
|
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
|
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
|
|
func isHTTPSURL(rawURL string) bool {
|
|
if rawURL == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Scheme == "https"
|
|
}
|
|
|
|
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
|
func stringFromAny(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
|
missing := extractMissingScopes(resp)
|
|
identity := cc.Identity
|
|
if identity == "" {
|
|
identity = "user"
|
|
}
|
|
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
|
return &errs.PermissionError{
|
|
Problem: p,
|
|
MissingScopes: missing,
|
|
Identity: identity,
|
|
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
|
|
}
|
|
}
|
|
|
|
// PermissionHint returns an actionable next-step string for a permission
|
|
// error. User identity with a missing user-scope is recovered by re-running
|
|
// `auth login --scope ...`; bot identity or app-level scope errors are
|
|
// recovered by enabling scopes in the open-platform console. The subtype
|
|
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
|
// where re-authentication will not help regardless of the caller identity.
|
|
//
|
|
// Exported so direct construction sites (cmd/service/service.go's
|
|
// checkServiceScopes) can produce hints that match the dispatcher path
|
|
// byte-for-byte instead of hand-rolling divergent strings.
|
|
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
|
// app_scope_not_enabled means the scope has not been granted at the
|
|
// app (developer console) level — re-authenticating cannot fix it,
|
|
// so route every caller identity to the console hint.
|
|
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
|
if len(missing) == 0 {
|
|
if useConsole {
|
|
return "check the app's scope grant in the Lark open platform console"
|
|
}
|
|
return "ensure the calling identity has been granted the required scopes"
|
|
}
|
|
scopes := strings.Join(missing, " ")
|
|
if useConsole {
|
|
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
|
|
}
|
|
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
|
|
}
|
|
|
|
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
|
// Returns nil when the structure is absent.
|
|
func extractMissingScopes(resp map[string]any) []string {
|
|
errBlock, ok := resp["error"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
raw, ok := errBlock["permission_violations"].([]any)
|
|
if !ok || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, v := range raw {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
s, _ := m["subject"].(string)
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
|
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
|
// scopes list returns the bare /auth landing page; scopes are joined with
|
|
// commas in the `q` query parameter so the console can pre-select them.
|
|
//
|
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
|
if appID == "" {
|
|
return ""
|
|
}
|
|
host := "open.feishu.cn"
|
|
if brand == "lark" {
|
|
host = "open.larksuite.com"
|
|
}
|
|
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
|
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
|
// content must not be able to inject extra query parameters via `&`/`#`.
|
|
pathID := url.PathEscape(appID)
|
|
if len(scopes) == 0 {
|
|
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
|
}
|
|
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
|
}
|
|
|
|
func intFromAny(v any) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case json.Number:
|
|
i, err := n.Int64()
|
|
if err == nil {
|
|
return int(i)
|
|
}
|
|
f, err := n.Float64()
|
|
if err == nil {
|
|
return int(f)
|
|
}
|
|
}
|
|
return 0
|
|
}
|