diff --git a/.changeset/config-claimed-secret-key.md b/.changeset/config-claimed-secret-key.md new file mode 100644 index 000000000..77bff3e98 --- /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, 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/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 8aed97d61..8775f6de6 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -188,6 +188,14 @@ 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, 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 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`): 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 09731030a..e7676b52b 100644 --- a/packages/cli-core/src/commands/config/apply-patch.ts +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -33,7 +33,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..b86991b22 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 }; @@ -289,6 +295,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 +685,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 +699,164 @@ 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 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).not.toContain("unclaimed"); + 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("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"); + const { configPut } = await import("./push.ts"); + + const error = await configPut({ json: "{}", yes: true }).catch((e: unknown) => e); + + 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"); + }); + + // `--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 f05de4852..42aabccee 100644 --- a/packages/cli-core/src/commands/config/keyless.ts +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -13,9 +13,9 @@ */ import { bapiRequest } from "../../lib/bapi.ts"; -import { ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; -import type { KeylessTarget } from "../../lib/keyless-target.ts"; -import { keylessCopy } from "../../lib/copy.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { 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): 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), + keylessCopy.unsupportedInstanceFieldsLine(unknown, access), keylessCopy.supportedInstanceFieldsLine([...INSTANCE_FIELDS]), ]; @@ -134,7 +134,10 @@ function assertInstanceFields(fields: Record): void { ), ]; for (const reason of reasons) { - lines.push(keylessCopy.noRouteForInstanceFieldLine(reason)); + lines.push(keylessCopy.noRouteForInstanceFieldLine(reason, access)); + } + if (access && access.claimed !== false) { + lines.push(keylessCopy.secretKeyCantChangeLine(unknown, access)); } throwUsageError(lines.join("\n")); @@ -144,17 +147,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[], access?: KeyAccess): 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, access)); } 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 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, + access?: KeyAccess, ): asserts payload is Record> { const unknown = Object.keys(payload).filter((key) => !(key in KEYLESS_GROUPS)); if (unknown.length > 0) { @@ -162,19 +171,19 @@ export function assertKeylessPayload( const accountOnly = unknown.filter((key) => !API_REACHABLE_KEYLESS_KEYS.has(key)); const lines = [ - keylessCopy.unsupportedPayloadKeysLine(unknown), + 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)); + 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()); + lines.push(keylessCopy.claimForFullConfigLine(accountOnly, access)); } throwUsageError(lines.join("\n")); @@ -187,7 +196,26 @@ export function assertKeylessPayload( } if (payload.instance) { - assertInstanceFields(payload.instance as Record); + assertInstanceFields(payload.instance as Record, access); + } +} + +/** + * Runs a check worded for an unclaimed application, and when it refuses, asks + * 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: (access?: KeyAccess) => T, +): Promise { + try { + return check(); + } catch (error) { + if (!(error instanceof CliError)) throw error; + check(await keyAccess(target)); + throw error; } } @@ -200,7 +228,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, (access) => asGroupNames(keys, access)) + : 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/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/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 3406208f8..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 { 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,7 +62,7 @@ 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 keyAccess(target.keyless)), + { + code: ERROR_CODE.AUTH_REQUIRED, + }, + ); } const ctx = target.ctx; diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index a49d24ca4..7771ab72d 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -129,18 +129,27 @@ 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, 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`. +- 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. With `--instance` but no `--app`, it refuses before the picker too, with the usage error that names `--app`. + ## 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..689af9634 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", () => { @@ -100,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, ); }); @@ -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. @@ -188,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 () => { @@ -235,6 +289,49 @@ 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(); + }); + + // `--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 () => { mockResolveProfile.mockResolvedValue(undefined); diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index b67c1b867..dc0eaecc5 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -1,10 +1,11 @@ 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"; +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,23 +68,29 @@ 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 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. * - * 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 (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) { - 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 +114,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. - // 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); + // 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, 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) { throw error; @@ -184,18 +191,10 @@ export async function open(options: UsersOpenOptions = {}): Promise { instance: options.instance, }); - 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.", @@ -207,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); diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts index 2c12a4a35..51ca56e34 100644 --- a/packages/cli-core/src/lib/copy.ts +++ b/packages/cli-core/src/lib/copy.ts @@ -13,56 +13,153 @@ * identifiers (`lib/log.ts` renders them cyan), and a refusal names the * reason and a working alternative rather than just saying no. */ +/** + * 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. + */ +export type KeyAccess = { claimed: boolean | undefined; source: string }; + +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)}`; + +/** 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 = { // --- 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[], + access?: KeyAccess, + ): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} ${keyTarget(access)}: ${unknown.join(", ")}.\n` + + `Supported keys: ${supported.join(", ")}.` + + (access && !isUnclaimed(access) + ? `\n${pastSecretKey(access, `read ${unknown.join(", ")}`, claimForConfig)}` + : ""), - unsupportedPayloadKeysLine: (unknown: string[]): string => - `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed accountless application: ${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[]): 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[], 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: (): string => - "Run `clerk auth login` to claim the application and use the full config document.", + 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[]): string => - `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` for an unclaimed accountless application: ${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): 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, 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 --- - 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) + ? "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`.", - 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`.", + 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.", - 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, 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.", securityNeedsClaimedApplication: (): string => "Security recommendations are only available for a claimed application — the checks read the account-level config document (attack protection, passwords, sessions), which Clerk's Backend API does not expose to an unclaimed accountless application.\n" + "Run `clerk auth login` to claim this application, then re-run `clerk security audit`.", - 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 + ? `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` + + `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/lib/keyless-target.ts b/packages/cli-core/src/lib/keyless-target.ts index 7accee935..81e2c82f1 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, type KeyAccess } from "./copy.ts"; import { log } from "./log.ts"; export interface KeylessTarget { @@ -204,6 +205,33 @@ 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; + } +} + +/** What a refusal needs to word itself for this key: see `KeyAccess`. */ +export async function keyAccess(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. @@ -237,10 +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( - `--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.", - ); + throwUsageError(keylessCopy.instanceFlagWithKey(target.source, await keyAccess(target))); } return target; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 9cf6965b1..1565fae8b 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -204,6 +204,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 = {