Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/config-claimed-secret-key.md
Original file line number Diff line number Diff line change
@@ -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 <user-id>` 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 <id> --instance <dev|prod>` (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.
7 changes: 6 additions & 1 deletion packages/cli-core/src/commands/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --instance <dev|prod>` (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
Expand Down Expand Up @@ -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 |
16 changes: 9 additions & 7 deletions packages/cli-core/src/commands/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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,
});
}
Expand All @@ -74,7 +76,7 @@ function assertBillingTarget(target: InstanceTarget): void {
export async function billingEnable(options: BillingOptions): Promise<void> {
const targets = parseForTargets(options.for);
const target = await resolveInstanceTarget(options);
assertBillingTarget(target);
await assertBillingTarget(target);

const billing: Record<string, unknown> = {};
const payload: Record<string, unknown> = { billing };
Expand Down Expand Up @@ -139,7 +141,7 @@ async function offerBillingSkillInstall(options: BillingOptions): Promise<void>
export async function billingDisable(options: BillingOptions): Promise<void> {
const targets = parseForTargets(options.for);
const target = await resolveInstanceTarget(options);
assertBillingTarget(target);
await assertBillingTarget(target);

// No cascade: leave organization_settings untouched.
const billing: Record<string, unknown> = {};
Expand Down
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --instance <dev|prod>` (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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/config/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface ApplyPatchOptions {
export async function applyConfigPatch(opts: ApplyPatchOptions): Promise<boolean> {
const { target, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts;

assertPayloadWritable(target, payload);
await assertPayloadWritable(target, payload);

const current =
opts.currentConfig ??
Expand Down
9 changes: 6 additions & 3 deletions packages/cli-core/src/commands/config/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
assertKeylessPayload,
patchKeylessConfig,
readCurrentGroups,
withClaimedWording,
type KeylessWriteVerification,
} from "./keyless.ts";

Expand All @@ -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<string, unknown>,
): void {
if (target.kind === "keyless") assertKeylessPayload(payload);
): Promise<void> {
if (target.kind === "keyless") {
await withClaimedWording(target.keyless, (claimed) => assertKeylessPayload(payload, claimed));
}
}

/**
Expand Down
191 changes: 186 additions & 5 deletions packages/cli-core/src/commands/config/keyless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -661,21 +685,178 @@ 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");

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 <id> --instance <dev|prod>` (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");

Expand Down
Loading
Loading