Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plapi-forbidden-role-message.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion .claude/rules/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
81 changes: 81 additions & 0 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down
90 changes: 89 additions & 1 deletion packages/cli-core/src/lib/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, describe } from "bun:test";
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import {
PlapiError,
FapiError,
Expand All @@ -7,6 +7,7 @@ import {
BILLING_ERROR_REASON,
CliError,
ERROR_CODE,
platformPermissionProblem,
} from "./errors.ts";

describe("ApiError envelope parsing (via PlapiError.fromBody)", () => {
Expand Down Expand Up @@ -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<string, unknown>) =>
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();
});
});
74 changes: 74 additions & 0 deletions packages/cli-core/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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) ||
Expand Down
30 changes: 29 additions & 1 deletion packages/cli-core/src/lib/first-application.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,7 +16,7 @@ mock.module("./plapi.ts", () => ({
const { ensureFirstApplication } = await import("./first-application.ts");

describe("ensureFirstApplication", () => {
useCaptureLog();
const captured = useCaptureLog();

beforeEach(() => {
mockListApplications.mockReset();
Expand Down Expand Up @@ -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");
});
});
8 changes: 7 additions & 1 deletion packages/cli-core/src/lib/first-application.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand All @@ -13,7 +14,12 @@ export async function ensureFirstApplication(): Promise<void> {
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)}`);
}
}
Loading