From 69106006c3b2981c3dbc2b0a3b5dc8950f796261 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 23:50:22 -0400 Subject: [PATCH 1/7] fix(config): stop calling a claimed application unclaimed when it is reached by its secret key Any `sk_` key in the environment or on disk takes the accountless path when there is no --app or linked profile, claimed application or not, so `clerk config patch` with CLERK_SECRET_KEY for a claimed dev instance said "Unsupported config key for an unclaimed accountless application" and told the user to claim it. A refusal now reads GET /v1/instance once: a non-null workspace_id means the application belongs to an account, and the patch, pull-keys, instance-field, put, schema and --instance refusals then say "with only a secret key" and to run `clerk auth login` and `clerk link` (or pass --app). A failed lookup keeps the accountless wording, and a valid payload makes no extra request. No Backend API route changes username, phone or email sign-up settings, so those still need the account. Co-Authored-By: Claude Opus 5.5 --- .changeset/config-claimed-secret-key.md | 5 ++ .../cli-core/src/commands/config/README.md | 2 + .../src/commands/config/apply-patch.ts | 2 +- packages/cli-core/src/commands/config/io.ts | 9 ++- .../src/commands/config/keyless.test.ts | 79 ++++++++++++++++++- .../cli-core/src/commands/config/keyless.ts | 52 +++++++++--- packages/cli-core/src/commands/config/push.ts | 13 +-- .../cli-core/src/commands/config/schema.ts | 11 ++- packages/cli-core/src/lib/copy.ts | 69 +++++++++++----- packages/cli-core/src/lib/keyless-target.ts | 26 +++++- 10 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 .changeset/config-claimed-secret-key.md diff --git a/.changeset/config-claimed-secret-key.md b/.changeset/config-claimed-secret-key.md new file mode 100644 index 000000000..2f05b10b9 --- /dev/null +++ b/.changeset/config-claimed-secret-key.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, `clerk config patch`, `put`, `schema` and `--instance` refusals now check whether the application is claimed, and if it is, say to run `clerk auth login` and then `clerk link` or pass `--app `, instead of telling you to claim it. diff --git a/packages/cli-core/src/commands/config/README.md b/packages/cli-core/src/commands/config/README.md index 8aed97d61..a2e029a69 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -188,6 +188,8 @@ Both must hold, otherwise the account-authenticated path runs unchanged: When credentials _are_ present and the directory simply isn't linked, the command prints a warning that it's using the reduced key-based view and points at `clerk link`, so the narrower output is never a silent surprise. +The same path takes a secret key for a **claimed** application, since the key alone doesn't say which it is. A refusal (an unsupported key, `put`, `schema`, `--instance`) reads `GET /v1/instance` once to find out: a non-null `workspace_id` means the application belongs to an account, and the refusal then says to run `clerk auth login` and `clerk link` (or pass `--app `) rather than to claim it. Only a refusal makes that request, and a failed lookup keeps the accountless wording. + The secret key is resolved the way the app itself would resolve one, and this order is shared by every accountless-capable command (`lib/keyless-target.ts`): 1. `CLERK_SECRET_KEY`, or the framework's secret key variable (e.g. `NUXT_CLERK_SECRET_KEY`), in the environment diff --git a/packages/cli-core/src/commands/config/apply-patch.ts b/packages/cli-core/src/commands/config/apply-patch.ts index 7883a26dc..5dfe79a06 100644 --- a/packages/cli-core/src/commands/config/apply-patch.ts +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -31,7 +31,7 @@ export interface ApplyPatchOptions { export async function applyConfigPatch(opts: ApplyPatchOptions): Promise { const { target, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts; - assertPayloadWritable(target, payload); + await assertPayloadWritable(target, payload); const current = opts.currentConfig ?? diff --git a/packages/cli-core/src/commands/config/io.ts b/packages/cli-core/src/commands/config/io.ts index 75d12da89..2ff528497 100644 --- a/packages/cli-core/src/commands/config/io.ts +++ b/packages/cli-core/src/commands/config/io.ts @@ -14,6 +14,7 @@ import { assertKeylessPayload, patchKeylessConfig, readCurrentGroups, + withClaimedWording, type KeylessWriteVerification, } from "./keyless.ts"; @@ -35,11 +36,13 @@ export interface WriteResult { * prompt is shown. Only the keyless path constrains the payload — the account * API validates the document server-side. */ -export function assertPayloadWritable( +export async function assertPayloadWritable( target: InstanceTarget, payload: Record, -): void { - if (target.kind === "keyless") assertKeylessPayload(payload); +): Promise { + if (target.kind === "keyless") { + await withClaimedWording(target.keyless, (claimed) => assertKeylessPayload(payload, claimed)); + } } /** diff --git a/packages/cli-core/src/commands/config/keyless.test.ts b/packages/cli-core/src/commands/config/keyless.test.ts index 497de6991..a71251c2b 100644 --- a/packages/cli-core/src/commands/config/keyless.test.ts +++ b/packages/cli-core/src/commands/config/keyless.test.ts @@ -289,6 +289,24 @@ describe("keyless config", () => { ); }); + test("rejects --instance for a claimed application without calling it unclaimed", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + stubFetch( + async () => + new Response(JSON.stringify({ ...INSTANCE, workspace_id: "org_123" }), { status: 200 }), + ); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + const error = await resolveKeylessTarget({ instance: "prod", cwd: projectDir }).catch( + (e: unknown) => e, + ); + + expect((error as Error).message).toContain( + "--instance is not supported with only a secret key", + ); + expect((error as Error).message).not.toContain("unclaimed"); + }); + test("rejects a key that is not a secret key", async () => { await writeEnv(".env", "CLERK_SECRET_KEY=pk_test_not_a_secret\n"); const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); @@ -661,10 +679,13 @@ describe("keyless config", () => { expect(captured.err).toContain("[dry-run] Nothing sent"); }); + // The refusal reads the instance once, to word itself; nothing else is sent. test("patch rejects unreachable keys before showing a diff or touching the API", async () => { - let requested = false; - stubFetch(async () => { - requested = true; + const requests: string[] = []; + stubFetch(async (input, init) => { + requests.push( + `${(init?.method ?? "GET").toUpperCase()} ${new URL(input.toString()).pathname}`, + ); return new Response(JSON.stringify(INSTANCE), { status: 200 }); }); const { configPatch } = await import("./push.ts"); @@ -672,10 +693,60 @@ describe("keyless config", () => { await expect(configPatch({ json: '{"session":{"lifetime":1}}', yes: true })).rejects.toThrow( /Unsupported config key/, ); - expect(requested).toBe(false); + expect(requests).toEqual(["GET /v1/instance"]); expect(captured.err).not.toContain("Updating config"); }); + /** Answers GET /v1/instance with this `workspace_id`, or fails it. */ + const stubWorkspace = (workspaceId: string | null | "fail") => + stubFetch(async () => + workspaceId === "fail" + ? new Response("{}", { status: 500 }) + : new Response(JSON.stringify({ ...INSTANCE, workspace_id: workspaceId }), { + status: 200, + }), + ); + const usernamePatch = '{"auth_username":{"allow_extended_special_characters":true}}'; + + // A secret key for a claimed application takes this path too. + test("patch does not call a claimed application unclaimed, and says how to reach the setting", async () => { + stubWorkspace("org_123"); + const { configPatch } = await import("./push.ts"); + + const error = await configPatch({ json: usernamePatch, yes: true }).catch((e: unknown) => e); + + const message = (error as Error).message; + expect(message).toContain("Unsupported config key with only a secret key: auth_username"); + expect(message).not.toContain("unclaimed"); + expect(message).toContain("`clerk auth login`, then `clerk link` or pass `--app `"); + expect(message).toContain("Supported top-level keys: instance"); + }); + + test.each([ + ["unclaimed", null], + ["unknown, when the lookup fails", "fail"], + ] as const)( + "patch keeps the accountless wording when the application is %s", + async (_label, id) => { + stubWorkspace(id); + const { configPatch } = await import("./push.ts"); + + await expect(configPatch({ json: usernamePatch, yes: true })).rejects.toThrow( + /for an unclaimed accountless application: auth_username/, + ); + }, + ); + + test("put on a claimed application points at the account, not at claiming it", async () => { + stubWorkspace("org_123"); + const { configPut } = await import("./push.ts"); + + const error = await configPut({ json: "{}", yes: true }).catch((e: unknown) => e); + + expect((error as Error).message).toContain("needs your account, not the secret key"); + expect((error as Error).message).not.toContain("unclaimed"); + }); + test("patch rejects config keys BAPI cannot reach", async () => { const { configPatch } = await import("./push.ts"); diff --git a/packages/cli-core/src/commands/config/keyless.ts b/packages/cli-core/src/commands/config/keyless.ts index f05de4852..731061994 100644 --- a/packages/cli-core/src/commands/config/keyless.ts +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -13,8 +13,8 @@ */ import { bapiRequest } from "../../lib/bapi.ts"; -import { ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; -import type { KeylessTarget } from "../../lib/keyless-target.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { isClaimedKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts"; import { keylessCopy } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; @@ -116,12 +116,12 @@ const ACCOUNT_ONLY_INSTANCE_FIELDS: Record = { * Rejects `instance` fields BAPI would silently discard, before the request is * sent. Runs only for that group — see `INSTANCE_FIELDS`. */ -function assertInstanceFields(fields: Record): void { +function assertInstanceFields(fields: Record, claimed?: boolean): void { const unknown = Object.keys(fields).filter((field) => !INSTANCE_FIELDS.has(field)); if (unknown.length === 0) return; const lines = [ - keylessCopy.unsupportedInstanceFieldsLine(unknown), + keylessCopy.unsupportedInstanceFieldsLine(unknown, claimed), keylessCopy.supportedInstanceFieldsLine([...INSTANCE_FIELDS]), ]; @@ -134,7 +134,7 @@ function assertInstanceFields(fields: Record): void { ), ]; for (const reason of reasons) { - lines.push(keylessCopy.noRouteForInstanceFieldLine(reason)); + lines.push(keylessCopy.noRouteForInstanceFieldLine(reason, claimed)); } throwUsageError(lines.join("\n")); @@ -144,17 +144,23 @@ function assertInstanceFields(fields: Record): void { * Validates caller-supplied names once, at the boundary, so everything * downstream works with a known group instead of re-checking strings. */ -function asGroupNames(names: string[]): KeylessGroup[] { +function asGroupNames(names: string[], claimed?: boolean): KeylessGroup[] { const unknown = names.filter((name) => !isGroupName(name)); if (unknown.length > 0) { - throwUsageError(keylessCopy.unsupportedConfigKeys(unknown, KEYLESS_GROUP_NAMES)); + throwUsageError(keylessCopy.unsupportedConfigKeys(unknown, KEYLESS_GROUP_NAMES, claimed)); } return names.filter(isGroupName); } -/** Rejects payload keys that don't name a BAPI resource, before anything is sent. */ +/** + * Rejects payload keys that don't name a BAPI resource, before anything is sent. + * + * @param claimed - Whether the key's application is claimed, which only + * changes how a refusal reads. See `isClaimedKeylessTarget`. + */ export function assertKeylessPayload( payload: Record, + claimed?: boolean, ): asserts payload is Record> { const unknown = Object.keys(payload).filter((key) => !(key in KEYLESS_GROUPS)); if (unknown.length > 0) { @@ -162,19 +168,19 @@ export function assertKeylessPayload( const accountOnly = unknown.filter((key) => !API_REACHABLE_KEYLESS_KEYS.has(key)); const lines = [ - keylessCopy.unsupportedPayloadKeysLine(unknown), + keylessCopy.unsupportedPayloadKeysLine(unknown, claimed), keylessCopy.supportedPayloadKeysLine(KEYLESS_GROUP_NAMES), ]; // Point these at `clerk api` — they're reachable today, and claiming the // application wouldn't move them into the config document anyway. if (apiReachable.length > 0) { - lines.push(keylessCopy.apiReachableKeysLine(apiReachable)); + lines.push(keylessCopy.apiReachableKeysLine(apiReachable, claimed)); } // Everything left really is part of the account-mode config document. if (accountOnly.length > 0) { - lines.push(keylessCopy.claimForFullConfigLine()); + lines.push(keylessCopy.claimForFullConfigLine(claimed)); } throwUsageError(lines.join("\n")); @@ -187,7 +193,25 @@ export function assertKeylessPayload( } if (payload.instance) { - assertInstanceFields(payload.instance as Record); + assertInstanceFields(payload.instance as Record, claimed); + } +} + +/** + * Runs a check worded for an unclaimed application, and when it refuses, asks + * Clerk whether the application is claimed and runs it again worded for that. + * Only a refusal costs the extra request. + */ +export async function withClaimedWording( + target: KeylessTarget, + check: (claimed?: boolean) => T, +): Promise { + try { + return check(); + } catch (error) { + if (!(error instanceof CliError)) throw error; + if ((await isClaimedKeylessTarget(target)) === true) check(true); + throw error; } } @@ -200,7 +224,9 @@ export async function pullKeylessConfig( target: KeylessTarget, keys?: string[], ): Promise> { - const requested = keys?.length ? asGroupNames(keys) : KEYLESS_GROUP_NAMES; + const requested = keys?.length + ? await withClaimedWording(target, (claimed) => asGroupNames(keys, claimed)) + : KEYLESS_GROUP_NAMES; // Only worth saying when the caller named a write-only group. A default pull // asks for everything, and reporting the same omission every time is noise. diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 3406208f8..4613db7b3 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -7,7 +7,7 @@ import { closeStatusForError } from "../../lib/signals.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { keylessCopy } from "../../lib/copy.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; -import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; +import { isClaimedKeylessTarget, resolveInstanceTarget } from "../../lib/keyless-target.ts"; import { credentialPlaceholder, credentialPlaceholderPaths } from "../../lib/oauth-config.ts"; import type { KeylessWriteVerification } from "./keyless.ts"; import { REDACTED, redactSecrets, redactedSecretPaths } from "./redact.ts"; @@ -62,15 +62,18 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise // Same shape as config push/put: resolve the target once, branch on kind. const target = await resolveInstanceTarget(options); if (target.kind === "keyless") { - throw new CliError(keylessCopy.schemaNeedsClaimedApplication(), { - code: ERROR_CODE.AUTH_REQUIRED, - }); + throw new CliError( + keylessCopy.schemaNeedsClaimedApplication(await isClaimedKeylessTarget(target.keyless)), + { + code: ERROR_CODE.AUTH_REQUIRED, + }, + ); } const ctx = target.ctx; diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index e8c12933e..7016fb0e9 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -13,35 +13,56 @@ * identifiers (`lib/log.ts` renders them cyan), and a refusal names the * reason and a working alternative rather than just saying no. */ +/** + * What a secret-key target is called in a refusal. Any `sk_` key found on disk + * or in the environment takes the secret-key path, claimed application or + * not, so `claimed` (from `isClaimedKeylessTarget`) picks the wording. + */ +const keyTarget = (claimed?: boolean): string => + claimed ? "with only a secret key" : "for an unclaimed accountless application"; + +/** The way to the full config document, for a claimed application reached by its key. */ +const loginForClaimed = + "This application is claimed: run `clerk auth login`, then `clerk link` or pass `--app `, so the command uses your account instead of the secret key."; + export const keylessCopy = { // --- config payload validation (config/keyless.ts) --- - unsupportedConfigKeys: (unknown: string[], supported: readonly string[]): string => - `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed accountless application: ${unknown.join(", ")}.\n` + - `Supported keys: ${supported.join(", ")}.`, + unsupportedConfigKeys: ( + unknown: string[], + supported: readonly string[], + claimed?: boolean, + ): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(claimed)}: ${unknown.join(", ")}.\n` + + `Supported keys: ${supported.join(", ")}.` + + (claimed ? `\n${loginForClaimed}` : ""), - unsupportedPayloadKeysLine: (unknown: string[]): string => - `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed accountless application: ${unknown.join(", ")}.`, + unsupportedPayloadKeysLine: (unknown: string[], claimed?: boolean): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(claimed)}: ${unknown.join(", ")}.`, supportedPayloadKeysLine: (supported: readonly string[]): string => `Supported top-level keys: ${supported.join(", ")}.`, - apiReachableKeysLine: (keys: string[]): string => - `${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} already reachable on an unclaimed application — use ${keys.map((key) => `\`clerk api /${key}\``).join(" or ")} directly instead of this config document.`, + apiReachableKeysLine: (keys: string[], claimed?: boolean): string => + `${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} already reachable ${claimed ? "with the secret key" : "on an unclaimed application"} — use ${keys.map((key) => `\`clerk api /${key}\``).join(" or ")} directly instead of this config document.`, - claimForFullConfigLine: (): string => - "Run `clerk auth login` to claim the application and use the full config document.", + claimForFullConfigLine: (claimed?: boolean): string => + claimed + ? loginForClaimed + : "Run `clerk auth login` to claim the application and use the full config document.", configKeyMustBeObject: (key: string): string => `Config key \`${key}\` must be a JSON object.`, - unsupportedInstanceFieldsLine: (unknown: string[]): string => - `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` for an unclaimed accountless application: ${unknown.join(", ")}.`, + unsupportedInstanceFieldsLine: (unknown: string[], claimed?: boolean): string => + `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` ${keyTarget(claimed)}: ${unknown.join(", ")}.`, supportedInstanceFieldsLine: (fields: readonly string[]): string => `Supported fields: ${fields.join(", ")}.`, - noRouteForInstanceFieldLine: (reason: string): string => - `Clerk's Backend API has no route for ${reason}, so this can't be changed from an unclaimed application at all — claim it first with \`clerk auth login\`.`, + noRouteForInstanceFieldLine: (reason: string, claimed?: boolean): string => + claimed + ? `Clerk's Backend API has no route for ${reason}, so a secret key can't change it. ${loginForClaimed}` + : `Clerk's Backend API has no route for ${reason}, so this can't be changed from an unclaimed application at all — claim it first with \`clerk auth login\`.`, // --- "needs a claimed application" refusals --- @@ -49,13 +70,23 @@ export const keylessCopy = { "Billing can only be configured on a claimed application — Clerk's Backend API has no billing settings, so an unclaimed accountless application can't reach them.\n" + "Run `clerk auth login` to claim this application, then re-run the command.", - schemaNeedsClaimedApplication: (): string => - "Config schema is only available for a claimed application — the schema describes the account-level config document, which an unclaimed accountless application has no access to.\n" + - "Run `clerk auth login` to claim this application, then re-run `clerk config schema`.", + schemaNeedsClaimedApplication: (claimed?: boolean): string => + claimed + ? `Config schema comes from your account, not the secret key — it describes the account-level config document.\n${loginForClaimed}` + : "Config schema is only available for a claimed application — the schema describes the account-level config document, which an unclaimed accountless application has no access to.\n" + + "Run `clerk auth login` to claim this application, then re-run `clerk config schema`.", + + putNeedsClaimedApplication: (claimed?: boolean): string => + claimed + ? `Replacing the entire configuration needs your account, not the secret key.\n${loginForClaimed} Or use \`clerk config patch\` to update the settings a secret key reaches.` + : "Replacing the entire configuration is only available for a claimed application — an unclaimed accountless application has no full config document to replace.\n" + + "Use `clerk config patch` to update individual settings, or run `clerk auth login` to claim the application first.", - putNeedsClaimedApplication: (): string => - "Replacing the entire configuration is only available for a claimed application — an unclaimed accountless application has no full config document to replace.\n" + - "Use `clerk config patch` to update individual settings, or run `clerk auth login` to claim the application first.", + instanceFlagWithKey: (source: string, claimed?: boolean): string => + claimed + ? `--instance is not supported with only a secret key: the key in ${source} already targets its own instance.\n${loginForClaimed}` + : `--instance is not supported for an unclaimed accountless application: the secret key in ${source} already targets its own instance.\n` + + "Run `clerk auth login` to claim the application, then target instances by name.", userDashboardNeedsClaim: (keySource: string, userId: string): string => `This directory holds an unclaimed accountless application (secret key from ${keySource}), which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + diff --git a/packages/cli-core/src/lib/keyless-target.ts b/packages/cli-core/src/lib/keyless-target.ts index 7accee935..2e34eaa88 100644 --- a/packages/cli-core/src/lib/keyless-target.ts +++ b/packages/cli-core/src/lib/keyless-target.ts @@ -15,6 +15,7 @@ import { parseEnvFile } from "./dotenv.ts"; import { CliError, ERROR_CODE, throwUsageError } from "./errors.ts"; import { decodePublishableKey } from "./fapi.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; +import { keylessCopy } from "./copy.ts"; import { log } from "./log.ts"; export interface KeylessTarget { @@ -204,6 +205,28 @@ export async function hasKeyPairMismatch( return !matchesADomain; } +/** + * Whether Clerk says the key's application belongs to an account: GET + * /v1/instance's `workspace_id` is null until it is claimed. Any `sk_` key + * takes the secret-key path, claimed or not, so a refusal asks this before + * calling the application unclaimed. `undefined` when Clerk can't be asked, or + * doesn't say; callers keep the accountless wording then. + */ +export async function isClaimedKeylessTarget(target: KeylessTarget): Promise { + try { + const { body } = await bapiRequest({ + method: "GET", + path: "/v1/instance", + secretKey: target.secretKey, + }); + const workspaceId = (body as { workspace_id?: unknown } | undefined)?.workspace_id; + if (workspaceId === undefined) return undefined; + return typeof workspaceId === "string" && workspaceId.length > 0; + } catch { + return undefined; + } +} + /** * Resolves the keyless target for a command, or `undefined` when the * account-authenticated path applies. @@ -238,8 +261,7 @@ export async function resolveKeylessTarget(options: { // instance to choose between. if (options.instance) { throwUsageError( - `--instance is not supported for an unclaimed accountless application: the secret key in ${target.source} already targets its own instance.\n` + - "Run `clerk auth login` to claim the application, then target instances by name.", + keylessCopy.instanceFlagWithKey(target.source, await isClaimedKeylessTarget(target)), ); } From 5454a8089239ca9909c8662b49d24668eae5f5f7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 00:00:28 -0400 Subject: [PATCH 2/7] fix(config): word a secret-key refusal for a claimed, unclaimed or unknown application A claimed application's refusal now says it belongs to a Clerk account, that a secret key can't change the keys it names, and to pass --app --instance (or run `clerk link`) with `clerk auth login` or CLERK_PLATFORM_API_KEY, since a login alone isn't enough while the secret key takes precedence. An unclaimed one keeps the claim-the-app copy. When GET /v1/instance fails, the refusal covers both cases rather than calling the application unclaimed. `clerk config pull` of a claimed application warns what the secret key left out, reading workspace_id from the instance group it already pulls. Co-Authored-By: Claude Opus 5.5 --- .changeset/config-claimed-secret-key.md | 2 +- .../cli-core/src/commands/config/README.md | 8 +- .../src/commands/config/keyless.test.ts | 87 ++++++++++--- .../cli-core/src/commands/config/keyless.ts | 54 +++++--- packages/cli-core/src/commands/config/push.ts | 11 +- .../cli-core/src/commands/config/schema.ts | 4 +- packages/cli-core/src/lib/copy.ts | 115 +++++++++++++----- packages/cli-core/src/lib/keyless-target.ts | 11 +- 8 files changed, 205 insertions(+), 87 deletions(-) diff --git a/.changeset/config-claimed-secret-key.md b/.changeset/config-claimed-secret-key.md index 2f05b10b9..a4e1cc90d 100644 --- a/.changeset/config-claimed-secret-key.md +++ b/.changeset/config-claimed-secret-key.md @@ -2,4 +2,4 @@ "clerk": patch --- -Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, `clerk config patch`, `put`, `schema` and `--instance` refusals now check whether the application is claimed, and if it is, say to run `clerk auth login` and then `clerk link` or pass `--app `, instead of telling you to claim it. +Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, a `clerk config patch`, `put`, `schema` or `--instance` refusal now asks Clerk whether the application belongs to an account. If it does, it says a secret key can't change those settings, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`; a login alone isn't enough while the secret key is set. If Clerk can't say, the message covers both cases. `clerk config pull` of a claimed application warns what the secret key left out. diff --git a/packages/cli-core/src/commands/config/README.md b/packages/cli-core/src/commands/config/README.md index a2e029a69..1a6f79d99 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -188,7 +188,13 @@ Both must hold, otherwise the account-authenticated path runs unchanged: When credentials _are_ present and the directory simply isn't linked, the command prints a warning that it's using the reduced key-based view and points at `clerk link`, so the narrower output is never a silent surprise. -The same path takes a secret key for a **claimed** application, since the key alone doesn't say which it is. A refusal (an unsupported key, `put`, `schema`, `--instance`) reads `GET /v1/instance` once to find out: a non-null `workspace_id` means the application belongs to an account, and the refusal then says to run `clerk auth login` and `clerk link` (or pass `--app `) rather than to claim it. Only a refusal makes that request, and a failed lookup keeps the accountless wording. +The same path takes a secret key for a **claimed** application, since the key alone doesn't say which it is, and the precedence above doesn't change for it. A refusal (an unsupported key or `instance` field, `put`, `schema`, `--instance`) reads `GET /v1/instance` once to word itself, and only a refusal makes that request: + +- `workspace_id` null: the application is unclaimed, and the refusal says to claim it with `clerk auth login`. +- `workspace_id` set: the application belongs to an account. The refusal says a secret key can't change those keys, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. A login alone isn't enough while the secret key is found, since it takes precedence. +- The lookup fails: the refusal gives both, and never calls the application unclaimed. + +A `config pull` of a claimed application warns that the secret key read only some groups, worded the same way. It reads `workspace_id` from the `instance` group it pulls, so it costs no extra request when that group is included. The secret key is resolved the way the app itself would resolve one, and this order is shared by every accountless-capable command (`lib/keyless-target.ts`): diff --git a/packages/cli-core/src/commands/config/keyless.test.ts b/packages/cli-core/src/commands/config/keyless.test.ts index a71251c2b..9cc9c8a30 100644 --- a/packages/cli-core/src/commands/config/keyless.test.ts +++ b/packages/cli-core/src/commands/config/keyless.test.ts @@ -22,7 +22,13 @@ mock.module("../../lib/spinner.ts", () => ({ const SECRET_KEY = "sk_test_keyless"; const BAPI_URL = "https://test-bapi.clerk.com"; -const INSTANCE = { object: "instance", id: "ins_1", support_email: "old@example.com" }; +// `workspace_id` is null until the application is claimed. +const INSTANCE = { + object: "instance", + id: "ins_1", + support_email: "old@example.com", + workspace_id: null, +}; const ORG_SETTINGS = { object: "organization_settings", enabled: false }; const COMMUNICATION = { object: "instance_communication", blocked_country_codes: [] }; const PROTECT = { object: "instance_protect", rules_enabled: false }; @@ -709,33 +715,53 @@ describe("keyless config", () => { const usernamePatch = '{"auth_username":{"allow_extended_special_characters":true}}'; // A secret key for a claimed application takes this path too. - test("patch does not call a claimed application unclaimed, and says how to reach the setting", async () => { + test("patch tells a claimed application how to reach the setting, without calling it unclaimed", async () => { stubWorkspace("org_123"); const { configPatch } = await import("./push.ts"); const error = await configPatch({ json: usernamePatch, yes: true }).catch((e: unknown) => e); const message = (error as Error).message; - expect(message).toContain("Unsupported config key with only a secret key: auth_username"); expect(message).not.toContain("unclaimed"); - expect(message).toContain("`clerk auth login`, then `clerk link` or pass `--app `"); + expect(message).toContain("Unsupported config key with only a secret key: auth_username"); expect(message).toContain("Supported top-level keys: instance"); + expect(message).toContain( + "This application belongs to a Clerk account, so a secret key can't change auth_username.", + ); + expect(message).toContain("Pass `--app --instance ` (or run `clerk link`)"); + expect(message).toContain("`clerk auth login` or `CLERK_PLATFORM_API_KEY`"); + expect(message).toContain("A login alone isn't enough while the secret key from .env"); }); - test.each([ - ["unclaimed", null], - ["unknown, when the lookup fails", "fail"], - ] as const)( - "patch keeps the accountless wording when the application is %s", - async (_label, id) => { - stubWorkspace(id); - const { configPatch } = await import("./push.ts"); - - await expect(configPatch({ json: usernamePatch, yes: true })).rejects.toThrow( - /for an unclaimed accountless application: auth_username/, - ); - }, - ); + test("patch keeps the claim-the-app wording for an unclaimed application", async () => { + stubWorkspace(null); + const { configPatch } = await import("./push.ts"); + + const error = await configPatch({ json: usernamePatch, yes: true }).catch((e: unknown) => e); + + const message = (error as Error).message; + expect(message).toContain("for an unclaimed accountless application: auth_username"); + expect(message).toContain("Run `clerk auth login` to claim the application"); + expect(message).not.toContain("belongs to a Clerk account"); + }); + + // Without an answer, the message covers both, and never calls it unclaimed. + test("patch covers both cases when Clerk can't say whether the application is claimed", async () => { + stubWorkspace("fail"); + const { configPatch } = await import("./push.ts"); + + const error = await configPatch({ json: usernamePatch, yes: true }).catch((e: unknown) => e); + + const message = (error as Error).message; + expect(message).toContain("Unsupported config key with only a secret key: auth_username"); + expect(message).toContain( + "If this application is unclaimed, run `clerk auth login` to claim it", + ); + expect(message).toContain( + "If it belongs to a Clerk account, a secret key can't change auth_username.", + ); + expect(message).not.toContain("for an unclaimed accountless application"); + }); test("put on a claimed application points at the account, not at claiming it", async () => { stubWorkspace("org_123"); @@ -743,8 +769,29 @@ describe("keyless config", () => { const error = await configPut({ json: "{}", yes: true }).catch((e: unknown) => e); - expect((error as Error).message).toContain("needs your account, not the secret key"); - expect((error as Error).message).not.toContain("unclaimed"); + const message = (error as Error).message; + expect(message).toContain("a secret key can't replace the whole config"); + expect(message).not.toContain("unclaimed"); + }); + + test("pull of a claimed application says what the secret key left out", async () => { + bapiState["/v1/instance"] = { ...INSTANCE, workspace_id: "org_123" }; + const { configPull } = await import("./pull.ts"); + + await configPull({}); + + expect(captured.err).toContain( + "This application belongs to a Clerk account, but the secret key from .env only reads instance", + ); + expect(captured.err).toContain("A login alone isn't enough"); + }); + + test("pull of an unclaimed application says nothing extra", async () => { + const { configPull } = await import("./pull.ts"); + + await configPull({}); + + expect(captured.err).not.toContain("belongs to a Clerk account"); }); test("patch rejects config keys BAPI cannot reach", async () => { diff --git a/packages/cli-core/src/commands/config/keyless.ts b/packages/cli-core/src/commands/config/keyless.ts index 731061994..1a853668b 100644 --- a/packages/cli-core/src/commands/config/keyless.ts +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -14,8 +14,8 @@ import { bapiRequest } from "../../lib/bapi.ts"; import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { isClaimedKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts"; -import { keylessCopy } from "../../lib/copy.ts"; +import { isClaimedKeylessTarget, keyAccess, type KeylessTarget } from "../../lib/keyless-target.ts"; +import { keylessCopy, type KeyAccess } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; /** @@ -116,12 +116,12 @@ const ACCOUNT_ONLY_INSTANCE_FIELDS: Record = { * Rejects `instance` fields BAPI would silently discard, before the request is * sent. Runs only for that group — see `INSTANCE_FIELDS`. */ -function assertInstanceFields(fields: Record, claimed?: boolean): void { +function assertInstanceFields(fields: Record, access?: KeyAccess): void { const unknown = Object.keys(fields).filter((field) => !INSTANCE_FIELDS.has(field)); if (unknown.length === 0) return; const lines = [ - keylessCopy.unsupportedInstanceFieldsLine(unknown, claimed), + keylessCopy.unsupportedInstanceFieldsLine(unknown, access), keylessCopy.supportedInstanceFieldsLine([...INSTANCE_FIELDS]), ]; @@ -134,7 +134,10 @@ function assertInstanceFields(fields: Record, claimed?: boolean ), ]; for (const reason of reasons) { - lines.push(keylessCopy.noRouteForInstanceFieldLine(reason, claimed)); + lines.push(keylessCopy.noRouteForInstanceFieldLine(reason, access)); + } + if (access && access.claimed !== false) { + lines.push(keylessCopy.secretKeyCantChangeLine(unknown, access)); } throwUsageError(lines.join("\n")); @@ -144,10 +147,10 @@ function assertInstanceFields(fields: Record, claimed?: boolean * Validates caller-supplied names once, at the boundary, so everything * downstream works with a known group instead of re-checking strings. */ -function asGroupNames(names: string[], claimed?: boolean): KeylessGroup[] { +function asGroupNames(names: string[], access?: KeyAccess): KeylessGroup[] { const unknown = names.filter((name) => !isGroupName(name)); if (unknown.length > 0) { - throwUsageError(keylessCopy.unsupportedConfigKeys(unknown, KEYLESS_GROUP_NAMES, claimed)); + throwUsageError(keylessCopy.unsupportedConfigKeys(unknown, KEYLESS_GROUP_NAMES, access)); } return names.filter(isGroupName); } @@ -155,12 +158,12 @@ function asGroupNames(names: string[], claimed?: boolean): KeylessGroup[] { /** * Rejects payload keys that don't name a BAPI resource, before anything is sent. * - * @param claimed - Whether the key's application is claimed, which only - * changes how a refusal reads. See `isClaimedKeylessTarget`. + * @param access - Whether the key's application is claimed, and where the key + * came from, which only change how a refusal reads. See `KeyAccess`. */ export function assertKeylessPayload( payload: Record, - claimed?: boolean, + access?: KeyAccess, ): asserts payload is Record> { const unknown = Object.keys(payload).filter((key) => !(key in KEYLESS_GROUPS)); if (unknown.length > 0) { @@ -168,19 +171,19 @@ export function assertKeylessPayload( const accountOnly = unknown.filter((key) => !API_REACHABLE_KEYLESS_KEYS.has(key)); const lines = [ - keylessCopy.unsupportedPayloadKeysLine(unknown, claimed), + keylessCopy.unsupportedPayloadKeysLine(unknown, access), keylessCopy.supportedPayloadKeysLine(KEYLESS_GROUP_NAMES), ]; // Point these at `clerk api` — they're reachable today, and claiming the // application wouldn't move them into the config document anyway. if (apiReachable.length > 0) { - lines.push(keylessCopy.apiReachableKeysLine(apiReachable, claimed)); + lines.push(keylessCopy.apiReachableKeysLine(apiReachable, access)); } // Everything left really is part of the account-mode config document. if (accountOnly.length > 0) { - lines.push(keylessCopy.claimForFullConfigLine(claimed)); + lines.push(keylessCopy.claimForFullConfigLine(accountOnly, access)); } throwUsageError(lines.join("\n")); @@ -193,24 +196,25 @@ export function assertKeylessPayload( } if (payload.instance) { - assertInstanceFields(payload.instance as Record, claimed); + assertInstanceFields(payload.instance as Record, access); } } /** * Runs a check worded for an unclaimed application, and when it refuses, asks - * Clerk whether the application is claimed and runs it again worded for that. - * Only a refusal costs the extra request. + * Clerk whether the application belongs to an account and runs it again + * worded for what Clerk said, or for either when it couldn't say. Only a + * refusal costs the extra request. */ export async function withClaimedWording( target: KeylessTarget, - check: (claimed?: boolean) => T, + check: (access?: KeyAccess) => T, ): Promise { try { return check(); } catch (error) { if (!(error instanceof CliError)) throw error; - if ((await isClaimedKeylessTarget(target)) === true) check(true); + check(await keyAccess(target)); throw error; } } @@ -225,7 +229,7 @@ export async function pullKeylessConfig( keys?: string[], ): Promise> { const requested = keys?.length - ? await withClaimedWording(target, (claimed) => asGroupNames(keys, claimed)) + ? await withClaimedWording(target, (access) => asGroupNames(keys, access)) : KEYLESS_GROUP_NAMES; // Only worth saying when the caller named a write-only group. A default pull @@ -246,6 +250,18 @@ export async function pullKeylessConfig( config[name] = response.body; } + // A claimed application's key reads only these groups, and the pull looks + // complete: say what it left out. The instance group, when read, already + // says whether it is claimed. + const instance = config.instance as { workspace_id?: unknown } | undefined; + const claimed = + instance && "workspace_id" in instance + ? typeof instance.workspace_id === "string" && instance.workspace_id.length > 0 + : await isClaimedKeylessTarget(target); + if (claimed) { + log.warn(keylessCopy.pullCoversLessForClaimed(Object.keys(config), target.source)); + } + return config; } diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 4613db7b3..af25ff7a8 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -7,7 +7,7 @@ import { closeStatusForError } from "../../lib/signals.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { keylessCopy } from "../../lib/copy.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; -import { isClaimedKeylessTarget, resolveInstanceTarget } from "../../lib/keyless-target.ts"; +import { keyAccess, resolveInstanceTarget } from "../../lib/keyless-target.ts"; import { credentialPlaceholder, credentialPlaceholderPaths } from "../../lib/oauth-config.ts"; import type { KeylessWriteVerification } from "./keyless.ts"; import { REDACTED, redactSecrets, redactedSecretPaths } from "./redact.ts"; @@ -62,12 +62,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise const target = await resolveInstanceTarget(options); if (target.kind === "keyless") { throw new CliError( - keylessCopy.schemaNeedsClaimedApplication(await isClaimedKeylessTarget(target.keyless)), + keylessCopy.schemaNeedsClaimedApplication(await keyAccess(target.keyless)), { code: ERROR_CODE.AUTH_REQUIRED, }, diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index 7016fb0e9..4c40bbe1d 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -14,16 +14,34 @@ * reason and a working alternative rather than just saying no. */ /** - * What a secret-key target is called in a refusal. Any `sk_` key found on disk - * or in the environment takes the secret-key path, claimed application or - * not, so `claimed` (from `isClaimedKeylessTarget`) picks the wording. + * Whether the secret key's application belongs to a Clerk account, and where + * the key came from. Any `sk_` key takes the secret-key path, claimed + * application or not, so a refusal asks Clerk before wording itself + * (`isClaimedKeylessTarget`): `claimed` is `undefined` when it couldn't. No + * access at all means an unclaimed application. */ -const keyTarget = (claimed?: boolean): string => - claimed ? "with only a secret key" : "for an unclaimed accountless application"; +export type KeyAccess = { claimed: boolean | undefined; source: string }; -/** The way to the full config document, for a claimed application reached by its key. */ -const loginForClaimed = - "This application is claimed: run `clerk auth login`, then `clerk link` or pass `--app `, so the command uses your account instead of the secret key."; +const isUnclaimed = (access?: KeyAccess): boolean => !access || access.claimed === false; + +const keyTarget = (access?: KeyAccess): string => + isUnclaimed(access) ? "for an unclaimed accountless application" : "with only a secret key"; + +const accountRoute = (source: string): string => + "Pass `--app --instance ` (or run `clerk link`), with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. " + + `A login alone isn't enough while the secret key from ${source} takes precedence.`; + +/** + * The way past a refusal when the application may belong to an account: + * `cant` completes "a secret key can't …", and `unclaimed` is the advice for + * an unclaimed one, used only when Clerk couldn't say which it is. + */ +const pastSecretKey = (access: KeyAccess, cant: string, unclaimed: string): string => + access.claimed + ? `This application belongs to a Clerk account, so a secret key can't ${cant}. ${accountRoute(access.source)}` + : `If this application is unclaimed, ${unclaimed} If it belongs to a Clerk account, a secret key can't ${cant}. ${accountRoute(access.source)}`; + +const claimForConfig = "run `clerk auth login` to claim it and use the full config document."; export const keylessCopy = { // --- config payload validation (config/keyless.ts) --- @@ -31,38 +49,53 @@ export const keylessCopy = { unsupportedConfigKeys: ( unknown: string[], supported: readonly string[], - claimed?: boolean, + access?: KeyAccess, ): string => - `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(claimed)}: ${unknown.join(", ")}.\n` + + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(access)}: ${unknown.join(", ")}.\n` + `Supported keys: ${supported.join(", ")}.` + - (claimed ? `\n${loginForClaimed}` : ""), + (access && !isUnclaimed(access) + ? `\n${pastSecretKey(access, `read ${unknown.join(", ")}`, claimForConfig)}` + : ""), - unsupportedPayloadKeysLine: (unknown: string[], claimed?: boolean): string => - `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(claimed)}: ${unknown.join(", ")}.`, + unsupportedPayloadKeysLine: (unknown: string[], access?: KeyAccess): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(access)}: ${unknown.join(", ")}.`, supportedPayloadKeysLine: (supported: readonly string[]): string => `Supported top-level keys: ${supported.join(", ")}.`, - apiReachableKeysLine: (keys: string[], claimed?: boolean): string => - `${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} already reachable ${claimed ? "with the secret key" : "on an unclaimed application"} — use ${keys.map((key) => `\`clerk api /${key}\``).join(" or ")} directly instead of this config document.`, + apiReachableKeysLine: (keys: string[], access?: KeyAccess): string => + `${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} already reachable ${isUnclaimed(access) ? "on an unclaimed application" : "with the secret key"} — use ${keys.map((key) => `\`clerk api /${key}\``).join(" or ")} directly instead of this config document.`, - claimForFullConfigLine: (claimed?: boolean): string => - claimed - ? loginForClaimed + claimForFullConfigLine: (keys: string[], access?: KeyAccess): string => + access && !isUnclaimed(access) + ? pastSecretKey(access, `change ${keys.join(", ")}`, claimForConfig) : "Run `clerk auth login` to claim the application and use the full config document.", configKeyMustBeObject: (key: string): string => `Config key \`${key}\` must be a JSON object.`, - unsupportedInstanceFieldsLine: (unknown: string[], claimed?: boolean): string => - `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` ${keyTarget(claimed)}: ${unknown.join(", ")}.`, + unsupportedInstanceFieldsLine: (unknown: string[], access?: KeyAccess): string => + `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` ${keyTarget(access)}: ${unknown.join(", ")}.`, supportedInstanceFieldsLine: (fields: readonly string[]): string => `Supported fields: ${fields.join(", ")}.`, - noRouteForInstanceFieldLine: (reason: string, claimed?: boolean): string => - claimed - ? `Clerk's Backend API has no route for ${reason}, so a secret key can't change it. ${loginForClaimed}` - : `Clerk's Backend API has no route for ${reason}, so this can't be changed from an unclaimed application at all — claim it first with \`clerk auth login\`.`, + noRouteForInstanceFieldLine: (reason: string, access?: KeyAccess): string => + isUnclaimed(access) + ? `Clerk's Backend API has no route for ${reason}, so this can't be changed from an unclaimed application at all — claim it first with \`clerk auth login\`.` + : `Clerk's Backend API has no route for ${reason}.`, + + /** Closes an instance-field refusal when the application may belong to an account. */ + secretKeyCantChangeLine: (fields: string[], access: KeyAccess): string => + pastSecretKey( + access, + `change ${fields.join(", ")}`, + "run `clerk auth login` to claim it first.", + ), + + /** A keyless pull of an application that belongs to an account: what it left out, and how to get it. */ + pullCoversLessForClaimed: (groups: readonly string[], source: string): string => + `This application belongs to a Clerk account, but the secret key from ${source} only reads ${groups.join(", ")}; the rest of its config is left out.\n` + + accountRoute(source), // --- "needs a claimed application" refusals --- @@ -70,21 +103,37 @@ export const keylessCopy = { "Billing can only be configured on a claimed application — Clerk's Backend API has no billing settings, so an unclaimed accountless application can't reach them.\n" + "Run `clerk auth login` to claim this application, then re-run the command.", - schemaNeedsClaimedApplication: (claimed?: boolean): string => - claimed - ? `Config schema comes from your account, not the secret key — it describes the account-level config document.\n${loginForClaimed}` + schemaNeedsClaimedApplication: (access?: KeyAccess): string => + access && !isUnclaimed(access) + ? "Config schema describes the account-level config document.\n" + + pastSecretKey( + access, + "read the config schema", + "run `clerk auth login` to claim it, then re-run `clerk config schema`.", + ) : "Config schema is only available for a claimed application — the schema describes the account-level config document, which an unclaimed accountless application has no access to.\n" + "Run `clerk auth login` to claim this application, then re-run `clerk config schema`.", - putNeedsClaimedApplication: (claimed?: boolean): string => - claimed - ? `Replacing the entire configuration needs your account, not the secret key.\n${loginForClaimed} Or use \`clerk config patch\` to update the settings a secret key reaches.` + putNeedsClaimedApplication: (access?: KeyAccess): string => + access && !isUnclaimed(access) + ? "Replacing the entire configuration needs the account-level config document.\n" + + pastSecretKey( + access, + "replace the whole config", + "run `clerk auth login` to claim it first.", + ) + + "\nOr use `clerk config patch` for the settings a secret key reaches." : "Replacing the entire configuration is only available for a claimed application — an unclaimed accountless application has no full config document to replace.\n" + "Use `clerk config patch` to update individual settings, or run `clerk auth login` to claim the application first.", - instanceFlagWithKey: (source: string, claimed?: boolean): string => - claimed - ? `--instance is not supported with only a secret key: the key in ${source} already targets its own instance.\n${loginForClaimed}` + instanceFlagWithKey: (source: string, access?: KeyAccess): string => + access && !isUnclaimed(access) + ? `--instance is not supported with only a secret key: the key in ${source} already targets its own instance.\n` + + pastSecretKey( + access, + "pick another instance", + "run `clerk auth login` to claim it, then target instances by name.", + ) : `--instance is not supported for an unclaimed accountless application: the secret key in ${source} already targets its own instance.\n` + "Run `clerk auth login` to claim the application, then target instances by name.", diff --git a/packages/cli-core/src/lib/keyless-target.ts b/packages/cli-core/src/lib/keyless-target.ts index 2e34eaa88..81e2c82f1 100644 --- a/packages/cli-core/src/lib/keyless-target.ts +++ b/packages/cli-core/src/lib/keyless-target.ts @@ -15,7 +15,7 @@ import { parseEnvFile } from "./dotenv.ts"; import { CliError, ERROR_CODE, throwUsageError } from "./errors.ts"; import { decodePublishableKey } from "./fapi.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; -import { keylessCopy } from "./copy.ts"; +import { keylessCopy, type KeyAccess } from "./copy.ts"; import { log } from "./log.ts"; export interface KeylessTarget { @@ -227,6 +227,11 @@ export async function isClaimedKeylessTarget(target: KeylessTarget): Promise { + return { claimed: await isClaimedKeylessTarget(target), source: target.source }; +} + /** * Resolves the keyless target for a command, or `undefined` when the * account-authenticated path applies. @@ -260,9 +265,7 @@ export async function resolveKeylessTarget(options: { // The secret key addresses exactly one instance — its own — so there is no // instance to choose between. if (options.instance) { - throwUsageError( - keylessCopy.instanceFlagWithKey(target.source, await isClaimedKeylessTarget(target)), - ); + throwUsageError(keylessCopy.instanceFlagWithKey(target.source, await keyAccess(target))); } return target; From d91cb46479e29a148c94c7193e53a6c6685f30da Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 18:33:06 -0400 Subject: [PATCH 3/7] fix(config,billing): warn only on a default pull, and word billing's refusal for a claimed application The claimed-application warning ran inside pullKeylessConfig, which a patch also calls to diff, so a patch printed it mid-write and sent an extra GET /v1/instance. It now lives in configPull, only without --keys, and reads workspace_id from the instance group the pull already fetched. clerk enable/disable billing told a claimed application reached by its secret key to claim it. Its refusal now asks /v1/instance and words itself the same way the config refusals do. Co-Authored-By: Claude Opus 5.5 --- .changeset/config-claimed-secret-key.md | 2 +- .../cli-core/src/commands/billing/README.md | 7 ++- .../cli-core/src/commands/billing/index.ts | 16 ++--- .../cli-core/src/commands/config/README.md | 2 +- .../src/commands/config/keyless.test.ts | 63 +++++++++++++++++++ .../cli-core/src/commands/config/keyless.ts | 14 +---- packages/cli-core/src/commands/config/pull.ts | 11 ++++ packages/cli-core/src/lib/copy.ts | 13 +++- 8 files changed, 102 insertions(+), 26 deletions(-) diff --git a/.changeset/config-claimed-secret-key.md b/.changeset/config-claimed-secret-key.md index a4e1cc90d..800b034a3 100644 --- a/.changeset/config-claimed-secret-key.md +++ b/.changeset/config-claimed-secret-key.md @@ -2,4 +2,4 @@ "clerk": patch --- -Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, a `clerk config patch`, `put`, `schema` or `--instance` refusal now asks Clerk whether the application belongs to an account. If it does, it says a secret key can't change those settings, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`; a login alone isn't enough while the secret key is set. If Clerk can't say, the message covers both cases. `clerk config pull` of a claimed application warns what the secret key left out. +Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, a `clerk config patch`, `put`, `schema` or `--instance` refusal, or a `clerk enable billing` / `clerk disable billing` refusal, now asks Clerk whether the application belongs to an account. If it does, it says a secret key can't change those settings, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`; a login alone isn't enough while the secret key is set. If Clerk can't say, the message covers both cases. A default `clerk config pull` of a claimed application warns what the secret key left out. diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md index 1322e57af..2b4f0a322 100644 --- a/packages/cli-core/src/commands/billing/README.md +++ b/packages/cli-core/src/commands/billing/README.md @@ -8,7 +8,11 @@ billing` commands. commands cannot run against an unclaimed accountless application: billing settings exist only in the account-level config document, and Clerk's Backend API exposes no billing resource an instance secret key could reach. In an accountless project both -commands exit with an `auth_required` error pointing at `clerk auth login`. +commands exit with an `auth_required` error. The refusal reads `GET /v1/instance` once to word +itself: an unclaimed application (`workspace_id` null) is pointed at `clerk auth login` to claim it, +and a claimed one reached by its secret key is pointed at `--app --instance ` (or +`clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. If the lookup fails, the +message gives both. For arbitrary billing config edits (plans, trials, payment-method requirements) use `clerk config patch --json '{"billing":{...}}'` until a dedicated @@ -71,3 +75,4 @@ The install runs via the user's package runner (`bunx`, `pnpm dlx`, `yarn dlx`, | ------ | ----------------------------------------------------------------- | ------------------------------------------------------------- | | GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config for diff before mutation | | PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch `billing.*` (with `?dry_run=true` when `--dry-run` set) | +| GET | `/v1/instance` (Backend API) | Accountless refusal only: whether the key's app is claimed | diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index aec98bfb1..7d1294eb9 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -8,7 +8,7 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { withGutter } from "../../lib/spinner.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../../lib/skills.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; -import { resolveInstanceTarget, type InstanceTarget } from "../../lib/keyless-target.ts"; +import { keyAccess, resolveInstanceTarget, type InstanceTarget } from "../../lib/keyless-target.ts"; interface BillingOptions { app?: string; @@ -60,12 +60,14 @@ function describeTargets(targets: Target[]): string { /** * Billing settings live only in the account-level config document — Clerk's * Backend API exposes no billing resource — so these commands can't run against - * an unclaimed keyless application the way the org toggles can. Pure assertion: - * resolving the target is `resolveInstanceTarget`'s job, not billing's. + * any keyless target the way the org toggles can. The refusal asks Clerk whether + * the key's application is claimed, to word itself. Resolving the target is + * `resolveInstanceTarget`'s job, not billing's. */ -function assertBillingTarget(target: InstanceTarget): void { +async function assertBillingTarget(target: InstanceTarget): Promise { if (target.kind === "keyless") { - throw new CliError(keylessCopy.billingNeedsClaimedApplication(), { + const access = await keyAccess(target.keyless); + throw new CliError(keylessCopy.billingNeedsClaimedApplication(access), { code: ERROR_CODE.AUTH_REQUIRED, }); } @@ -74,7 +76,7 @@ function assertBillingTarget(target: InstanceTarget): void { export async function billingEnable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); const target = await resolveInstanceTarget(options); - assertBillingTarget(target); + await assertBillingTarget(target); const billing: Record = {}; const payload: Record = { billing }; @@ -139,7 +141,7 @@ async function offerBillingSkillInstall(options: BillingOptions): Promise export async function billingDisable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); const target = await resolveInstanceTarget(options); - assertBillingTarget(target); + await assertBillingTarget(target); // No cascade: leave organization_settings untouched. const billing: Record = {}; diff --git a/packages/cli-core/src/commands/config/README.md b/packages/cli-core/src/commands/config/README.md index 1a6f79d99..8775f6de6 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -194,7 +194,7 @@ The same path takes a secret key for a **claimed** application, since the key al - `workspace_id` set: the application belongs to an account. The refusal says a secret key can't change those keys, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. A login alone isn't enough while the secret key is found, since it takes precedence. - The lookup fails: the refusal gives both, and never calls the application unclaimed. -A `config pull` of a claimed application warns that the secret key read only some groups, worded the same way. It reads `workspace_id` from the `instance` group it pulls, so it costs no extra request when that group is included. +A default `config pull` (no `--keys`) of a claimed application warns that the secret key read only some groups, worded the same way. It reads `workspace_id` from the `instance` group it pulls, so it makes no extra request. A pull with `--keys`, and the read a `patch` makes to diff against, never warn. The secret key is resolved the way the app itself would resolve one, and this order is shared by every accountless-capable command (`lib/keyless-target.ts`): diff --git a/packages/cli-core/src/commands/config/keyless.test.ts b/packages/cli-core/src/commands/config/keyless.test.ts index 9cc9c8a30..b86991b22 100644 --- a/packages/cli-core/src/commands/config/keyless.test.ts +++ b/packages/cli-core/src/commands/config/keyless.test.ts @@ -794,6 +794,69 @@ describe("keyless config", () => { expect(captured.err).not.toContain("belongs to a Clerk account"); }); + // `--keys` names the groups, so "only reads" them is no news. + test("pull --keys of a claimed application says nothing extra", async () => { + bapiState["/v1/instance"] = { ...INSTANCE, workspace_id: "org_123" }; + const { configPull } = await import("./pull.ts"); + + await configPull({ keys: ["protect"] }); + + expect(captured.err).not.toContain("belongs to a Clerk account"); + }); + + // A patch diffs against a pull, but the warning is the pull command's alone. + test("patch of a claimed application's instance doesn't warn about the pull", async () => { + bapiState["/v1/instance"] = { ...INSTANCE, workspace_id: "org_123" }; + const { configPatch } = await import("./push.ts"); + + await configPatch({ json: '{"instance":{"support_email":"new@example.com"}}', yes: true }); + + expect(captured.err).not.toContain("only reads"); + }); + + test("a valid patch that doesn't touch instance never reads it", async () => { + const requests: string[] = []; + const answer = globalThis.fetch; + stubFetch(async (input, init) => { + requests.push( + `${(init?.method ?? "GET").toUpperCase()} ${new URL(input.toString()).pathname}`, + ); + return answer(input, init); + }); + const { configPatch } = await import("./push.ts"); + + await configPatch({ json: '{"organization_settings":{"enabled":true}}', yes: true }); + + expect(requests).not.toContain("GET /v1/instance"); + }); + + test("billing on a claimed application points at the account, not at claiming it", async () => { + stubWorkspace("org_123"); + const { billingEnable } = await import("../billing/index.ts"); + + const error = await billingEnable({ for: ["users"], yes: true }).catch((e: unknown) => e); + + const message = (error as Error).message; + expect(message).toContain( + "This application belongs to a Clerk account, so a secret key can't configure billing.", + ); + expect(message).toContain("A login alone isn't enough"); + expect(message).not.toContain("unclaimed"); + }); + + test("billing covers both cases when Clerk can't say whether the application is claimed", async () => { + stubWorkspace("fail"); + const { billingEnable } = await import("../billing/index.ts"); + + const error = await billingEnable({ for: ["users"], yes: true }).catch((e: unknown) => e); + + const message = (error as Error).message; + expect(message).toContain("If this application is unclaimed, run `clerk auth login`"); + expect(message).toContain( + "If it belongs to a Clerk account, a secret key can't configure billing.", + ); + }); + test("patch rejects config keys BAPI cannot reach", async () => { const { configPatch } = await import("./push.ts"); diff --git a/packages/cli-core/src/commands/config/keyless.ts b/packages/cli-core/src/commands/config/keyless.ts index 1a853668b..42aabccee 100644 --- a/packages/cli-core/src/commands/config/keyless.ts +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -14,7 +14,7 @@ import { bapiRequest } from "../../lib/bapi.ts"; import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { isClaimedKeylessTarget, keyAccess, type KeylessTarget } from "../../lib/keyless-target.ts"; +import { keyAccess, type KeylessTarget } from "../../lib/keyless-target.ts"; import { keylessCopy, type KeyAccess } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; @@ -250,18 +250,6 @@ export async function pullKeylessConfig( config[name] = response.body; } - // A claimed application's key reads only these groups, and the pull looks - // complete: say what it left out. The instance group, when read, already - // says whether it is claimed. - const instance = config.instance as { workspace_id?: unknown } | undefined; - const claimed = - instance && "workspace_id" in instance - ? typeof instance.workspace_id === "string" && instance.workspace_id.length > 0 - : await isClaimedKeylessTarget(target); - if (claimed) { - log.warn(keylessCopy.pullCoversLessForClaimed(Object.keys(config), target.source)); - } - return config; } diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 1698e7f14..288d68f11 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -2,6 +2,7 @@ import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; +import { keylessCopy } from "../../lib/copy.ts"; import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; import { pullKeylessConfig } from "./keyless.ts"; @@ -25,6 +26,16 @@ export async function configPull(options: ConfigPullOptions): Promise { ), ); + // A claimed application's key reads only these groups, and a default pull + // looks complete: say what it left out. It always reads the instance + // group, which already says whether it is claimed. + if (target.kind === "keyless" && !options.keys?.length) { + const workspaceId = (config.instance as { workspace_id?: unknown } | undefined)?.workspace_id; + if (typeof workspaceId === "string" && workspaceId.length > 0) { + log.warn(keylessCopy.pullCoversLessForClaimed(Object.keys(config), target.keyless.source)); + } + } + const json = JSON.stringify(config, null, 2); if (options.output) { diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index 4c40bbe1d..0d1cdb894 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -99,9 +99,16 @@ export const keylessCopy = { // --- "needs a claimed application" refusals --- - billingNeedsClaimedApplication: (): string => - "Billing can only be configured on a claimed application — Clerk's Backend API has no billing settings, so an unclaimed accountless application can't reach them.\n" + - "Run `clerk auth login` to claim this application, then re-run the command.", + billingNeedsClaimedApplication: (access?: KeyAccess): string => + access && !isUnclaimed(access) + ? "Billing lives in the account-level config document — Clerk's Backend API has no billing settings.\n" + + pastSecretKey( + access, + "configure billing", + "run `clerk auth login` to claim it, then re-run the command.", + ) + : "Billing can only be configured on a claimed application — Clerk's Backend API has no billing settings, so an unclaimed accountless application can't reach them.\n" + + "Run `clerk auth login` to claim this application, then re-run the command.", schemaNeedsClaimedApplication: (access?: KeyAccess): string => access && !isUnclaimed(access) From c171da5f1c5e182a5962daee8c36d09cd8e511bc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 18:58:44 -0400 Subject: [PATCH 4/7] fix(users): stop calling a claimed application unclaimed in `clerk users open` With only a secret key on disk, `clerk users open ` told a claimed application it was unclaimed and to claim it. The refusal now asks /v1/instance: a claimed application is pointed at `clerk link` (or --app) and exits not_linked, and a failed lookup gives both. keylessTargetStubs also covers every export of lib/keyless-target.ts again. Co-Authored-By: Claude Opus 5.5 --- .changeset/config-claimed-secret-key.md | 2 +- .../cli-core/src/commands/users/README.md | 17 +++++++---- .../cli-core/src/commands/users/open.test.ts | 30 +++++++++++++++++++ packages/cli-core/src/commands/users/open.ts | 28 +++++++++-------- packages/cli-core/src/lib/copy.ts | 16 ++++++++-- packages/cli-core/src/test/lib/stubs.ts | 3 ++ 6 files changed, 74 insertions(+), 22 deletions(-) diff --git a/.changeset/config-claimed-secret-key.md b/.changeset/config-claimed-secret-key.md index 800b034a3..77bff3e98 100644 --- a/.changeset/config-claimed-secret-key.md +++ b/.changeset/config-claimed-secret-key.md @@ -2,4 +2,4 @@ "clerk": patch --- -Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, a `clerk config patch`, `put`, `schema` or `--instance` refusal, or a `clerk enable billing` / `clerk disable billing` refusal, now asks Clerk whether the application belongs to an account. If it does, it says a secret key can't change those settings, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`; a login alone isn't enough while the secret key is set. If Clerk can't say, the message covers both cases. A default `clerk config pull` of a claimed application warns what the secret key left out. +Stop calling a claimed application "unclaimed accountless" when `clerk config` reaches it with only a secret key. With `CLERK_SECRET_KEY` (or a key in `.env`) and no `--app` or linked profile, a `clerk config patch`, `put`, `schema` or `--instance` refusal, a `clerk enable billing` / `clerk disable billing` refusal, or a `clerk users open ` refusal, now asks Clerk whether the application belongs to an account. If it does, it says a secret key can't change those settings, and to pass `--app --instance ` (or run `clerk link`) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`; a login alone isn't enough while the secret key is set. If Clerk can't say, the message covers both cases. A default `clerk config pull` of a claimed application warns what the secret key left out. diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index a49d24ca4..092c2f18b 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -129,18 +129,25 @@ In agent mode the user-id is required (no interactive picker) and output is a JS `--secret-key` chooses the Backend API key used for user lookup. `users open` still requires an app target to resolve the dashboard URL, either from `--app`, a linked project, or the human-mode app picker. Use `--instance` when you want something other than the default development instance. -#### On an unclaimed accountless application +#### With only a secret key This is the one command in the family that an unclaimed accountless application cannot satisfy, and the reason is structural rather than a missing code path: a dashboard link is `/apps/{appId}/instances/{instanceId}/users/{userId}`, and an application has no `appId` until somebody claims it. Rather than report the generic "no Clerk project linked", `users open` detects the accountless project and says so — because the two remedies that message implies, `clerk link` and `--app`, both want an application ID that does not exist yet. The error names `clerk auth login` to claim the application, and `clerk api /users/` to read the user right now without claiming anything. Passing `--app` explicitly skips this check: naming an application says the request isn't about the accountless one in this directory. +A secret key for a **claimed** application takes the same path, since the key alone doesn't say which it is. So before refusing, `users open` reads `GET /v1/instance` once: + +- `workspace_id` null: the application is unclaimed, and the error is the one above. +- `workspace_id` set: the application belongs to an account. The error says the key doesn't carry the application ID, and to run `clerk link` (or pass `--app `) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. It exits `not_linked`. +- The lookup fails: the error gives both, and never calls the application unclaimed. + ## API Endpoints -| Method | Endpoint | Command(s) | -| ------ | ----------- | ------------------------------------------- | -| `GET` | `/v1/users` | `list`, `open` (when picking interactively) | -| `POST` | `/v1/users` | `create` | +| Method | Endpoint | Command(s) | +| ------ | -------------- | ------------------------------------------------------- | +| `GET` | `/v1/users` | `list`, `open` (when picking interactively) | +| `POST` | `/v1/users` | `create` | +| `GET` | `/v1/instance` | `open` (only when refusing a secret-key-only directory) | ## Notes diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index 593d035f5..ba4b43c91 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; import { setMode } from "../../mode.ts"; import { setCurrentEnv } from "../../lib/environment.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { configStubs, keylessTargetStubs, useCaptureLog } from "../../test/lib/stubs.ts"; const mockResolveAppContext = mock(); @@ -31,9 +32,11 @@ mock.module("../../lib/open.ts", () => ({ })); const mockResolveKeylessTarget = mock(); +const mockKeyAccess = mock(); mock.module("../../lib/keyless-target.ts", () => ({ ...keylessTargetStubs, resolveKeylessTarget: (...args: unknown[]) => mockResolveKeylessTarget(...args), + keyAccess: (...args: unknown[]) => mockKeyAccess(...args), })); mock.module("../../lib/spinner.ts", () => ({ @@ -73,6 +76,7 @@ describe("users open", () => { mockResolveUsersInstanceContext.mockResolvedValue(CTX); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); mockResolveKeylessTarget.mockResolvedValue(undefined); + mockKeyAccess.mockResolvedValue({ claimed: false, source: ".env.local" }); }); afterEach(() => { @@ -83,6 +87,7 @@ describe("users open", () => { mockPickUser.mockReset(); mockOpenBrowser.mockReset(); mockResolveKeylessTarget.mockReset(); + mockKeyAccess.mockReset(); }); describe("on an unclaimed keyless application", () => { @@ -111,6 +116,31 @@ describe("users open", () => { expect((error as Error).message).toContain("clerk api /users/user_abc"); }); + // Any `sk_` key lands here, so a claimed application's key does too. + test("points a claimed application at `clerk link`, without calling it unclaimed", async () => { + mockKeyAccess.mockResolvedValue({ claimed: true, source: ".env.local" }); + + const error = (await open({ userId: "user_abc" }).catch( + (thrown: unknown) => thrown, + )) as CliError; + + expect(error.message).toContain("This application belongs to a Clerk account"); + expect(error.message).toContain("Run `clerk link` (or pass `--app `)"); + expect(error.message).toContain("clerk api /users/user_abc"); + expect(error.message).not.toContain("unclaimed"); + expect(error.code).toBe(ERROR_CODE.NOT_LINKED); + }); + + test("covers both cases when Clerk can't say whether the application is claimed", async () => { + mockKeyAccess.mockResolvedValue({ claimed: undefined, source: ".env.local" }); + + const error = await open({ userId: "user_abc" }).catch((thrown: unknown) => thrown); + + const message = (error as Error).message; + expect(message).toContain("If this application is unclaimed, run `clerk auth login`"); + expect(message).toContain("If it belongs to a Clerk account, run `clerk link`"); + }); + test("does not hijack an explicit --app target", async () => { // Naming an application says the user isn't asking about the keyless one // sitting in this directory, so that resolution must run untouched. diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index b67c1b867..75ddc4540 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -1,7 +1,7 @@ import { bold, cyan, dim } from "../../lib/color.ts"; import { resolveAppContext, resolveInstanceId, resolveProfile } from "../../lib/config.ts"; import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; -import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; +import { keyAccess, resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { keylessCopy } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; @@ -67,23 +67,25 @@ async function resolveKnownUserDashboardTarget(options: UsersOpenOptions): Promi } /** - * Fails with the real reason when the directory holds an unclaimed keyless - * application, rather than letting the caller land on `not_linked`. + * Fails with the real reason when the directory holds only a secret key, + * rather than letting the caller land on `not_linked`. * - * Both are true — nothing is linked, and there is nothing to link to — but only - * one of them tells you what to do next. `clerk link` and `--app` both expect an - * application ID that an unclaimed application does not have yet, so the generic - * message sends people to try two things that cannot work. + * A dashboard link needs an application ID, and the key doesn't carry one. + * For an unclaimed application there is nothing to link to yet: `clerk link` + * and `--app` both expect an ID it does not have, so the advice is to claim + * it. Any `sk_` key lands here, claimed or not, so this asks Clerk which + * before saying so. */ -async function assertNotUnclaimedKeyless(options: UsersOpenOptions, userId: string): Promise { +async function assertNotKeyless(options: UsersOpenOptions, userId: string): Promise { // An explicit destination means the user is not asking about keyless at all, // and the original error is the accurate one. if (options.app || options.instance) return; const keyless = await resolveKeylessTarget({ cwd: process.cwd() }); if (keyless) { - throw new CliError(keylessCopy.userDashboardNeedsClaim(keyless.source, userId), { - code: ERROR_CODE.INSTANCE_NOT_FOUND, + const access = await keyAccess(keyless); + throw new CliError(keylessCopy.userDashboardNeedsClaim(userId, access), { + code: access.claimed ? ERROR_CODE.NOT_LINKED : ERROR_CODE.INSTANCE_NOT_FOUND, }); } } @@ -107,12 +109,12 @@ export async function open(options: UsersOpenOptions = {}): Promise { try { target = await resolveKnownUserDashboardTarget(options); } catch (error) { - // An unclaimed application has no Dashboard page to open — the deep-link - // is `/apps/{appId}/…` and there is no appId until somebody claims it. + // A secret key alone can't build the deep-link — it is `/apps/{appId}/…`, + // and an unclaimed application has no appId until somebody claims it. // Checked before the `--secret-key` branch below because the advice that // branch would eventually give ("use --app", "run `clerk link`") is // unreachable here: no app exists to name. - await assertNotUnclaimedKeyless(options, userId); + await assertNotKeyless(options, userId); if (!options.secretKey) { throw error; diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index 0d1cdb894..dd5db0c87 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -41,6 +41,10 @@ const pastSecretKey = (access: KeyAccess, cant: string, unclaimed: string): stri ? `This application belongs to a Clerk account, so a secret key can't ${cant}. ${accountRoute(access.source)}` : `If this application is unclaimed, ${unclaimed} If it belongs to a Clerk account, a secret key can't ${cant}. ${accountRoute(access.source)}`; +/** Completes "run …" for a claimed application's dashboard link. */ +const linkForDashboard = (userId: string): string => + `\`clerk link\` (or pass \`--app \`), with \`clerk auth login\` or \`CLERK_PLATFORM_API_KEY\`, then \`clerk users open ${userId}\` will work.`; + const claimForConfig = "run `clerk auth login` to claim it and use the full config document."; export const keylessCopy = { @@ -144,8 +148,14 @@ export const keylessCopy = { : `--instance is not supported for an unclaimed accountless application: the secret key in ${source} already targets its own instance.\n` + "Run `clerk auth login` to claim the application, then target instances by name.", - userDashboardNeedsClaim: (keySource: string, userId: string): string => - `This directory holds an unclaimed accountless application (secret key from ${keySource}), which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + - `Run \`clerk auth login\` to claim it, then \`clerk users open ${userId}\` will work.\n` + + userDashboardNeedsClaim: (userId: string, access: KeyAccess): string => + (access.claimed === false + ? `This directory holds an unclaimed accountless application (secret key from ${access.source}), which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + + `Run \`clerk auth login\` to claim it, then \`clerk users open ${userId}\` will work.\n` + : access.claimed + ? `This application belongs to a Clerk account, but a dashboard link needs its application ID, which the secret key from ${access.source} doesn't carry.\n` + + `Run ${linkForDashboard(userId)}\n` + : `A dashboard link needs an application ID, which the secret key from ${access.source} doesn't carry.\n` + + `If this application is unclaimed, run \`clerk auth login\` to claim it. If it belongs to a Clerk account, run ${linkForDashboard(userId)}\n`) + `To inspect the user right now, \`clerk api /users/${userId}\` reads it straight from the instance.`, }; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index dd3fa6b4f..c2cf2e8eb 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -203,6 +203,9 @@ export const keylessTargetStubs = { findLocalPublishableKey: noop, hasKeyPairMismatch: async () => false, readSdkKeylessApp: noop, + isClaimedKeylessTarget: async () => false, + keyAccess: async (target: { source: string }) => ({ claimed: false, source: target.source }), + SDK_KEYLESS_SOURCE: ".clerk/.tmp/keyless.json", }; export const autolinkStubs = { From b32299d7fadf7b68666077ec6249bdf73be6440a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 21 Oct 2026 19:30:43 -0400 Subject: [PATCH 5/7] fix(users): word a `clerk users open` refusal for the `--secret-key` that was passed With `--secret-key` and no app target, the refusal resolved and asked Clerk about the directory's own key instead, so a different local key decided the message and exit code. It now checks the passed key's prefix and asks about that key, and resolves the local one only when none was passed. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/users/README.md | 2 +- .../cli-core/src/commands/users/open.test.ts | 66 +++++++++++++------ packages/cli-core/src/commands/users/open.ts | 17 +++-- packages/cli-core/src/lib/copy.ts | 2 +- 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 092c2f18b..16955d21c 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -135,7 +135,7 @@ This is the one command in the family that an unclaimed accountless application Rather than report the generic "no Clerk project linked", `users open` detects the accountless project and says so — because the two remedies that message implies, `clerk link` and `--app`, both want an application ID that does not exist yet. The error names `clerk auth login` to claim the application, and `clerk api /users/` to read the user right now without claiming anything. Passing `--app` explicitly skips this check: naming an application says the request isn't about the accountless one in this directory. -A secret key for a **claimed** application takes the same path, since the key alone doesn't say which it is. So before refusing, `users open` reads `GET /v1/instance` once: +A secret key for a **claimed** application takes the same path, since the key alone doesn't say which it is. So before refusing, `users open` reads `GET /v1/instance` once, with `--secret-key` when it was passed and otherwise with the key this directory holds: - `workspace_id` null: the application is unclaimed, and the error is the one above. - `workspace_id` set: the application belongs to an account. The error says the key doesn't carry the application ID, and to run `clerk link` (or pass `--app `) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. It exits `not_linked`. diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index ba4b43c91..05bcd739a 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -105,7 +105,7 @@ describe("users open", () => { test("says the application is unclaimed rather than that nothing is linked", async () => { await expect(open({ userId: "user_abc" })).rejects.toThrow( - /unclaimed accountless application \(secret key from \.env\.local\).*no Dashboard page/s, + /secret key from \.env\.local belongs to an unclaimed accountless application.*no Dashboard page/s, ); }); @@ -218,29 +218,53 @@ describe("users open", () => { ); }); - test("--secret-key without a resolvable dashboard target: throws usage error", async () => { - setMode("agent"); - const { CliError, ERROR_CODE } = await import("../../lib/errors.ts"); - mockResolveAppContext.mockRejectedValueOnce( - new CliError("Not linked.", { - code: ERROR_CODE.NOT_LINKED, - }), - ); - mockResolveUsersInstanceContext - .mockRejectedValueOnce( - new CliError("Not linked.", { - code: ERROR_CODE.NOT_LINKED, - }), - ) - .mockResolvedValueOnce({ + describe("--secret-key without a resolvable dashboard target", () => { + beforeEach(() => { + setMode("agent"); + mockResolveAppContext.mockRejectedValue( + new CliError("Not linked.", { code: ERROR_CODE.NOT_LINKED }), + ); + mockResolveUsersInstanceContext.mockRejectedValue( + new CliError("Not linked.", { code: ERROR_CODE.NOT_LINKED }), + ); + // A different key in this directory must not speak for the one passed. + mockResolveKeylessTarget.mockResolvedValue({ + secretKey: "sk_test_local", + source: ".env.local", + }); + }); + + test("classifies the passed key, not the directory's", async () => { + mockKeyAccess.mockResolvedValue({ claimed: false, source: "the `--secret-key` flag" }); + + await expect(open({ secretKey: "sk_test_loose", userId: "user_2x9k" })).rejects.toThrow( + /secret key from the `--secret-key` flag belongs to an unclaimed/, + ); + expect(mockKeyAccess).toHaveBeenCalledWith({ secretKey: "sk_test_loose", + source: "the `--secret-key` flag", }); + expect(mockResolveKeylessTarget).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); - await expect(open({ secretKey: "sk_test_loose", userId: "user_2x9k" })).rejects.toThrow( - /dashboard URL|--app/, - ); - expect(mockResolveUsersInstanceContext).toHaveBeenCalledTimes(2); - expect(mockOpenBrowser).not.toHaveBeenCalled(); + test("points a claimed passed key at `--app`, as not_linked", async () => { + mockKeyAccess.mockResolvedValue({ claimed: true, source: "the `--secret-key` flag" }); + + const error = (await open({ secretKey: "sk_test_loose", userId: "user_2x9k" }).catch( + (thrown: unknown) => thrown, + )) as CliError; + + expect(error.message).toContain("or pass `--app `"); + expect(error.code).toBe(ERROR_CODE.NOT_LINKED); + }); + + test("rejects a passed key that isn't a secret key before asking Clerk", async () => { + await expect(open({ secretKey: "ak_test_wrong", userId: "user_2x9k" })).rejects.toThrow( + /Expected a Secret key/, + ); + expect(mockKeyAccess).not.toHaveBeenCalled(); + }); }); test("no user-id + human mode: invokes pickUser and uses returned id", async () => { diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index 75ddc4540..e821bcb05 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -5,6 +5,7 @@ import { keyAccess, resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { keylessCopy } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; +import { validateKeyPrefix } from "../../lib/plapi.ts"; import { intro, outro } from "../../lib/spinner.ts"; import { isAgent } from "../../mode.ts"; import { buildDashboardUrl } from "../open/index.ts"; @@ -67,8 +68,9 @@ async function resolveKnownUserDashboardTarget(options: UsersOpenOptions): Promi } /** - * Fails with the real reason when the directory holds only a secret key, - * rather than letting the caller land on `not_linked`. + * Fails with the real reason when the only target is a secret key, rather + * than letting the caller land on `not_linked`. That key is `--secret-key` + * when given, or else the one this directory holds. * * A dashboard link needs an application ID, and the key doesn't carry one. * For an unclaimed application there is nothing to link to yet: `clerk link` @@ -81,7 +83,10 @@ async function assertNotKeyless(options: UsersOpenOptions, userId: string): Prom // and the original error is the accurate one. if (options.app || options.instance) return; - const keyless = await resolveKeylessTarget({ cwd: process.cwd() }); + if (options.secretKey) validateKeyPrefix(options.secretKey, "sk_"); + const keyless = options.secretKey + ? { secretKey: options.secretKey, source: "the `--secret-key` flag" } + : await resolveKeylessTarget({ cwd: process.cwd() }); if (keyless) { const access = await keyAccess(keyless); throw new CliError(keylessCopy.userDashboardNeedsClaim(userId, access), { @@ -111,9 +116,9 @@ export async function open(options: UsersOpenOptions = {}): Promise { } catch (error) { // A secret key alone can't build the deep-link — it is `/apps/{appId}/…`, // and an unclaimed application has no appId until somebody claims it. - // Checked before the `--secret-key` branch below because the advice that - // branch would eventually give ("use --app", "run `clerk link`") is - // unreachable here: no app exists to name. + // Checked before the `--secret-key` branch below, and against that key + // when given, because the advice that branch would give ("use --app", + // "run `clerk link`") is unreachable for an unclaimed one. await assertNotKeyless(options, userId); if (!options.secretKey) { diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index dd5db0c87..14cd7645a 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -150,7 +150,7 @@ export const keylessCopy = { userDashboardNeedsClaim: (userId: string, access: KeyAccess): string => (access.claimed === false - ? `This directory holds an unclaimed accountless application (secret key from ${access.source}), which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + + ? `The secret key from ${access.source} belongs to an unclaimed accountless application, which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + `Run \`clerk auth login\` to claim it, then \`clerk users open ${userId}\` will work.\n` : access.claimed ? `This application belongs to a Clerk account, but a dashboard link needs its application ID, which the secret key from ${access.source} doesn't carry.\n` + From 5ac382a8672a5c5f6ded38717a1aec9912150347 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 22 Oct 2026 14:12:29 -0400 Subject: [PATCH 6/7] fix(users): refuse `clerk users open --secret-key` without a user ID before the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `--secret-key`, no user ID and no app target, the picker ran first and then failed with "Cannot build a dashboard URL from --secret-key alone… run `clerk link`", which can't work for an unclaimed application. That path now gives the same claim-aware refusal as the user-ID path, before the picker. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/users/README.md | 2 ++ .../cli-core/src/commands/users/open.test.ts | 34 +++++++++++++++++++ packages/cli-core/src/commands/users/open.ts | 4 +++ 3 files changed, 40 insertions(+) diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 16955d21c..33ba3cce1 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -141,6 +141,8 @@ A secret key for a **claimed** application takes the same path, since the key al - `workspace_id` set: the application belongs to an account. The error says the key doesn't carry the application ID, and to run `clerk link` (or pass `--app `) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. It exits `not_linked`. - The lookup fails: the error gives both, and never calls the application unclaimed. +Without a user ID, `users open --secret-key ` (no `--app` or `--instance`) gives the same refusal before the user picker runs, since no user picked could produce a dashboard link. + ## API Endpoints | Method | Endpoint | Command(s) | diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index 05bcd739a..e8fb03526 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -289,6 +289,40 @@ describe("users open", () => { expect(mockOpenBrowser).not.toHaveBeenCalled(); }); + describe("no user-id + --secret-key without a resolvable dashboard target", () => { + beforeEach(() => { + mockResolveUsersInstanceContext.mockResolvedValue({ secretKey: "sk_test_loose" }); + }); + + test("refuses an unclaimed key before the picker, pointing at claiming it", async () => { + mockKeyAccess.mockResolvedValue({ claimed: false, source: "the `--secret-key` flag" }); + + const error = await open({ secretKey: "sk_test_loose" }).catch((thrown: unknown) => thrown); + + expect((error as Error).message).toContain( + "secret key from the `--secret-key` flag belongs to an unclaimed accountless application", + ); + expect((error as Error).message).toContain("clerk auth login"); + expect(mockKeyAccess).toHaveBeenCalledWith({ + secretKey: "sk_test_loose", + source: "the `--secret-key` flag", + }); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + + test("refuses a claimed key before the picker, pointing at `--app`, as not_linked", async () => { + mockKeyAccess.mockResolvedValue({ claimed: true, source: "the `--secret-key` flag" }); + + const error = (await open({ secretKey: "sk_test_loose" }).catch( + (thrown: unknown) => thrown, + )) as CliError; + + expect(error.message).toContain("or pass `--app `"); + expect(error.code).toBe(ERROR_CODE.NOT_LINKED); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + }); + test("forwards --app and --instance to the resolver", async () => { mockResolveProfile.mockResolvedValue(undefined); diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index e821bcb05..72f4ca2e6 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -191,6 +191,10 @@ export async function open(options: UsersOpenOptions = {}): Promise { instance: options.instance, }); + // Refuse before the picker, not after a user is chosen: with no app ID there + // is no dashboard link to build, whichever user is picked. + if (!target.appId) await assertNotKeyless(options, ""); + if (!target.secretKey) { throw new CliError("Internal: users open target is missing a secret key for pickUser.", { code: ERROR_CODE.NO_SECRET_KEY, From 0b314037e1fcce139b73098148ddddf397d059c6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 22 Oct 2026 15:44:26 -0400 Subject: [PATCH 7/7] fix(users): check the app target before the `clerk users open` picker With `--secret-key --instance` and no user ID, the picker still ran first and the missing-app usage error came only after a user was chosen. The app-target check now runs before `pickUser`, so no one picks a user that can't be opened. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/users/README.md | 2 +- .../cli-core/src/commands/users/open.test.ts | 9 ++++++ packages/cli-core/src/commands/users/open.ts | 29 +++++++++---------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 33ba3cce1..7771ab72d 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -141,7 +141,7 @@ A secret key for a **claimed** application takes the same path, since the key al - `workspace_id` set: the application belongs to an account. The error says the key doesn't carry the application ID, and to run `clerk link` (or pass `--app `) with `clerk auth login` or `CLERK_PLATFORM_API_KEY`. It exits `not_linked`. - The lookup fails: the error gives both, and never calls the application unclaimed. -Without a user ID, `users open --secret-key ` (no `--app` or `--instance`) gives the same refusal before the user picker runs, since no user picked could produce a dashboard link. +Without a user ID, `users open --secret-key ` (no `--app` or `--instance`) gives the same refusal before the user picker runs, since no user picked could produce a dashboard link. With `--instance` but no `--app`, it refuses before the picker too, with the usage error that names `--app`. ## API Endpoints diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index e8fb03526..689af9634 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -321,6 +321,15 @@ describe("users open", () => { expect(error.code).toBe(ERROR_CODE.NOT_LINKED); expect(mockPickUser).not.toHaveBeenCalled(); }); + + // `--instance` skips the claim check, but `--app` is still missing. + test("with --instance, refuses before the picker with the usage error", async () => { + await expect(open({ secretKey: "sk_test_loose", instance: "dev" })).rejects.toThrow( + /Cannot build a dashboard URL from --secret-key alone/, + ); + expect(mockKeyAccess).not.toHaveBeenCalled(); + expect(mockPickUser).not.toHaveBeenCalled(); + }); }); test("forwards --app and --instance to the resolver", async () => { diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index 72f4ca2e6..dc0eaecc5 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -191,22 +191,10 @@ export async function open(options: UsersOpenOptions = {}): Promise { instance: options.instance, }); - // Refuse before the picker, not after a user is chosen: with no app ID there - // is no dashboard link to build, whichever user is picked. - if (!target.appId) await assertNotKeyless(options, ""); - - if (!target.secretKey) { - throw new CliError("Internal: users open target is missing a secret key for pickUser.", { - code: ERROR_CODE.NO_SECRET_KEY, - }); - } - - userId = await pickUser({ - secretKey: target.secretKey, - message: "Pick a user to open in the dashboard:", - }); - + // Refuse before the picker, not after a user is chosen: with no app target + // there is no dashboard link to build, whichever user is picked. if (!target.appId || !target.instanceId) { + if (!target.appId) await assertNotKeyless(options, ""); if (options.secretKey) { throwUsageError( "Cannot build a dashboard URL from --secret-key alone when no app target can be resolved. Use --app instead, or run `clerk link` to link this directory.", @@ -218,6 +206,17 @@ export async function open(options: UsersOpenOptions = {}): Promise { }); } + if (!target.secretKey) { + throw new CliError("Internal: users open target is missing a secret key for pickUser.", { + code: ERROR_CODE.NO_SECRET_KEY, + }); + } + + userId = await pickUser({ + secretKey: target.secretKey, + message: "Pick a user to open in the dashboard:", + }); + const subpath = `users/${userId}`; const url = buildDashboardUrl(target.appId, target.instanceId, subpath);