Files
larksuite-cli/internal/errclass/classify.go

492 lines
18 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"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
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
LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm
}
// 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 (catch-all for classified Lark business errors)
//
// 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,
}
// Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted
// universally before the category switch so every classified typed
// error surfaces it when present. The remaining contents of resp["error"]
// (permission_violations.subject, data.challenge_url, data.hint) are
// either lifted into category-specific typed extension fields below or
// intentionally dropped as redundant with the typed envelope.
if errBlock, ok := resp["error"].(map[string]any); ok {
if ts, _ := errBlock["troubleshooter"].(string); ts != "" {
base.Troubleshooter = ts
}
}
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category {
case errs.CategoryAuthorization:
return buildPermissionError(base, resp, cc)
case errs.CategoryAuthentication:
return &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
return buildConfigError(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:
// Risk + Action are non-omitempty wire fields. Derive from
// CodeMeta when available; otherwise emit RiskUnknown +
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
risk := meta.Risk
if risk == "" {
risk = errs.RiskUnknown
}
action := meta.Action
if action == "" {
action = cc.LarkCmd
}
if action == "" {
action = "unknown"
}
return &errs.ConfirmationRequiredError{
Problem: base,
Risk: risk,
Action: action,
}
case errs.CategoryAPI:
// A server-supplied detail (lifted into base.Hint above) wins over the
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
// instead of emitting an empty Problem on the wire.
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
},
}
}
}
// 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 collapse once the auth transport adopts BuildAPIError directly.
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
}
// buildConfigError enriches a typed ConfigError with the canonical
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
// ConfigHint returns the canonical per-subtype recovery hint for a typed
// ConfigError emitted via BuildAPIError.
func ConfigHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeInvalidClient:
return "run `lark-cli config init` to set valid app_id and app_secret"
case errs.SubtypeNotConfigured:
return "run `lark-cli config init` to set up app_id and app_secret"
case errs.SubtypeInvalidConfig:
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
}
return ""
}
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
// layered on by the caller after BuildAPIError returns and overrides this.
func APIHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeConflict:
return "retry later and avoid concurrent duplicate requests on the same resource"
case errs.SubtypeCrossTenant:
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity
if identity == "" {
identity = "user"
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
MissingScopes: missing,
Identity: identity,
}
// ConsoleURL is the developer-console deep-link an app developer follows to
// apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied,
// which is bot-perspective. The other authorization subtypes route to a
// different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient /
// SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable
// / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those
// envelopes is dead weight and risks pointing an end user at a console they
// cannot modify; the URL is still computed so the hint composer can use it
// where appropriate.
if p.Subtype == errs.SubtypeAppScopeNotApplied {
permErr.ConsoleURL = consoleURL
}
return permErr
}
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
// typed PermissionError, preserving the Lark official-API phrasing
// ("access denied" / "unauthorized" / "token has no permission") and
// enhancing it with CLI context (app ID, missing scope list). Subtypes
// outside the known set fall through to fallback so the upstream message
// is preserved.
func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string {
switch subtype {
case errs.SubtypeAppScopeNotApplied:
if len(missing) > 0 {
scopes := strings.Join(missing, ", ")
if appID != "" {
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes)
}
return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes)
}
if appID != "" {
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID)
}
return "access denied: app has not applied for the required scope(s)"
case errs.SubtypeMissingScope:
if len(missing) > 0 {
return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", "))
}
return "unauthorized: user authorization does not cover the required scope"
case errs.SubtypeTokenScopeInsufficient:
return "token has no permission for this operation; required scope is missing"
case errs.SubtypeUserUnauthorized:
return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy"
case errs.SubtypeAppUnavailable:
if appID != "" {
return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID)
}
return "unauthorized app: app is not properly installed in this tenant"
case errs.SubtypeAppDisabled:
if appID != "" {
return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID)
}
return "app is not in use in this tenant (currently disabled)"
case errs.SubtypePermissionDenied:
return "user lacks permission for the requested resource"
}
return fallback
}
// PermissionHint returns the canonical per-subtype recovery hint for a typed
// PermissionError. The hint distinguishes authorization subtypes routing
// to different recovery paths: developer console for app_scope_not_applied,
// user re-login for missing_scope / token_scope_insufficient / user_unauthorized,
// and tenant admin for app_unavailable / app_disabled. The subtype
// argument is the primary discriminator; identity is retained for the
// generic permission_denied fallback so callers that do not yet route on
// subtype still get a sensible hint.
//
// 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, consoleURL string) string {
switch subtype {
case errs.SubtypeAppScopeNotApplied:
if consoleURL != "" {
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
}
return "the app developer must apply for the required scope(s) at the developer console"
case errs.SubtypeMissingScope:
if len(missing) > 0 {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
}
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
case errs.SubtypeTokenScopeInsufficient:
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
case errs.SubtypeUserUnauthorized:
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
case errs.SubtypeAppUnavailable:
return "ask the tenant admin to check the app's install status in the Lark admin console"
case errs.SubtypeAppDisabled:
return "ask the tenant admin to re-enable the app in the Lark admin console"
case errs.SubtypePermissionDenied:
who := "this user"
if identity == "bot" {
who = "this bot"
}
return fmt.Sprintf("check the resource owner has granted access to %s", who)
}
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// 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 application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` 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 ""
}
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return base
}
return base + "&scopes=" + 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
}