From 717058a189a85b4e25358a0a2e73f48a6acbd4b7 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 3 Sep 2026 13:30:38 -0500 Subject: [PATCH 1/5] fix(auth): explain managed-workspace claim rejections during login `clerk auth login` autoclaims the keyless app through the Platform API (`POST /v1/platform/accountless_applications/claim`). That endpoint returns 403 with the error code `accountless_application_managed_workspace` when the token's workspace is provisioned by an integration provider. The CLI mapped every 403 to `no_organization` and told the user their account had no active organization, which was wrong and gave no way forward. Read the error code from the parsed response body (`PlapiError.code`) and classify that case as a new `managed_workspace` result carrying the API's `long_message`. Login prints the provider named by the API, then the CLI's own instruction: switch to a workspace you own in the Clerk Dashboard and run `clerk auth login` again. The keyless breadcrumb is kept for that retry; every other 403 still maps to `no_organization` and clears it. Co-Authored-By: Claude Fable 5.1 --- .changeset/autoclaim-managed-workspace.md | 5 +++ .../cli-core/src/commands/auth/login.test.ts | 40 +++++++++++++++++- packages/cli-core/src/commands/auth/login.ts | 34 ++++++++++++++- packages/cli-core/src/lib/autoclaim.test.ts | 42 +++++++++++++++++++ packages/cli-core/src/lib/autoclaim.ts | 28 +++++++++++-- packages/cli-core/src/lib/next-steps.ts | 4 ++ 6 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 .changeset/autoclaim-managed-workspace.md diff --git a/.changeset/autoclaim-managed-workspace.md b/.changeset/autoclaim-managed-workspace.md new file mode 100644 index 000000000..8f459abeb --- /dev/null +++ b/.changeset/autoclaim-managed-workspace.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +`clerk auth login` now explains why a keyless application could not be claimed when the active workspace is managed by an integration such as Vercel Marketplace or Stripe. When the Platform API rejects the claim with the error code `accountless_application_managed_workspace`, the CLI prints "Unable to claim - this workspace is managed by Vercel. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again." instead of the generic "no active organization" warning, and keeps the local claim token so the next `clerk auth login` from a workspace you own claims the application. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index e04b01743..f053938aa 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe, afterEach, beforeEach, mock, spyOn } from "bun:test"; import { AuthError } from "../../lib/errors.ts"; import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; +import type { AutoclaimResult } from "../../lib/autoclaim.ts"; const actualConstants = await import("../../lib/constants.ts"); const actualEnvironment = await import("../../lib/environment.ts"); @@ -20,6 +21,9 @@ const mockIsHuman = mock(); const mockConfirm = mock(); const mockOpenBrowser = mock(); const mockEnsureFirstApplication = mock<() => Promise>(() => Promise.resolve()); +const mockAttemptAutoclaim = mock<() => Promise>(() => + Promise.resolve({ status: "not_keyless" }), +); mock.module("../../lib/credential-store.ts", () => ({ ...credentialStoreStubs, @@ -93,7 +97,7 @@ mock.module("../../lib/first-application.ts", () => ({ })); mock.module("../../lib/autoclaim.ts", () => ({ - attemptAutoclaim: async () => ({ status: "not_keyless" }), + attemptAutoclaim: () => mockAttemptAutoclaim(), })); const { setLogLevel } = await import("../../lib/log.ts"); @@ -127,6 +131,8 @@ describe("login", () => { mockOpenBrowser.mockReset(); mockEnsureFirstApplication.mockReset(); mockEnsureFirstApplication.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockReset(); + mockAttemptAutoclaim.mockResolvedValue({ status: "not_keyless" }); mockIsHuman.mockReturnValue(false); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); mockRevokeToken.mockResolvedValue("revoked"); @@ -588,6 +594,38 @@ describe("login", () => { expect(mockEnsureFirstApplication).toHaveBeenCalledTimes(1); }); + test("explains a managed-workspace claim rejection with the provider named by the API", async () => { + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + mockAttemptAutoclaim.mockResolvedValue({ + status: "managed_workspace", + longMessage: + "This workspace is managed by Vercel. Switch to a workspace you own and claim the application there.", + }); + + await runLogin(); + + expect(captured.err).toContain( + "Unable to claim - this workspace is managed by Vercel. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again.", + ); + expect(captured.err).not.toContain("claim the application there"); + expect(captured.err).not.toContain("does not have an active organization"); + }); + + test("falls back to a generic provider when the managed-workspace rejection has no long message", async () => { + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + mockAttemptAutoclaim.mockResolvedValue({ status: "managed_workspace", longMessage: null }); + + await runLogin(); + + expect(captured.err).toContain( + "Unable to claim - this workspace is managed by an integration provider. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again.", + ); + }); + test("does not call ensureFirstApplication when existing session is reused", async () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 29a80d12c..c2c82a164 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -224,6 +224,37 @@ const CLAIM_WARNINGS: Partial> = { "Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.", }; +const MANAGED_WORKSPACE_FALLBACK_REASON = "this workspace is managed by an integration provider."; + +/** + * The API's `long_message` reads "This workspace is managed by Vercel. Switch + * to a workspace you own and claim the application there." Only its first + * sentence is kept - it names the provider - and the CLI's own instruction + * replaces the second, so the user is not told to switch twice. + */ +function managedWorkspaceWarning(longMessage: string | null): string { + const reason = longMessage + ? lowercaseFirst(firstSentence(longMessage)) + : MANAGED_WORKSPACE_FALLBACK_REASON; + return `Unable to claim - ${reason} Switch to a workspace you own in the Clerk Dashboard, then run \`clerk auth login\` again.`; +} + +function firstSentence(text: string): string { + const trimmed = text.trim(); + const end = trimmed.indexOf(". "); + const sentence = end === -1 ? trimmed : trimmed.slice(0, end + 1); + return sentence.endsWith(".") ? sentence : `${sentence}.`; +} + +function lowercaseFirst(text: string): string { + return text.charAt(0).toLowerCase() + text.slice(1); +} + +function claimWarning(result: AutoclaimResult): string | undefined { + if (result.status === "managed_workspace") return managedWorkspaceWarning(result.longMessage); + return CLAIM_WARNINGS[result.status]; +} + async function handleAutoclaim(cwd: string): Promise { const result = await attemptAutoclaim(cwd); @@ -232,7 +263,7 @@ async function handleAutoclaim(cwd: string): Promise { log.success(`Claimed and linked application: \`${label}\``); } - const warning = CLAIM_WARNINGS[result.status]; + const warning = claimWarning(result); if (warning) log.warn(warning); return result; @@ -243,6 +274,7 @@ async function loginNextSteps(result: AutoclaimResult): Promise { const originalFetch = globalThis.fetch; let tempDir: string; @@ -124,6 +137,35 @@ describe("attemptAutoclaim", () => { expect(clearBreadcrumbSpy).toHaveBeenCalled(); }); + test("returns no_organization and clears breadcrumb on 403 with an unrelated error code", async () => { + withBreadcrumb("forbidden_token"); + const body = JSON.stringify({ + errors: [{ code: "authorization_invalid", message: "Request not allowed" }], + }); + stubFetch(async () => new Response(body, { status: 403 })); + + const result = await run(); + + expect(result.status).toBe("no_organization"); + expect(clearBreadcrumbSpy).toHaveBeenCalled(); + }); + + test("returns managed_workspace with the API long_message and preserves breadcrumb on 403 accountless_application_managed_workspace", async () => { + withBreadcrumb("managed_token"); + stubFetch(async () => new Response(MANAGED_WORKSPACE_BODY, { status: 403 })); + + const result = await run(); + + expect(result.status).toBe("managed_workspace"); + if (result.status === "managed_workspace") { + expect(result.longMessage).toBe(MANAGED_WORKSPACE_LONG_MESSAGE); + } + // The claim token must survive: the user switches workspace, then runs + // `clerk auth login` again to claim the same application. + expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + expect(linkAppSpy).not.toHaveBeenCalled(); + }); + test("returns failed (preserves breadcrumb) on 400 — could be recoverable (e.g. 401 re-login)", async () => { withBreadcrumb("bad_token"); stubFetch(async () => new Response("Bad Request", { status: 400 })); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index c71bf0533..b323f7e59 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -7,13 +7,24 @@ import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; type Claimed = { status: "claimed"; app: Application; envPulled: boolean }; +/** The claim can never succeed with this token; the breadcrumb is cleared. */ type Terminal = { status: "not_found" | "no_organization" }; +/** + * The token's workspace is provisioned by an integration (Vercel Marketplace, + * Stripe), so nothing can be claimed into it. The breadcrumb is kept: the user + * switches to a workspace they own and the next `clerk auth login` retries. + * `longMessage` is the API's `long_message`, which names the provider. + */ +type ManagedWorkspace = { status: "managed_workspace"; longMessage: string | null }; type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; -export type AutoclaimResult = Claimed | Terminal | Failed | Skipped; +export type AutoclaimResult = Claimed | Terminal | ManagedWorkspace | Failed | Skipped; -type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | Failed; +type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | ManagedWorkspace | Failed; + +/** PLAPI error code for a claim into a provider-managed workspace. */ +const MANAGED_WORKSPACE_CODE = "accountless_application_managed_workspace"; const TERMINAL_BY_STATUS: Record = { 404: "not_found", @@ -28,7 +39,7 @@ export async function attemptAutoclaim(cwd: string): Promise { const appName = await deriveProjectName(cwd); const result = await tryClaim(breadcrumb.claimToken, appName); - if (result.status === "failed") return result; + if (result.status === "failed" || result.status === "managed_workspace") return result; await clearKeylessBreadcrumb(cwd); @@ -75,7 +86,16 @@ async function tryPullEnv(): Promise { } } -function classifyClaimError(error: unknown): Terminal | Failed { +function classifyClaimError(error: unknown): Terminal | ManagedWorkspace | Failed { + if ( + error instanceof PlapiError && + error.status === 403 && + error.code === MANAGED_WORKSPACE_CODE + ) { + log.debug(`Claim returned 403 ${MANAGED_WORKSPACE_CODE}: classified as managed_workspace`); + return { status: "managed_workspace", longMessage: error.longMessage }; + } + if (error instanceof PlapiError && error.status in TERMINAL_BY_STATUS) { const status = TERMINAL_BY_STATUS[error.status]!; log.debug(`Claim returned ${error.status}: classified as ${status}`); diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index e447ddd2e..b2e217433 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -33,6 +33,10 @@ export const NEXT_STEPS = { "Run `clerk auth login` again to retry auto-claim", "Run `clerk link` to connect your application manually", ], + AUTOCLAIM_SWITCH_WORKSPACE: [ + "Switch to a workspace you own in the Clerk Dashboard", + "Run `clerk auth login` again to claim the application", + ], ENABLE_ORGS: [ "Run `clerk config schema --keys organization_settings` to see all available settings", "Run `clerk config pull --keys organization_settings` to see current values", From 30f24456fc4b46c738456767765b498fe454d9dc Mon Sep 17 00:00:00 2001 From: "Mitch (a.k.a Voz)" Date: Thu, 3 Sep 2026 16:10:49 -0500 Subject: [PATCH 2/5] Apply suggestion from @thiskevinwang Co-authored-by: Kevin Wang <26389321+thiskevinwang@users.noreply.github.com> --- packages/cli-core/src/lib/next-steps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index b2e217433..bc3ae7c71 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -34,8 +34,8 @@ export const NEXT_STEPS = { "Run `clerk link` to connect your application manually", ], AUTOCLAIM_SWITCH_WORKSPACE: [ - "Switch to a workspace you own in the Clerk Dashboard", - "Run `clerk auth login` again to claim the application", + "Select a different workspace in the Clerk Dashboard", + "Run `clerk auth login` again to proceed, ], ENABLE_ORGS: [ "Run `clerk config schema --keys organization_settings` to see all available settings", From 02c67eecb6e4eb37198da1e5f65e54bd1ebb4271 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 3 Sep 2026 16:20:39 -0500 Subject: [PATCH 3/5] fix(auth): close the string in the managed-workspace next steps The applied review suggestion dropped the closing quote on the second line. Co-Authored-By: Claude Fable 5.1 --- packages/cli-core/src/lib/next-steps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index bc3ae7c71..f0967af8f 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -35,7 +35,7 @@ export const NEXT_STEPS = { ], AUTOCLAIM_SWITCH_WORKSPACE: [ "Select a different workspace in the Clerk Dashboard", - "Run `clerk auth login` again to proceed, + "Run `clerk auth login` again to proceed", ], ENABLE_ORGS: [ "Run `clerk config schema --keys organization_settings` to see all available settings", From 6ed181b0bd58dbbc43a1d97041ab620c7d8ab0c1 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 3 Sep 2026 16:38:10 -0500 Subject: [PATCH 4/5] refactor(auth): print the managed-workspace API message verbatim Drop the sentence surgery and the unreachable fallback constant. The backend message now carries the full instruction, so the CLI shows it as-is after the usual "Unable to claim - " prefix. Co-Authored-By: Claude Fable 5.1 --- .changeset/autoclaim-managed-workspace.md | 2 +- .../cli-core/src/commands/auth/login.test.ts | 6 ++-- packages/cli-core/src/commands/auth/login.ts | 31 +++---------------- 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/.changeset/autoclaim-managed-workspace.md b/.changeset/autoclaim-managed-workspace.md index 8f459abeb..9bf6fa0f7 100644 --- a/.changeset/autoclaim-managed-workspace.md +++ b/.changeset/autoclaim-managed-workspace.md @@ -2,4 +2,4 @@ "clerk": patch --- -`clerk auth login` now explains why a keyless application could not be claimed when the active workspace is managed by an integration such as Vercel Marketplace or Stripe. When the Platform API rejects the claim with the error code `accountless_application_managed_workspace`, the CLI prints "Unable to claim - this workspace is managed by Vercel. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again." instead of the generic "no active organization" warning, and keeps the local claim token so the next `clerk auth login` from a workspace you own claims the application. +`clerk auth login` now explains why a keyless application could not be claimed when the active workspace is managed by an integration such as Vercel Marketplace or Stripe. When the Platform API rejects the claim with the error code `accountless_application_managed_workspace`, the CLI prints the API message, for example "Unable to claim - This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", instead of the generic "no active organization" warning, and keeps the local claim token so the next `clerk auth login` from a workspace you own claims the application. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index f053938aa..32326ef07 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -601,13 +601,13 @@ describe("login", () => { mockAttemptAutoclaim.mockResolvedValue({ status: "managed_workspace", longMessage: - "This workspace is managed by Vercel. Switch to a workspace you own and claim the application there.", + "This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", }); await runLogin(); expect(captured.err).toContain( - "Unable to claim - this workspace is managed by Vercel. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again.", + "Unable to claim - This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", ); expect(captured.err).not.toContain("claim the application there"); expect(captured.err).not.toContain("does not have an active organization"); @@ -622,7 +622,7 @@ describe("login", () => { await runLogin(); expect(captured.err).toContain( - "Unable to claim - this workspace is managed by an integration provider. Switch to a workspace you own in the Clerk Dashboard, then run `clerk auth login` again.", + "Unable to claim - this workspace is managed by an integration provider.", ); }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c2c82a164..6ca812172 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -224,34 +224,11 @@ const CLAIM_WARNINGS: Partial> = { "Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.", }; -const MANAGED_WORKSPACE_FALLBACK_REASON = "this workspace is managed by an integration provider."; - -/** - * The API's `long_message` reads "This workspace is managed by Vercel. Switch - * to a workspace you own and claim the application there." Only its first - * sentence is kept - it names the provider - and the CLI's own instruction - * replaces the second, so the user is not told to switch twice. - */ -function managedWorkspaceWarning(longMessage: string | null): string { - const reason = longMessage - ? lowercaseFirst(firstSentence(longMessage)) - : MANAGED_WORKSPACE_FALLBACK_REASON; - return `Unable to claim - ${reason} Switch to a workspace you own in the Clerk Dashboard, then run \`clerk auth login\` again.`; -} - -function firstSentence(text: string): string { - const trimmed = text.trim(); - const end = trimmed.indexOf(". "); - const sentence = end === -1 ? trimmed : trimmed.slice(0, end + 1); - return sentence.endsWith(".") ? sentence : `${sentence}.`; -} - -function lowercaseFirst(text: string): string { - return text.charAt(0).toLowerCase() + text.slice(1); -} - function claimWarning(result: AutoclaimResult): string | undefined { - if (result.status === "managed_workspace") return managedWorkspaceWarning(result.longMessage); + if (result.status === "managed_workspace") { + // The API message already names the provider and says what to do. + return `Unable to claim - ${result.longMessage ?? "this workspace is managed by an integration provider."}`; + } return CLAIM_WARNINGS[result.status]; } From 5111a80513bb78f2a99b0887ad90c985f8e7b70e Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 3 Sep 2026 16:50:04 -0500 Subject: [PATCH 5/5] test(auth): match the generic managed-workspace API message Co-Authored-By: Claude Fable 5.1 --- .changeset/autoclaim-managed-workspace.md | 2 +- packages/cli-core/src/commands/auth/login.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/autoclaim-managed-workspace.md b/.changeset/autoclaim-managed-workspace.md index 9bf6fa0f7..0323cb693 100644 --- a/.changeset/autoclaim-managed-workspace.md +++ b/.changeset/autoclaim-managed-workspace.md @@ -2,4 +2,4 @@ "clerk": patch --- -`clerk auth login` now explains why a keyless application could not be claimed when the active workspace is managed by an integration such as Vercel Marketplace or Stripe. When the Platform API rejects the claim with the error code `accountless_application_managed_workspace`, the CLI prints the API message, for example "Unable to claim - This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", instead of the generic "no active organization" warning, and keeps the local claim token so the next `clerk auth login` from a workspace you own claims the application. +`clerk auth login` now explains why a keyless application could not be claimed when the active workspace is managed by an integration such as Vercel Marketplace or Stripe. When the Platform API rejects the claim with the error code `accountless_application_managed_workspace`, the CLI prints the API message, for example "Unable to claim - The target application cannot be claimed into the current workspace. Select a different workspace and try again.", instead of the generic "no active organization" warning, and keeps the local claim token so the next `clerk auth login` from a workspace you own claims the application. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 32326ef07..9518f17b9 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -601,13 +601,13 @@ describe("login", () => { mockAttemptAutoclaim.mockResolvedValue({ status: "managed_workspace", longMessage: - "This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", + "The target application cannot be claimed into the current workspace. Select a different workspace and try again.", }); await runLogin(); expect(captured.err).toContain( - "Unable to claim - This workspace is managed by Vercel. Select a different workspace in the Clerk Dashboard and try again.", + "Unable to claim - The target application cannot be claimed into the current workspace. Select a different workspace and try again.", ); expect(captured.err).not.toContain("claim the application there"); expect(captured.err).not.toContain("does not have an active organization");