diff --git a/.changeset/plapi-forbidden-role-message.md b/.changeset/plapi-forbidden-role-message.md new file mode 100644 index 000000000..c083070af --- /dev/null +++ b/.changeset/plapi-forbidden-role-message.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Explain Platform API `403` errors caused by your workspace role. When your role in the signed-in Clerk workspace has no Platform API access (for example Support or Viewer), every command now says so, names your role and the roles that have access, and tells you to ask a workspace owner or admin to change it or to switch workspaces and run `clerk auth logout` and `clerk auth login`. A `403` for a missing scope now names the scope and says whether your role or your `CLERK_PLATFORM_API_KEY` lacks it. `clerk auth login` no longer suggests `clerk apps create` when it can't set up a default application for this reason. In `--json` and agent mode the error keeps the API's `code` and `errors` and adds a `docsUrl`. diff --git a/.claude/rules/errors.md b/.claude/rules/errors.md index bfcc06b1c..eb721765c 100644 --- a/.claude/rules/errors.md +++ b/.claude/rules/errors.md @@ -48,7 +48,7 @@ if (!confirmed) throwUserAbort(); ## API errors — `withApiContext` -Wrap API calls with `withApiContext` to attach a human-readable context string. The global handler extracts the first error message from the response body and prints it with the context prefix: +Wrap API calls with `withApiContext` to attach a human-readable context string. The global handler extracts the first error message from the response body and prints it with the context prefix. A Platform API 403 caused by the workspace role or a missing scope is explained by `platformPermissionProblem` instead, in the global handler and anywhere else that reports a swallowed error: ```ts import { withApiContext } from "../../lib/errors.ts"; diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index fe8e4b14a..5137d3a25 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -603,6 +603,87 @@ describe("reportError", () => { }); }); + describe("Platform API permission errors", () => { + const roleBody = JSON.stringify({ + errors: [ + { + code: "platform_oauth_access_token_role_has_no_access", + message: "Workspace role has no Platform API access", + long_message: + "Your role in the active workspace (org:support) cannot use the Platform API. Roles with access: org:owner, org:admin, org:developer. Ask a workspace owner to change your role, or use an API key with the scopes you need.", + meta: { + role: "org:support", + roles_with_access: ["org:owner", "org:admin", "org:developer"], + }, + }, + ], + }); + const roleError = () => { + const error = new PlapiError(403, roleBody, "https://api.clerk.com/v1/platform/applications"); + error.context = "Failed to fetch application"; + return error; + }; + const roleMessage = + "Failed to fetch application (403): Your role in this Clerk workspace (org:support) has no Platform API access, so the CLI can't read or change its applications.\n" + + "Ask an owner or admin of the workspace to change your role to one of: org:owner, org:admin, org:developer.\n" + + "To use another workspace, switch to it in the Clerk Dashboard, then run `clerk auth logout` and `clerk auth login`."; + + test("human mode explains the workspace role and what to do", () => { + expect(reportError(roleError(), false)).toBe(EXIT_CODE.GENERAL); + expect(captured.err).toContain(roleMessage); + expect(captured.err).toContain( + "For more information, see: https://clerk.com/docs/guides/dashboard/manage-team-access", + ); + }); + + test("agent mode keeps the API's code and errors and adds the explanation", () => { + asAgent(); + reportError(roleError(), false); + expect(json().error).toEqual({ + code: "platform_oauth_access_token_role_has_no_access", + message: roleMessage, + docsUrl: "https://clerk.com/docs/guides/dashboard/manage-team-access.md", + errors: [ + { + code: "platform_oauth_access_token_role_has_no_access", + message: "Workspace role has no Platform API access", + meta: { + role: "org:support", + roles_with_access: ["org:owner", "org:admin", "org:developer"], + }, + }, + ], + }); + }); + + test("a missing-scopes 403 names the scope the role lacks", () => { + const body = JSON.stringify({ + errors: [ + { + code: "authorization_missing_scopes", + message: "Missing authorization scopes", + meta: { scopes: ["applications:manage"] }, + }, + ], + }); + const error = new PlapiError(403, body); + error.context = "Failed to create application"; + reportError(error, false); + expect(captured.err).toContain( + "Failed to create application (403): Your role in this Clerk workspace isn't allowed to do this. It needs the applications:manage scope.", + ); + }); + + test("other 403s keep the API's message", () => { + const body = JSON.stringify({ + errors: [{ code: "authorization_invalid", message: "Request not allowed" }], + }); + reportError(new PlapiError(403, body), false); + expect(captured.err).toContain("Request failed (403): Request not allowed"); + expect(captured.err).not.toContain("For more information"); + }); + }); + describe("unexpected failures", () => { test("a plain Error prints its message in human mode", () => { expect(reportError(new Error("socket hang up"), false)).toBe(EXIT_CODE.GENERAL); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 6bf24c165..170fd7128 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -43,6 +43,8 @@ import { PlapiError, FapiError, EXIT_CODE, + agentDocsUrl, + platformPermissionProblem, throwUsageError, } from "./lib/errors.ts"; import { clerkHelpConfig, formatExamplesBlock, type Example } from "./lib/help.ts"; @@ -375,7 +377,10 @@ export function reportError(error: unknown, verbose: boolean): number { } if (error instanceof ApiError) { - const detail = formatApiBody(error.body, verbose); + // The response body stays in the `--verbose` request log and the JSON `errors` array. + const permission = platformPermissionProblem(error); + const detail = permission?.message ?? formatApiBody(error.body, verbose); + const docsUrl = permission?.docsUrl && agentDocsUrl(permission.docsUrl); const prefix = error.context ?? "Request failed"; if (isAgent()) { const apiErrors: ApiErrorEntry[] | undefined = @@ -391,7 +396,7 @@ export function reportError(error: unknown, verbose: boolean): number { outputJsonError( error.code ?? "api_error", `${prefix} (${error.status}): ${detail}`, - undefined, + docsUrl, apiErrors, ); } else { @@ -402,6 +407,9 @@ export function reportError(error: unknown, verbose: boolean): number { if (verbose && error.clerkTraceId) { log.error(` Trace: ${error.clerkTraceId}`); } + if (docsUrl) { + log.info(`\nFor more information, see: ${docsUrl}`); + } } return EXIT_CODE.GENERAL; } diff --git a/packages/cli-core/src/lib/errors.test.ts b/packages/cli-core/src/lib/errors.test.ts index 2803971b4..6cfb2787a 100644 --- a/packages/cli-core/src/lib/errors.test.ts +++ b/packages/cli-core/src/lib/errors.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { PlapiError, FapiError, @@ -7,6 +7,7 @@ import { BILLING_ERROR_REASON, CliError, ERROR_CODE, + platformPermissionProblem, } from "./errors.ts"; describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { @@ -182,3 +183,90 @@ describe("BillingError", () => { expect(err.docsUrl).toBe("https://dashboard.clerk.com/settings/billing"); }); }); + +describe("platformPermissionProblem", () => { + const originalKey = process.env.CLERK_PLATFORM_API_KEY; + beforeEach(() => { + delete process.env.CLERK_PLATFORM_API_KEY; + }); + afterEach(() => { + if (originalKey === undefined) { + delete process.env.CLERK_PLATFORM_API_KEY; + } else { + process.env.CLERK_PLATFORM_API_KEY = originalKey; + } + }); + + const body = (code: string, meta?: Record) => + JSON.stringify({ errors: [{ code, message: "API message", ...(meta ? { meta } : {}) }] }); + const roleCode = "platform_oauth_access_token_role_has_no_access"; + + test("names the role and the roles that have access", () => { + const problem = platformPermissionProblem( + new PlapiError( + 403, + body(roleCode, { role: "org:viewer", roles_with_access: ["org:owner", "org:admin"] }), + ), + ); + expect(problem?.message).toContain( + "Your role in this Clerk workspace (org:viewer) has no Platform API access", + ); + expect(problem?.message).toContain("change your role to one of: org:owner, org:admin."); + expect(problem?.message).toContain("`clerk auth logout` and `clerk auth login`"); + expect(problem?.docsUrl).toBe("https://clerk.com/docs/guides/dashboard/manage-team-access"); + }); + + test("points a managed workspace at an API key, since its roles can't change", () => { + const problem = platformPermissionProblem( + new PlapiError(403, body(roleCode, { role: "org:managed_member", roles_with_access: [] })), + ); + expect(problem?.message).toContain("managed by a partner integration"); + expect(problem?.message).toContain("Set CLERK_PLATFORM_API_KEY"); + expect(problem?.message).not.toContain("change your role"); + }); + + test("still explains a role error whose meta is missing", () => { + const problem = platformPermissionProblem(new PlapiError(403, body(roleCode))); + expect(problem?.message).toStartWith( + "Your role in this Clerk workspace has no Platform API access", + ); + }); + + test("blames the workspace role for missing scopes on an OAuth session", () => { + const problem = platformPermissionProblem( + new PlapiError( + 403, + body("authorization_missing_scopes", { + scopes: ["applications:manage", "applications:delete"], + }), + ), + ); + expect(problem?.message).toStartWith( + "Your role in this Clerk workspace isn't allowed to do this. It needs the applications:manage, applications:delete scopes.", + ); + }); + + test("blames the Platform API key for missing scopes when one is set", () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + const problem = platformPermissionProblem( + new PlapiError( + 403, + body("authorization_missing_scopes", { scopes: ["applications:manage"] }), + ), + ); + expect(problem?.message).toStartWith( + "The Platform API key in CLERK_PLATFORM_API_KEY isn't allowed to do this. It needs the applications:manage scope.", + ); + expect(problem?.message).not.toContain("workspace"); + expect(problem?.docsUrl).toBeUndefined(); + }); + + test.each([ + ["another 403 code", new PlapiError(403, body("authorization_invalid"))], + ["a role code on another status", new PlapiError(401, body(roleCode))], + ["a role code from the Backend API", new BapiError(403, body(roleCode), new Headers())], + ["a non-API error", new Error("boom")], + ])("ignores %s", (_name, error) => { + expect(platformPermissionProblem(error)).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index c9a0d58da..2f641a142 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -512,6 +512,80 @@ export class BapiError extends ApiError { } } +const WORKSPACE_ROLE_HAS_NO_ACCESS_CODE = "platform_oauth_access_token_role_has_no_access"; +const MISSING_SCOPES_CODE = "authorization_missing_scopes"; +const TEAM_ACCESS_DOCS_URL = "https://clerk.com/docs/guides/dashboard/manage-team-access"; +const SWITCH_WORKSPACE_HINT = + "To use another workspace, switch to it in the Clerk Dashboard, then run `clerk auth logout` and `clerk auth login`."; + +interface PlatformPermissionProblem { + message: string; + docsUrl?: string; +} + +function metaString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function metaStrings(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; +} + +function workspaceRoleHasNoAccess(meta: Record | null): PlatformPermissionProblem { + const role = metaString(meta?.role); + const rolesWithAccess = metaStrings(meta?.roles_with_access); + const roleLabel = role + ? `Your role in this Clerk workspace (${role})` + : "Your role in this Clerk workspace"; + const remedy = + rolesWithAccess.length > 0 + ? `Ask an owner or admin of the workspace to change your role to one of: ${rolesWithAccess.join(", ")}.` + : "This workspace is managed by a partner integration, so its roles can't be changed. Set CLERK_PLATFORM_API_KEY to a Platform API key with the scopes you need."; + return { + message: [ + `${roleLabel} has no Platform API access, so the CLI can't read or change its applications.`, + remedy, + SWITCH_WORKSPACE_HINT, + ].join("\n"), + docsUrl: TEAM_ACCESS_DOCS_URL, + }; +} + +function missingScopes(meta: Record | null): PlatformPermissionProblem { + const scopes = metaStrings(meta?.scopes); + const needs = + scopes.length > 0 + ? ` It needs the ${scopes.join(", ")} scope${scopes.length > 1 ? "s" : ""}.` + : ""; + if (process.env.CLERK_PLATFORM_API_KEY) { + return { + message: [ + `The Platform API key in CLERK_PLATFORM_API_KEY isn't allowed to do this.${needs}`, + `Set CLERK_PLATFORM_API_KEY to a key that has ${scopes.length > 0 ? "them" : "the scopes this command needs"}: https://dashboard.clerk.com/last-active?path=api-keys`, + ].join("\n"), + }; + } + return { + message: [ + `Your role in this Clerk workspace isn't allowed to do this.${needs}`, + "Ask an owner or admin of the workspace for a role that allows it.", + SWITCH_WORKSPACE_HINT, + ].join("\n"), + docsUrl: TEAM_ACCESS_DOCS_URL, + }; +} + +/** + * Explain a Platform API 403 caused by what the signed-in workspace role (or + * the Platform API key) may do, or return `undefined` for any other error. + */ +export function platformPermissionProblem(error: unknown): PlatformPermissionProblem | undefined { + if (!(error instanceof PlapiError) || error.status !== 403) return undefined; + if (error.code === WORKSPACE_ROLE_HAS_NO_ACCESS_CODE) return workspaceRoleHasNoAccess(error.meta); + if (error.code === MISSING_SCOPES_CODE) return missingScopes(error.meta); + return undefined; +} + export function isAuthError(error: unknown): error is AuthError | ApiError { return ( (error instanceof CliError && error.code === ERROR_CODE.AUTH_REQUIRED) || diff --git a/packages/cli-core/src/lib/first-application.test.ts b/packages/cli-core/src/lib/first-application.test.ts index cd8354e68..862745d3e 100644 --- a/packages/cli-core/src/lib/first-application.test.ts +++ b/packages/cli-core/src/lib/first-application.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; import { DEFAULT_FIRST_APPLICATION_NAME } from "./constants.ts"; +import { PlapiError } from "./errors.ts"; import { useCaptureLog } from "../test/lib/stubs.ts"; // Mock the plapi module so the tests don't hit a real server. Match the @@ -15,7 +16,7 @@ mock.module("./plapi.ts", () => ({ const { ensureFirstApplication } = await import("./first-application.ts"); describe("ensureFirstApplication", () => { - useCaptureLog(); + const captured = useCaptureLog(); beforeEach(() => { mockListApplications.mockReset(); @@ -54,4 +55,31 @@ describe("ensureFirstApplication", () => { mockCreateApplication.mockRejectedValue(new Error("boom")); await expect(ensureFirstApplication()).resolves.toBeUndefined(); }); + + it("suggests `clerk apps create` when the setup fails for another reason", async () => { + mockListApplications.mockRejectedValue(new Error("boom")); + await ensureFirstApplication(); + expect(captured.err).toContain("Run `clerk apps create` to create one."); + }); + + it("explains a workspace role without Platform API access instead of suggesting `clerk apps create`", async () => { + const body = JSON.stringify({ + errors: [ + { + code: "platform_oauth_access_token_role_has_no_access", + message: "Workspace role has no Platform API access", + meta: { + role: "org:support", + roles_with_access: ["org:owner", "org:admin", "org:developer"], + }, + }, + ], + }); + mockListApplications.mockRejectedValue(new PlapiError(403, body)); + await ensureFirstApplication(); + expect(captured.err).toContain( + "Could not set up a default application. Your role in this Clerk workspace (org:support) has no Platform API access", + ); + expect(captured.err).not.toContain("clerk apps create"); + }); }); diff --git a/packages/cli-core/src/lib/first-application.ts b/packages/cli-core/src/lib/first-application.ts index 1e5f2c434..8de22274d 100644 --- a/packages/cli-core/src/lib/first-application.ts +++ b/packages/cli-core/src/lib/first-application.ts @@ -1,6 +1,7 @@ import { listApplications, createApplication } from "./plapi.ts"; import { log } from "./log.ts"; import { DEFAULT_FIRST_APPLICATION_NAME } from "./constants.ts"; +import { platformPermissionProblem } from "./errors.ts"; /** Create a default app for first-time users; no-op if any app already exists. */ export async function ensureFirstApplication(): Promise { @@ -13,7 +14,12 @@ export async function ensureFirstApplication(): Promise { const created = await createApplication(DEFAULT_FIRST_APPLICATION_NAME); log.debug(`ensure-first-app: created ${created.application_id}`); } catch (err) { - log.warn("Could not set up a default application. Run `clerk apps create` to create one."); + const permission = platformPermissionProblem(err); + log.warn( + permission + ? `Could not set up a default application. ${permission.message}` + : "Could not set up a default application. Run `clerk apps create` to create one.", + ); log.debug(`ensure-first-app: failed (non-fatal): ${String(err)}`); } }