diff --git a/errs/types_test.go b/errs/types_test.go index 52e0f32e..8279c2e4 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) { WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send"). WithMissingScopes("mail:user_mailbox.message:send"). WithIdentity("user"). - WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send") if got.Category != errs.CategoryAuthorization { t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) @@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) { WithHint("run lark-cli auth login --scope calendar:event:create"). WithMissingScopes("calendar:event:create"). WithIdentity("user"). - WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create") buf, err := json.Marshal(e) if err != nil { @@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) { "hint": "run lark-cli auth login --scope calendar:event:create", "log_id": "20260520-0a1b2c3d", "identity": "user", - "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create", "missing_scopes": []any{"calendar:event:create"}, } for k, want := range wantFields { diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index a2d1a740..bc200b4d 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -10,12 +10,14 @@ import ( "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). -// Identity is a plain string ("user" / "bot" / "") so this package does not -// depend on internal/core (which would create an import cycle). +// 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 @@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string { 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. +// 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 "" } - 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) + // 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 fmt.Sprintf("https://%s/app/%s/auth", host, pathID) + return base } - return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ","))) + return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) } func intFromAny(v any) int { diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 9ba38ca1..d4dff580 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) { if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) } - if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") { - t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL) + if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") { + t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL) } } @@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) { if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) } - if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") { - t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL) + if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") { + t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL) } } @@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) { name: "ampersand in scope smuggles extra param", appID: "cli_good", scopes: []string{"scope&evil=injected"}, - wantInURL: []string{"q=scope%26evil%3Dinjected"}, - denyInURL: []string{"q=scope&evil=injected"}, + wantInURL: []string{"scopes=scope%26evil%3Dinjected"}, + denyInURL: []string{"scopes=scope&evil=injected"}, }, { name: "hash in scope splits fragment", appID: "cli_good", scopes: []string{"scope#fragment"}, - wantInURL: []string{"q=scope%23fragment"}, - denyInURL: []string{"q=scope#fragment"}, + wantInURL: []string{"scopes=scope%23fragment"}, + denyInURL: []string{"scopes=scope#fragment"}, }, { name: "question mark in appID prematurely opens query", appID: "good?q=injected", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%3Fq=injected/auth"}, - denyInURL: []string{"/app/good?q=injected/auth"}, + wantInURL: []string{"clientID=good%3Fq%3Dinjected"}, + denyInURL: []string{"clientID=good?q=injected"}, }, { name: "hash in appID truncates URL", appID: "good#fragment", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%23fragment/auth"}, - denyInURL: []string{"/app/good#fragment/auth"}, + wantInURL: []string{"clientID=good%23fragment"}, + denyInURL: []string{"clientID=good#fragment"}, }, { - name: "slash in appID escapes path segment", + name: "slash in appID does not open a new path segment", appID: "good/extra/segment", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"}, + wantInURL: []string{"clientID=good%2Fextra%2Fsegment"}, }, } @@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) { if pe.MissingScopes != nil { t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes) } - if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") { - t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL) + if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") { + t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL) } } @@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { // at the app level — re-authenticating cannot fix it. The hint must // point to the developer console regardless of caller identity, or // agents will loop on `auth login` forever. - consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact" + consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact" for _, identity := range []string{"user", "bot", ""} { got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL) if !strings.Contains(got, "developer console") { diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index 5e7ecbf1..c1af42d9 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { if appID == "" || scope == "" { return "" } - host := "open.feishu.cn" - if brand == core.BrandLark { - host = "open.larksuite.com" - } return fmt.Sprintf( - "https://%s/page/scope-apply?clientID=%s&scopes=%s", - host, + "%s/page/scope-apply?clientID=%s&scopes=%s", + core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), ) diff --git a/shortcuts/task/task_util_test.go b/shortcuts/task/task_util_test.go index a1ee9aaf..3db84d12 100644 --- a/shortcuts/task/task_util_test.go +++ b/shortcuts/task/task_util_test.go @@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) { if pe.Subtype != errs.SubtypeAppScopeNotApplied { t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied) } - if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") { + if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") { t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL) } if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {