From 35d4cb58f7071b6ed911474165a8816463bb4011 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:34:37 -0300 Subject: [PATCH 01/14] feat(impersonate): scaffold clerk impersonate command wiring --- packages/cli-core/src/cli-program.ts | 2 + .../src/commands/impersonate/README.md | 107 ++++++++++++++++++ .../src/commands/impersonate/impersonate.ts | 19 ++++ .../src/commands/impersonate/index.test.ts | 24 ++++ .../src/commands/impersonate/index.ts | 60 ++++++++++ .../src/commands/impersonate/revoke.ts | 14 +++ 6 files changed, 226 insertions(+) create mode 100644 packages/cli-core/src/commands/impersonate/README.md create mode 100644 packages/cli-core/src/commands/impersonate/impersonate.ts create mode 100644 packages/cli-core/src/commands/impersonate/index.test.ts create mode 100644 packages/cli-core/src/commands/impersonate/index.ts create mode 100644 packages/cli-core/src/commands/impersonate/revoke.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..860b06d4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -10,6 +10,7 @@ import { registerWhoami } from "./commands/whoami/index.ts"; import { registerOpen } from "./commands/open/index.ts"; import { registerApps } from "./commands/apps/index.ts"; import { registerUsers } from "./commands/users/index.ts"; +import { registerImpersonate } from "./commands/impersonate/index.ts"; import { registerEnv } from "./commands/env/index.ts"; import { registerConfig } from "./commands/config/index.ts"; import { registerToggles } from "./commands/toggles/index.ts"; @@ -60,6 +61,7 @@ const registrants: CommandRegistrant[] = [ registerOpen, registerApps, registerUsers, + registerImpersonate, registerEnv, registerConfig, registerToggles, diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md new file mode 100644 index 00000000..e3e52939 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -0,0 +1,107 @@ +# `clerk impersonate` + +Create a short-lived actor token for a Clerk user and print the sign-in URL that +lets you sign in as them ("impersonate"). Alias: `clerk imp`. + +## Auth + +`clerk impersonate` and `clerk impersonate revoke` both **require `clerk auth +login`** — there is no `--secret-key`-only bypass. The actor token's +`actor.sub` field is stamped with your logged-in email (`cli:`, or +`cli:+` with `--actor `) so every impersonation +session is traceable back to a real Clerk account, not just an API key. + +## Usage + +```sh +clerk imp # pick a user interactively, then confirm +clerk imp user_2x9k # impersonate a specific user +clerk imp alice@example.com # resolve by exact email match +clerk imp alice --open # open the sign-in URL in your browser immediately +clerk imp user_2x9k --print # print the URL only, no prompt, no browser +clerk imp user_2x9k --yes --expires-in 900 # skip confirmation, 15-minute token +clerk imp user_2x9k --actor oncall # stamp the actor as cli:+oncall +clerk imp revoke act_29w9... # revoke a pending actor token +``` + +## Options + +| Flag | Applies to | Description | +| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `[user]` | create | `user_...` ID, exact email, or fuzzy search term. Omit to pick interactively. | +| `` | revoke | Actor token ID to revoke (required) | +| `--secret-key ` | both | Backend API secret key to use | +| `--app ` | both | Application ID to target (works from any directory) | +| `--instance ` | both | Instance to target (`dev`, `prod`, or a full instance ID) | +| `--actor ` | create | Extra context appended to the actor stamp: `cli:+` | +| `--expires-in ` | create | Actor token lifetime in seconds, integer >= 1. Defaults to 3600 (1 hour), matching the dashboard's short expiry. | +| `--open` | create | Open the sign-in URL in your browser immediately, skipping the prompt | +| `--print` | create | Print the sign-in URL only — no prompt, no browser | +| `--yes` | create | Skip the confirmation prompt | + +`clerk impersonate revoke` never prompts for confirmation — it's a low-risk +operation on an already-pending token. + +## User resolution + +Given `[user]`: + +1. `/^user_[A-Za-z0-9]+$/` → used directly, no lookup. +2. Contains `@` → exact match via `GET /v1/users?email_address=`. +3. Otherwise → fuzzy match via `GET /v1/users?query=`. + +Then: + +- 0 matches → usage error. +- 1 match → used directly. +- 2+ matches, human mode → an interactive picker (the picker's search box + always starts empty — there's no way to prefill it with your original + search term; see `commands/users/interactive/pick-user.ts`). +- 2+ matches, agent mode → usage error listing candidate user IDs so you can + re-run with a specific `user_...` ID. +- No `[user]` argument, human mode → the interactive picker. +- No `[user]` argument, agent mode → usage error (agent mode never prompts). + +## Confirmation and the production guardrail + +In human mode, unless `--yes` is passed, `clerk impersonate` asks +"Impersonate `` on `` (``)?" defaulting to **No**. When +the target instance's label is `production`, a warning is printed above the +prompt: + +> production — signs you in as this user and bypasses their MFA. may count +> against your monthly impersonation quota. + +The CLI cannot read your live impersonation quota (Backend API has no +endpoint for it), so this is a generic reminder, not a number. Agent mode +never prompts and proceeds directly. + +## Output modes + +The sign-in URL from BAPI's response is always printed **verbatim** via +`log.data()` — never reconstructed client-side. + +| Condition | Behavior | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--print` | Print the URL, exit. No prompt, no browser. | +| `--open` | Print the URL, open the browser immediately. No prompt. | +| TTY, no `--print`/`--open` | Print the URL, then prompt "Press Enter to open in your browser (Ctrl+C to skip)". | +| Non-TTY, human mode, no `--print`/`--open` | Same as `--print` — never hangs waiting for input that can't arrive. | +| Agent mode | Emit one JSON line via `log.data()`: `{ url, id, userId, actor, appId, appLabel, instanceId, instanceLabel, expiresInSeconds }`. Never prompts, never opens a browser. | + +## Clerk API endpoints + +| Method | Path | Used by | +| ------ | --------------------------------- | ---------------------------------------------------- | +| `GET` | `/v1/users?email_address=` | Resolving `[user]` when it contains `@` | +| `GET` | `/v1/users?query=` | Resolving `[user]` fuzzy search | +| `GET` | `/v1/users?query=&limit=21` | The interactive user picker (`pickUser`) | +| `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) | +| `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) | + +`POST /v1/actor_tokens` returns `402` when impersonation isn't enabled on the +app's subscription plan, and `422` when the impersonation quota is exhausted +(the CLI surfaces `used`/`limit` from the error's `meta` when BAPI includes +them). + +No part of this command is mocked or stubbed. diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts new file mode 100644 index 00000000..f0ea380e --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -0,0 +1,19 @@ +import { CliError } from "../../lib/errors.ts"; + +export type ImpersonateOptions = { + user?: string; + secretKey?: string; + app?: string; + instance?: string; + actor?: string; + expiresIn?: number; + open?: boolean; + print?: boolean; + yes?: boolean; +}; + +// Task 4 replaces this function body with the full create flow: login gate, +// targeting, user resolution, confirm, POST /actor_tokens, and output modes. +export async function impersonate(_options: ImpersonateOptions = {}): Promise { + throw new CliError("`clerk impersonate` is not implemented yet."); +} diff --git a/packages/cli-core/src/commands/impersonate/index.test.ts b/packages/cli-core/src/commands/impersonate/index.test.ts new file mode 100644 index 00000000..756bcd04 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/index.test.ts @@ -0,0 +1,24 @@ +import { test, expect, describe } from "bun:test"; +import { createProgram } from "../../cli-program.ts"; + +describe("impersonate command wiring", () => { + test("registers `impersonate` with `imp` alias and an optional `[user]` argument", () => { + const program = createProgram(); + const impersonateCommand = program.commands.find((c) => c.name() === "impersonate"); + + expect(impersonateCommand).toBeDefined(); + expect(impersonateCommand?.aliases()).toContain("imp"); + expect(impersonateCommand?.registeredArguments[0]?.name()).toBe("user"); + expect(impersonateCommand?.registeredArguments[0]?.required).toBe(false); + }); + + test("registers a required `revoke ` subcommand", () => { + const program = createProgram(); + const impersonateCommand = program.commands.find((c) => c.name() === "impersonate"); + const revokeCommand = impersonateCommand?.commands.find((c) => c.name() === "revoke"); + + expect(revokeCommand).toBeDefined(); + expect(revokeCommand?.registeredArguments[0]?.name()).toBe("actorTokenId"); + expect(revokeCommand?.registeredArguments[0]?.required).toBe(true); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/index.ts b/packages/cli-core/src/commands/impersonate/index.ts new file mode 100644 index 00000000..78357315 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/index.ts @@ -0,0 +1,60 @@ +import { createArgument } from "@commander-js/extra-typings"; +import type { Program } from "../../cli-program.ts"; +import { parseIntegerOption } from "../../lib/option-parsers.ts"; +import { impersonate } from "./impersonate.ts"; +import { revoke } from "./revoke.ts"; + +export function registerImpersonate(program: Program): void { + const impersonateCommand = program + .command("impersonate") + .alias("imp") + .description("Impersonate a Clerk user") + .addArgument( + createArgument( + "[user]", + "User ID (user_...), exact email, or search term to impersonate. Omit to pick interactively.", + ), + ) + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--actor ", "Extra context appended to the actor stamp: cli:+") + .option("--expires-in ", "Actor token lifetime in seconds (default 3600)", (value) => + parseIntegerOption(value, "--expires-in", { min: 1 }), + ) + .option("--open", "Open the sign-in URL in your browser immediately, skipping the prompt") + .option("--print", "Print the sign-in URL only — no prompt, no browser") + .option("--yes", "Skip the impersonation confirmation prompt") + .setExamples([ + { command: "clerk imp", description: "Pick a user interactively and impersonate" }, + { command: "clerk imp user_2x9k", description: "Impersonate a specific user" }, + { + command: "clerk imp alice@example.com --open", + description: "Impersonate by exact email and open the session in your browser", + }, + { command: "clerk imp revoke act_29w9...", description: "Revoke a pending actor token" }, + ]) + .action((user, _opts, cmd) => + impersonate({ + ...(cmd.optsWithGlobals() as Parameters[0]), + user, + }), + ); + + impersonateCommand + .command("revoke") + .description("Revoke a pending actor token") + .addArgument(createArgument("", "Actor token ID to revoke")) + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .setExamples([ + { command: "clerk imp revoke act_29w9...", description: "Revoke a pending actor token" }, + ]) + .action((actorTokenId, _opts, cmd) => + revoke({ + ...(cmd.optsWithGlobals() as Parameters[0]), + actorTokenId, + }), + ); +} diff --git a/packages/cli-core/src/commands/impersonate/revoke.ts b/packages/cli-core/src/commands/impersonate/revoke.ts new file mode 100644 index 00000000..6fee87a5 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/revoke.ts @@ -0,0 +1,14 @@ +import { CliError } from "../../lib/errors.ts"; + +export type RevokeOptions = { + actorTokenId: string; + secretKey?: string; + app?: string; + instance?: string; +}; + +// Task 5 replaces this function body with the full revoke flow: login gate, +// targeting, and POST /actor_tokens/{id}/revoke. +export async function revoke(_options: RevokeOptions): Promise { + throw new CliError("`clerk impersonate revoke` is not implemented yet."); +} From 75028a87a8d8396d0e8e202544a2d38b05f3fa9f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:35:12 -0300 Subject: [PATCH 02/14] feat(impersonate): add login gate and actor stamp helper --- .../src/commands/impersonate/actor.test.ts | 85 +++++++++++++++++++ .../src/commands/impersonate/actor.ts | 38 +++++++++ 2 files changed, 123 insertions(+) create mode 100644 packages/cli-core/src/commands/impersonate/actor.test.ts create mode 100644 packages/cli-core/src/commands/impersonate/actor.ts diff --git a/packages/cli-core/src/commands/impersonate/actor.test.ts b/packages/cli-core/src/commands/impersonate/actor.test.ts new file mode 100644 index 00000000..15b0be44 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/actor.test.ts @@ -0,0 +1,85 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { AuthError } from "../../lib/errors.ts"; + +const mockGetValidToken = mock(); +mock.module("../../lib/credential-store.ts", () => ({ + getValidToken: (...args: unknown[]) => mockGetValidToken(...args), +})); + +const mockFetchUserInfo = mock(); +mock.module("../../lib/token-exchange.ts", () => ({ + fetchUserInfo: (...args: unknown[]) => mockFetchUserInfo(...args), +})); + +const { requireLoginEmail, buildActorStamp, CLERK_CLI_ISSUER } = await import("./actor.ts"); + +describe("requireLoginEmail", () => { + beforeEach(() => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_admin", email: "admin@example.com" }); + }); + + afterEach(() => { + mockGetValidToken.mockReset(); + mockFetchUserInfo.mockReset(); + }); + + test("returns the login email when a valid token exists", async () => { + await expect(requireLoginEmail()).resolves.toBe("admin@example.com"); + }); + + test("throws AuthError(not_logged_in) when there is no stored token", async () => { + mockGetValidToken.mockResolvedValue(null); + + let error: unknown; + try { + await requireLoginEmail(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).reason).toBe("not_logged_in"); + expect(mockFetchUserInfo).not.toHaveBeenCalled(); + }); + + test("throws AuthError(session_expired) when fetchUserInfo fails", async () => { + mockFetchUserInfo.mockRejectedValue(new Error("401")); + + let error: unknown; + try { + await requireLoginEmail(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).reason).toBe("session_expired"); + }); +}); + +describe("buildActorStamp", () => { + test("stamps `cli:` with no --actor context", () => { + expect(buildActorStamp("admin@example.com")).toEqual({ + sub: "cli:admin@example.com", + iss: CLERK_CLI_ISSUER, + }); + }); + + test("appends `+` when a --actor context is supplied", () => { + expect(buildActorStamp("admin@example.com", "oncall")).toEqual({ + sub: "cli:admin@example.com+oncall", + iss: CLERK_CLI_ISSUER, + }); + }); + + test("does not attempt to parse `+` characters already present in the email", () => { + // Emails can legally contain '+' (plus-addressing). The stamp is a + // write-only audit label — this locks in that we never try to be clever + // about splitting it back apart. + expect(buildActorStamp("admin+test@example.com", "oncall")).toEqual({ + sub: "cli:admin+test@example.com+oncall", + iss: CLERK_CLI_ISSUER, + }); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/actor.ts b/packages/cli-core/src/commands/impersonate/actor.ts new file mode 100644 index 00000000..a1b46341 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/actor.ts @@ -0,0 +1,38 @@ +import { getValidToken } from "../../lib/credential-store.ts"; +import { AuthError } from "../../lib/errors.ts"; +import { fetchUserInfo } from "../../lib/token-exchange.ts"; + +export const CLERK_CLI_ISSUER = "clerk-cli"; + +/** + * Verify the CLI has an active login session and return the operator's + * login email. Impersonation always requires `clerk login` — there is no + * secret-key bypass, because the actor stamp on every actor token must + * identify a real Clerk account for audit purposes. + */ +export async function requireLoginEmail(): Promise { + const token = await getValidToken(); + if (!token) { + throw new AuthError({ reason: "not_logged_in" }); + } + + try { + const userInfo = await fetchUserInfo(token); + return userInfo.email; + } catch { + throw new AuthError({ reason: "session_expired" }); + } +} + +/** + * Build the actor stamp embedded in every actor token: `cli:`, + * or `cli:+` when `--actor ` is supplied. + * This is a write-only audit label — never parse it back apart. + */ +export function buildActorStamp( + loginEmail: string, + actorContext?: string, +): { sub: string; iss: typeof CLERK_CLI_ISSUER } { + const sub = actorContext ? `cli:${loginEmail}+${actorContext}` : `cli:${loginEmail}`; + return { sub, iss: CLERK_CLI_ISSUER }; +} From 913af590269f8902b0cb67c08a975e6200185cc5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:35:59 -0300 Subject: [PATCH 03/14] feat(impersonate): add user argument resolution --- .../commands/impersonate/resolve-user.test.ts | 125 ++++++++++++++++++ .../src/commands/impersonate/resolve-user.ts | 79 +++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/cli-core/src/commands/impersonate/resolve-user.test.ts create mode 100644 packages/cli-core/src/commands/impersonate/resolve-user.ts diff --git a/packages/cli-core/src/commands/impersonate/resolve-user.test.ts b/packages/cli-core/src/commands/impersonate/resolve-user.test.ts new file mode 100644 index 00000000..e3c42d83 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/resolve-user.test.ts @@ -0,0 +1,125 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { CliError } from "../../lib/errors.ts"; +import { mock } from "bun:test"; + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockPickUser = mock(); +mock.module("../users/interactive/pick-user.ts", () => ({ + pickUser: (...args: unknown[]) => mockPickUser(...args), +})); + +const { resolveImpersonationTarget } = await import("./resolve-user.ts"); + +describe("resolveImpersonationTarget", () => { + beforeEach(() => { + setMode("human"); + mockBapiRequest.mockReset(); + mockPickUser.mockReset(); + }); + + test("uses a user_xxx argument directly without calling BAPI", async () => { + const result = await resolveImpersonationTarget("user_2x9k", "sk_test_123"); + expect(result).toBe("user_2x9k"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + + test("searches by exact email_address filter when the argument contains '@'", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_alice" }], + rawBody: "", + }); + + const result = await resolveImpersonationTarget("alice@example.com", "sk_test_123"); + + expect(result).toBe("user_alice"); + expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ + method: "GET", + path: "/users?email_address=alice%40example.com&limit=6", + secretKey: "sk_test_123", + }); + }); + + test("searches by fuzzy query when the argument has no '@'", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_bob" }], + rawBody: "", + }); + + await resolveImpersonationTarget("bob", "sk_test_123"); + + expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ + path: "/users?query=bob&limit=6", + }); + }); + + test("throws a usage error when zero users match", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [], + rawBody: "", + }); + + await expect(resolveImpersonationTarget("nobody@example.com", "sk_test_123")).rejects.toThrow( + /no user found matching "nobody@example.com"/i, + ); + }); + + test("human mode: opens the picker (no prefilled query) when 2+ users match", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_a" }, { id: "user_b" }], + rawBody: "", + }); + mockPickUser.mockResolvedValue("user_a"); + + const result = await resolveImpersonationTarget("smith", "sk_test_123"); + + expect(result).toBe("user_a"); + expect(mockPickUser).toHaveBeenCalledWith( + expect.objectContaining({ secretKey: "sk_test_123" }), + ); + }); + + test("agent mode: throws a usage error listing candidate user IDs when 2+ users match", async () => { + setMode("agent"); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_a" }, { id: "user_b" }], + rawBody: "", + }); + + await expect(resolveImpersonationTarget("smith", "sk_test_123")).rejects.toThrow( + /user_a, user_b/, + ); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + + test("human mode: no argument opens the picker", async () => { + mockPickUser.mockResolvedValue("user_picked"); + + const result = await resolveImpersonationTarget(undefined, "sk_test_123"); + + expect(result).toBe("user_picked"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("agent mode: no argument throws a usage error instead of prompting", async () => { + setMode("agent"); + + await expect(resolveImpersonationTarget(undefined, "sk_test_123")).rejects.toThrow(CliError); + expect(mockPickUser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/resolve-user.ts b/packages/cli-core/src/commands/impersonate/resolve-user.ts new file mode 100644 index 00000000..01442e5f --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/resolve-user.ts @@ -0,0 +1,79 @@ +import { bapiRequest } from "../../lib/bapi.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { isAgent } from "../../mode.ts"; +import { pickUser } from "../users/interactive/pick-user.ts"; + +const USER_ID_PATTERN = /^user_[A-Za-z0-9]+$/; +const CANDIDATE_LIMIT = 5; + +type ImpersonationUserCandidate = { + id: string; +}; + +/** + * Resolve the `[user]` positional argument (or its absence) to a single + * `user_...` ID: + * + * - `user_...` → used directly, no lookup. + * - contains `@` → exact match via `email_address` filter. + * - otherwise → fuzzy match via `query`. + * - 0 matches → usage error. 1 match → used directly. + * - 2+ matches: human mode opens the picker (no prefilled query support — + * see pick-user.ts); agent mode errors with candidate IDs. + * - no argument: human mode opens the picker; agent mode errors (agent mode + * never prompts). + */ +export async function resolveImpersonationTarget( + user: string | undefined, + secretKey: string, +): Promise { + if (user === undefined) { + if (isAgent()) { + throwUsageError("A user is required in agent mode. Pass it as a positional argument."); + } + return pickUser({ secretKey, message: "Pick a user to impersonate:" }); + } + + if (USER_ID_PATTERN.test(user)) { + return user; + } + + const searchParams = new URLSearchParams(); + if (user.includes("@")) { + searchParams.set("email_address", user); + } else { + searchParams.set("query", user); + } + searchParams.set("limit", String(CANDIDATE_LIMIT + 1)); + + const response = await bapiRequest({ + method: "GET", + path: `/users?${searchParams}`, + secretKey, + }); + + const users = Array.isArray(response.body) ? (response.body as ImpersonationUserCandidate[]) : []; + + if (users.length === 0) { + throwUsageError(`No user found matching "${user}".`); + } + + if (users.length === 1) { + return users[0]!.id; + } + + if (isAgent()) { + const candidates = users + .slice(0, CANDIDATE_LIMIT) + .map((candidate) => candidate.id) + .join(", "); + throwUsageError( + `Multiple users match "${user}": ${candidates}. Pass a specific user_id instead.`, + ); + } + + // pickUser has no prefilled/initial-query support (see pick-user.ts), so + // the picker re-opens with an empty search box rather than the original + // term. + return pickUser({ secretKey, message: `Multiple users match "${user}" — pick one:` }); +} From d018638dbf6ad102325c9928b75225963a75d3d8 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:38:08 -0300 Subject: [PATCH 04/14] feat(impersonate): implement actor token creation flow --- .../commands/impersonate/impersonate.test.ts | 278 ++++++++++++++++++ .../src/commands/impersonate/impersonate.ts | 165 ++++++++++- packages/cli-core/src/lib/errors.ts | 4 + 3 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 packages/cli-core/src/commands/impersonate/impersonate.test.ts diff --git a/packages/cli-core/src/commands/impersonate/impersonate.test.ts b/packages/cli-core/src/commands/impersonate/impersonate.test.ts new file mode 100644 index 00000000..c3a6f2b6 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/impersonate.test.ts @@ -0,0 +1,278 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { BapiError, CliError, ERROR_CODE } from "../../lib/errors.ts"; + +const mockRequireLoginEmail = mock(); +const mockBuildActorStamp = mock(); +mock.module("./actor.ts", () => ({ + requireLoginEmail: (...args: unknown[]) => mockRequireLoginEmail(...args), + buildActorStamp: (...args: unknown[]) => mockBuildActorStamp(...args), +})); + +const mockResolveImpersonationTarget = mock(); +mock.module("./resolve-user.ts", () => ({ + resolveImpersonationTarget: (...args: unknown[]) => mockResolveImpersonationTarget(...args), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("../users/interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "{}", +})); + +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const { impersonate } = await import("./impersonate.ts"); + +const SIGN_IN_URL = "https://example.clerk.accounts.dev/v1/tickets/accept?ticket=tkt_abc"; + +const CTX = { + secretKey: "sk_test_123", + appId: "app_abc123", + appLabel: "My App", + instanceId: "ins_dev789", + instanceLabel: "development", +}; + +const PROD_CTX = { ...CTX, instanceLabel: "production" }; + +function setStdinTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); +} + +describe("impersonate", () => { + const captured = useCaptureLog(); + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + setMode("human"); + setStdinTTY(true); + mockRequireLoginEmail.mockResolvedValue("admin@example.com"); + mockBuildActorStamp.mockReturnValue({ sub: "cli:admin@example.com", iss: "clerk-cli" }); + mockResolveUsersInstanceContext.mockResolvedValue(CTX); + mockResolveImpersonationTarget.mockResolvedValue("user_2x9k"); + mockConfirm.mockResolvedValue(true); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { id: "act_1", url: SIGN_IN_URL }, + rawBody: "", + }); + }); + + afterEach(() => { + setStdinTTY(originalIsTTY); + mockRequireLoginEmail.mockReset(); + mockBuildActorStamp.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockResolveImpersonationTarget.mockReset(); + mockBapiRequest.mockReset(); + mockOpenBrowser.mockReset(); + mockConfirm.mockReset(); + }); + + test("hard-fails before any BAPI call when requireLoginEmail rejects", async () => { + mockRequireLoginEmail.mockRejectedValue( + new CliError("Not logged in. Run `clerk auth login` to authenticate", { + code: ERROR_CODE.AUTH_REQUIRED, + }), + ); + + await expect(impersonate({ user: "user_2x9k" })).rejects.toThrow(/Not logged in/); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("human mode: confirms before creating the token, prints the URL verbatim, and offers to open it", async () => { + mockConfirm.mockResolvedValueOnce(true); // "Impersonate ... ?" + mockConfirm.mockResolvedValueOnce(true); // "Press Enter to open ..." + + await impersonate({ user: "user_2x9k" }); + + expect(mockConfirm).toHaveBeenNthCalledWith(1, expect.objectContaining({ default: false })); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens", + secretKey: CTX.secretKey, + body: JSON.stringify({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 3600, + }), + }); + expect(captured.out).toBe(SIGN_IN_URL); + expect(mockOpenBrowser).toHaveBeenCalledWith(SIGN_IN_URL); + }); + + test("declining the confirm prompt aborts without calling BAPI", async () => { + mockConfirm.mockResolvedValueOnce(false); + + await expect(impersonate({ user: "user_2x9k" })).rejects.toThrow("User aborted"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("--yes skips the confirm prompt", async () => { + await impersonate({ user: "user_2x9k", yes: true, print: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("production instance: prints the guardrail warning before confirming", async () => { + mockResolveUsersInstanceContext.mockResolvedValue(PROD_CTX); + + await impersonate({ user: "user_2x9k", print: true }); + + expect(captured.err).toContain("production — signs you in as this user and bypasses their MFA"); + }); + + test("--expires-in overrides the default 3600s lifetime", async () => { + await impersonate({ user: "user_2x9k", expiresIn: 900, print: true, yes: true }); + + const requestBody = (mockBapiRequest.mock.calls[0]![0] as { body: string }).body; + expect(JSON.parse(requestBody)).toEqual({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 900, + }); + }); + + test("--actor passes context through to buildActorStamp", async () => { + mockBuildActorStamp.mockReturnValue({ sub: "cli:admin@example.com+oncall", iss: "clerk-cli" }); + + await impersonate({ user: "user_2x9k", actor: "oncall", print: true, yes: true }); + + expect(mockBuildActorStamp).toHaveBeenCalledWith("admin@example.com", "oncall"); + }); + + test("--print prints the URL only — no confirm-to-open prompt, no browser", async () => { + await impersonate({ user: "user_2x9k", yes: true, print: true }); + + expect(captured.out).toBe(SIGN_IN_URL); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("--open prints the URL and opens the browser immediately, skipping the prompt", async () => { + await impersonate({ user: "user_2x9k", yes: true, open: true }); + + expect(mockOpenBrowser).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("non-TTY stdin in human mode behaves like --print and never prompts", async () => { + setStdinTTY(false); + + await impersonate({ user: "user_2x9k", yes: true }); + + expect(captured.out).toBe(SIGN_IN_URL); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("agent mode: emits structured JSON, never confirms, never opens a browser", async () => { + setMode("agent"); + + await impersonate({ user: "user_2x9k" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(JSON.parse(captured.out)).toEqual({ + url: SIGN_IN_URL, + id: "act_1", + userId: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + appId: CTX.appId, + appLabel: CTX.appLabel, + instanceId: CTX.instanceId, + instanceLabel: CTX.instanceLabel, + expiresInSeconds: 3600, + }); + }); + + test("402 from BAPI surfaces the plan-gate error, distinct from the quota error", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError(402, JSON.stringify({ errors: [{ message: "not enabled" }] }), new Headers()), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe("Impersonation isn't enabled on this app's plan."); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED); + }); + + test("422 from BAPI with limit/used meta surfaces a quota message including the counts", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ + errors: [{ message: "limit exceeded", meta: { limit: 100, used: 100 } }], + }), + new Headers(), + ), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe( + "Impersonation limit exceeded (used 100/100 this billing period).", + ); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + }); + + test("422 from BAPI without limit/used meta still surfaces a generic quota message", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ errors: [{ message: "limit exceeded" }] }), + new Headers(), + ), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe("Impersonation limit exceeded."); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index f0ea380e..1bf870bc 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -1,4 +1,23 @@ -import { CliError } from "../../lib/errors.ts"; +import { bold, cyan, dim } from "../../lib/color.ts"; +import { bapiRequest } from "../../lib/bapi.ts"; +import { + BapiError, + CliError, + ERROR_CODE, + throwUserAbort, + withApiContext, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { + resolveUsersInstanceContext, + type UsersInstanceContext, +} from "../users/interactive/instance-context.ts"; +import { buildActorStamp, requireLoginEmail } from "./actor.ts"; +import { resolveImpersonationTarget } from "./resolve-user.ts"; export type ImpersonateOptions = { user?: string; @@ -12,8 +31,144 @@ export type ImpersonateOptions = { yes?: boolean; }; -// Task 4 replaces this function body with the full create flow: login gate, -// targeting, user resolution, confirm, POST /actor_tokens, and output modes. -export async function impersonate(_options: ImpersonateOptions = {}): Promise { - throw new CliError("`clerk impersonate` is not implemented yet."); +const DEFAULT_EXPIRES_IN_SECONDS = 3600; + +function isProductionInstance(ctx: UsersInstanceContext): boolean { + if (ctx.instanceLabel) return ctx.instanceLabel === "production"; + return ctx.secretKey.startsWith("sk_live_"); +} + +async function openOrWarn(url: string): Promise { + const result = await openBrowser(url); + if (!result.ok) { + log.warn( + `Could not open your browser automatically. Open this URL to continue:\n ${cyan(url)}\n${dim(`(Reason: ${result.reason})`)}`, + ); + } +} + +function actorTokenErrorToCliError(error: unknown): CliError | undefined { + if (!(error instanceof BapiError)) return undefined; + + if (error.status === 402) { + return new CliError("Impersonation isn't enabled on this app's plan.", { + code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + }); + } + + if (error.status === 422) { + const meta = error.meta ?? {}; + const limit = typeof meta.limit === "number" ? meta.limit : undefined; + const used = typeof meta.used === "number" ? meta.used : undefined; + const quota = + limit !== undefined && used !== undefined + ? ` (used ${used}/${limit} this billing period)` + : ""; + return new CliError(`Impersonation limit exceeded${quota}.`, { + code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + }); + } + + return undefined; +} + +export async function impersonate(options: ImpersonateOptions = {}): Promise { + const loginEmail = await requireLoginEmail(); + + const ctx = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + const userId = await resolveImpersonationTarget(options.user, ctx.secretKey); + const expiresIn = options.expiresIn ?? DEFAULT_EXPIRES_IN_SECONDS; + const actor = buildActorStamp(loginEmail, options.actor); + + const appLabel = ctx.appLabel ?? ctx.appId ?? "this application"; + const instanceLabel = ctx.instanceLabel ?? ctx.instanceId ?? "this instance"; + + if (!options.yes && isHuman()) { + if (isProductionInstance(ctx)) { + log.warn( + "production — signs you in as this user and bypasses their MFA. may count against your monthly impersonation quota.", + ); + } + const proceed = await confirm({ + message: `Impersonate ${cyan(userId)} on ${bold(appLabel)} (${instanceLabel})?`, + default: false, + }); + if (!proceed) { + throwUserAbort(); + } + } + + const payload = { + user_id: userId, + actor: { sub: actor.sub, iss: actor.iss }, + expires_in_seconds: expiresIn, + }; + + let response; + try { + response = await withApiContext( + withSpinner("Creating impersonation session...", () => + bapiRequest({ + method: "POST", + path: "/actor_tokens", + secretKey: ctx.secretKey, + body: JSON.stringify(payload), + }), + ), + "Failed to create impersonation session", + ); + } catch (error) { + const cliError = actorTokenErrorToCliError(error); + if (cliError) throw cliError; + throw error; + } + + const body = response.body as { id: string; url: string }; + + if (isAgent()) { + log.data( + JSON.stringify({ + url: body.url, + id: body.id, + userId, + actor: { sub: actor.sub, iss: actor.iss }, + appId: ctx.appId, + appLabel: ctx.appLabel, + instanceId: ctx.instanceId, + instanceLabel: ctx.instanceLabel, + expiresInSeconds: expiresIn, + }), + ); + return; + } + + // Always print the URL verbatim first, regardless of what happens next — + // if --print/--open are both passed, --print wins (never opens). + log.data(body.url); + + if (options.print) { + return; + } + + if (options.open) { + await openOrWarn(body.url); + return; + } + + if (!process.stdin.isTTY) { + return; + } + + const shouldOpen = await confirm({ + message: "Press Enter to open in your browser (Ctrl+C to skip)", + default: true, + }); + if (shouldOpen) { + await openOrWarn(body.url); + } } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index bff5a8cf..579ad19c 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,10 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** Impersonation isn't enabled on the app's subscription plan. */ + IMPERSONATION_NOT_ENABLED: "impersonation_not_enabled", + /** Actor token creation rejected because the impersonation quota is exhausted. */ + IMPERSONATION_LIMIT_EXCEEDED: "impersonation_limit_exceeded", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 7a3b942b6eb327c43f2718b4993fee1fab527033 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:38:44 -0300 Subject: [PATCH 05/14] feat(impersonate): implement actor token revoke flow --- .../src/commands/impersonate/revoke.test.ts | 102 ++++++++++++++++++ .../src/commands/impersonate/revoke.ts | 45 +++++++- 2 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 packages/cli-core/src/commands/impersonate/revoke.test.ts diff --git a/packages/cli-core/src/commands/impersonate/revoke.test.ts b/packages/cli-core/src/commands/impersonate/revoke.test.ts new file mode 100644 index 00000000..7e5a79c5 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/revoke.test.ts @@ -0,0 +1,102 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; + +const mockRequireLoginEmail = mock(); +mock.module("./actor.ts", () => ({ + requireLoginEmail: (...args: unknown[]) => mockRequireLoginEmail(...args), + buildActorStamp: () => ({ sub: "cli:unused", iss: "clerk-cli" }), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("../users/interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const { revoke } = await import("./revoke.ts"); + +const CTX = { + secretKey: "sk_test_123", + appId: "app_abc123", + appLabel: "My App", + instanceId: "ins_dev789", + instanceLabel: "development", +}; + +describe("revoke", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + setMode("human"); + mockRequireLoginEmail.mockResolvedValue("admin@example.com"); + mockResolveUsersInstanceContext.mockResolvedValue(CTX); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { id: "act_1", status: "revoked" }, + rawBody: "", + }); + }); + + afterEach(() => { + mockRequireLoginEmail.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockBapiRequest.mockReset(); + }); + + test("hard-fails before touching BAPI when not logged in", async () => { + mockRequireLoginEmail.mockRejectedValue( + new CliError("Not logged in. Run `clerk auth login` to authenticate", { + code: ERROR_CODE.AUTH_REQUIRED, + }), + ); + + await expect(revoke({ actorTokenId: "act_1" })).rejects.toThrow(/Not logged in/); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("calls POST /actor_tokens/{id}/revoke against the resolved secret key", async () => { + await revoke({ actorTokenId: "act_1" }); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens/act_1/revoke", + secretKey: CTX.secretKey, + }); + }); + + test("human mode: prints a success message to stderr", async () => { + await revoke({ actorTokenId: "act_1" }); + expect(captured.err).toContain("Revoked actor token act_1."); + }); + + test("agent mode: emits structured JSON with the token id and status", async () => { + setMode("agent"); + await revoke({ actorTokenId: "act_1" }); + expect(JSON.parse(captured.out)).toEqual({ id: "act_1", status: "revoked" }); + }); + + test("targets the app/instance from resolveUsersInstanceContext", async () => { + await revoke({ actorTokenId: "act_1", app: "app_abc123", instance: "prod" }); + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ + secretKey: undefined, + app: "app_abc123", + instance: "prod", + }); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/revoke.ts b/packages/cli-core/src/commands/impersonate/revoke.ts index 6fee87a5..f8b3b0a1 100644 --- a/packages/cli-core/src/commands/impersonate/revoke.ts +++ b/packages/cli-core/src/commands/impersonate/revoke.ts @@ -1,4 +1,10 @@ -import { CliError } from "../../lib/errors.ts"; +import { bapiRequest } from "../../lib/bapi.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { resolveUsersInstanceContext } from "../users/interactive/instance-context.ts"; +import { requireLoginEmail } from "./actor.ts"; export type RevokeOptions = { actorTokenId: string; @@ -7,8 +13,37 @@ export type RevokeOptions = { instance?: string; }; -// Task 5 replaces this function body with the full revoke flow: login gate, -// targeting, and POST /actor_tokens/{id}/revoke. -export async function revoke(_options: RevokeOptions): Promise { - throw new CliError("`clerk impersonate revoke` is not implemented yet."); +export async function revoke(options: RevokeOptions): Promise { + // Login is required for revoke too — the login email itself isn't used + // here (revoking doesn't stamp a new actor), but the gate must run before + // any BAPI call, same as create. + await requireLoginEmail(); + + const ctx = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + const response = await withApiContext( + withSpinner(`Revoking actor token ${options.actorTokenId}...`, () => + bapiRequest({ + method: "POST", + path: `/actor_tokens/${options.actorTokenId}/revoke`, + secretKey: ctx.secretKey, + }), + ), + `Failed to revoke actor token ${options.actorTokenId}`, + ); + + const body = response.body as { id?: string; status?: string }; + + if (isAgent()) { + log.data( + JSON.stringify({ id: body.id ?? options.actorTokenId, status: body.status ?? "revoked" }), + ); + return; + } + + log.success(`Revoked actor token ${options.actorTokenId}.`); } From e402e3f18321362dc347656e868a3ad61cfd0031 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:42:08 -0300 Subject: [PATCH 06/14] test(impersonate): cover completion for imp/impersonate/revoke and regenerate root README --- README.md | 37 ++++++++++--------- .../src/test/integration/completion.test.ts | 27 ++++++++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e372cef3..cd9c6082 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,23 @@ Options: -h, --help Display help for command Commands: - init [options] Initialize Clerk in your project - auth Manage authentication - link [options] Link this project to a Clerk application - unlink [options] Unlink this project from its Clerk application - whoami [options] Show the current logged-in user and linked application - open Open Clerk resources in your browser - apps Manage your Clerk applications - users [options] Manage Clerk users - env Manage environment variables - config Manage instance configuration - enable Enable Clerk features on the linked instance - disable Disable Clerk features on the linked instance - api [options] [endpoint] [filter] Make authenticated requests to the Clerk API - doctor [options] Check your project's Clerk integration health - completion [shell] Generate shell autocompletion script - update [options] Update the Clerk CLI to the latest version - deploy Deploy a Clerk application to production - help [command] Display help for command + init [options] Initialize Clerk in your project + auth Manage authentication + link [options] Link this project to a Clerk application + unlink [options] Unlink this project from its Clerk application + whoami [options] Show the current logged-in user and linked application + open Open Clerk resources in your browser + apps Manage your Clerk applications + users [options] Manage Clerk users + impersonate|imp [options] [user] Impersonate a Clerk user + env Manage environment variables + config Manage instance configuration + enable Enable Clerk features on the linked instance + disable Disable Clerk features on the linked instance + api [options] [endpoint] [filter] Make authenticated requests to the Clerk API + doctor [options] Check your project's Clerk integration health + completion [shell] Generate shell autocompletion script + update [options] Update the Clerk CLI to the latest version + deploy Deploy a Clerk application to production + help [command] Display help for command ``` diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index edc8c506..e1e10d2e 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -79,6 +79,33 @@ describe("generateCompletions", () => { }); }); + describe("impersonate completion", () => { + test("completes root subcommands including impersonate", () => { + const names = completionNames(""); + expect(names).toContain("impersonate"); + }); + + test("completes the imp alias as its own command with its subcommand and flags", () => { + const names = completionNames("imp", ""); + expect(names).toContain("revoke"); + expect(names).toContain("--secret-key"); + expect(names).toContain("--actor"); + expect(names).toContain("--expires-in"); + }); + + test("completes --instance hints under `clerk impersonate`", () => { + const names = completionNames("impersonate", "--instance", ""); + expect(names).toContain("dev"); + expect(names).toContain("prod"); + }); + + test("completes revoke subcommand options", () => { + const names = completionNames("impersonate", "revoke", ""); + expect(names).toContain("--app"); + expect(names).toContain("--instance"); + }); + }); + describe("alias completion", () => { test("completes command aliases", () => { const names = completionNames("auth", ""); From 5435e48ac2fff89eabfa6502ac8a8e33097d3ab0 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 15:42:30 -0300 Subject: [PATCH 07/14] docs(changeset): add impersonate command changeset --- .changeset/impersonate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/impersonate.md diff --git a/.changeset/impersonate.md b/.changeset/impersonate.md new file mode 100644 index 00000000..249d80a3 --- /dev/null +++ b/.changeset/impersonate.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk impersonate` (alias `clerk imp`) to create a short-lived sign-in URL that logs you in as another user, plus `clerk impersonate revoke` to cancel a pending token. Both require `clerk login` and stamp the impersonation with your account for auditing. From 913e655c670617003d6f6a221ba67dde5aec1338 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 1 Jul 2026 19:04:15 -0300 Subject: [PATCH 08/14] docs(impersonate): sync root README help transcript with clerk --help (add bird row) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cd9c6082..b6eb6d61 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,5 @@ Commands: update [options] Update the Clerk CLI to the latest version deploy Deploy a Clerk application to production help [command] Display help for command + bird Play Clerk Bird, a Flappy Bird game in your terminal ``` From f382c4bc5fd70cab9844b01328a3e3620df3295e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 3 Jul 2026 18:28:07 -0300 Subject: [PATCH 09/14] feat(impersonate): prompt for instance instead of silently defaulting to development Interactive flows now ask which instance to use when the resolved app has more than one and no --instance flag pins it. Zero-match user lookups name the searched app and instance, and instances targeted by raw ID are labeled by environment type so the production warning always fires. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc --- .changeset/interactive-instance-picker.md | 5 + .../src/commands/impersonate/README.md | 15 +- .../src/commands/impersonate/impersonate.ts | 2 +- .../commands/impersonate/resolve-user.test.ts | 41 ++++- .../src/commands/impersonate/resolve-user.ts | 20 ++- .../cli-core/src/commands/users/README.md | 16 +- .../interactive/instance-context.test.ts | 165 +++++++++++++++++- .../users/interactive/instance-context.ts | 22 +++ packages/cli-core/src/lib/config.test.ts | 25 ++- packages/cli-core/src/lib/config.ts | 4 +- .../test/integration/users-commands.test.ts | 8 +- 11 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 .changeset/interactive-instance-picker.md diff --git a/.changeset/interactive-instance-picker.md b/.changeset/interactive-instance-picker.md new file mode 100644 index 00000000..dac7340e --- /dev/null +++ b/.changeset/interactive-instance-picker.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Prompt for an instance instead of silently defaulting to development: interactive flows (`clerk impersonate`, `clerk impersonate revoke`, `clerk users open`, the create wizard, and the application-picker fallback) now ask in human mode whenever the resolved app has more than one instance and no `--instance` flag pins one. User lookups that find no match name the searched app and instance in the error, and instances targeted by raw ID are labeled by their environment type so the production impersonation warning always fires. diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md index e3e52939..300cf252 100644 --- a/packages/cli-core/src/commands/impersonate/README.md +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -52,7 +52,8 @@ Given `[user]`: Then: -- 0 matches → usage error. +- 0 matches → usage error naming the searched app and instance, e.g. + `No user found matching "alice@example.com" on My Application (development).` - 1 match → used directly. - 2+ matches, human mode → an interactive picker (the picker's search box always starts empty — there's no way to prefill it with your original @@ -62,6 +63,18 @@ Then: - No `[user]` argument, human mode → the interactive picker. - No `[user]` argument, agent mode → usage error (agent mode never prompts). +## Instance resolution + +The app comes from `--app`, the linked profile, or — in human mode with no +linked project — an interactive app picker. In human mode, whenever the +resolved app has more than one instance and no `--instance` flag was passed, +`clerk impersonate` and `clerk impersonate revoke` prompt +"Select an instance to use:" instead of silently defaulting to development — +even in a linked project. Users usually exist on only one instance (and actor +tokens are instance-scoped), so a silent development default makes lookups and +revokes fail confusingly. Agent mode never prompts and keeps the development +default; `--instance` or `--secret-key` always pins the instance. + ## Confirmation and the production guardrail In human mode, unless `--yes` is passed, `clerk impersonate` asks diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index 1bf870bc..c29a9144 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -81,7 +81,7 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise { }); test("uses a user_xxx argument directly without calling BAPI", async () => { - const result = await resolveImpersonationTarget("user_2x9k", "sk_test_123"); + const result = await resolveImpersonationTarget("user_2x9k", { secretKey: "sk_test_123" }); expect(result).toBe("user_2x9k"); expect(mockBapiRequest).not.toHaveBeenCalled(); expect(mockPickUser).not.toHaveBeenCalled(); @@ -37,7 +37,9 @@ describe("resolveImpersonationTarget", () => { rawBody: "", }); - const result = await resolveImpersonationTarget("alice@example.com", "sk_test_123"); + const result = await resolveImpersonationTarget("alice@example.com", { + secretKey: "sk_test_123", + }); expect(result).toBe("user_alice"); expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ @@ -55,7 +57,7 @@ describe("resolveImpersonationTarget", () => { rawBody: "", }); - await resolveImpersonationTarget("bob", "sk_test_123"); + await resolveImpersonationTarget("bob", { secretKey: "sk_test_123" }); expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ path: "/users?query=bob&limit=6", @@ -70,8 +72,27 @@ describe("resolveImpersonationTarget", () => { rawBody: "", }); - await expect(resolveImpersonationTarget("nobody@example.com", "sk_test_123")).rejects.toThrow( - /no user found matching "nobody@example.com"/i, + await expect( + resolveImpersonationTarget("nobody@example.com", { secretKey: "sk_test_123" }), + ).rejects.toThrow(/no user found matching "nobody@example.com"/i); + }); + + test("names the searched app and instance in the zero-match error when known", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [], + rawBody: "", + }); + + await expect( + resolveImpersonationTarget("nobody@example.com", { + secretKey: "sk_test_123", + appLabel: "My Application", + instanceLabel: "development", + }), + ).rejects.toThrow( + /no user found matching "nobody@example.com" on My Application \(development\)/i, ); }); @@ -84,7 +105,7 @@ describe("resolveImpersonationTarget", () => { }); mockPickUser.mockResolvedValue("user_a"); - const result = await resolveImpersonationTarget("smith", "sk_test_123"); + const result = await resolveImpersonationTarget("smith", { secretKey: "sk_test_123" }); expect(result).toBe("user_a"); expect(mockPickUser).toHaveBeenCalledWith( @@ -101,7 +122,7 @@ describe("resolveImpersonationTarget", () => { rawBody: "", }); - await expect(resolveImpersonationTarget("smith", "sk_test_123")).rejects.toThrow( + await expect(resolveImpersonationTarget("smith", { secretKey: "sk_test_123" })).rejects.toThrow( /user_a, user_b/, ); expect(mockPickUser).not.toHaveBeenCalled(); @@ -110,7 +131,7 @@ describe("resolveImpersonationTarget", () => { test("human mode: no argument opens the picker", async () => { mockPickUser.mockResolvedValue("user_picked"); - const result = await resolveImpersonationTarget(undefined, "sk_test_123"); + const result = await resolveImpersonationTarget(undefined, { secretKey: "sk_test_123" }); expect(result).toBe("user_picked"); expect(mockBapiRequest).not.toHaveBeenCalled(); @@ -119,7 +140,9 @@ describe("resolveImpersonationTarget", () => { test("agent mode: no argument throws a usage error instead of prompting", async () => { setMode("agent"); - await expect(resolveImpersonationTarget(undefined, "sk_test_123")).rejects.toThrow(CliError); + await expect( + resolveImpersonationTarget(undefined, { secretKey: "sk_test_123" }), + ).rejects.toThrow(CliError); expect(mockPickUser).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli-core/src/commands/impersonate/resolve-user.ts b/packages/cli-core/src/commands/impersonate/resolve-user.ts index 01442e5f..fbf0f289 100644 --- a/packages/cli-core/src/commands/impersonate/resolve-user.ts +++ b/packages/cli-core/src/commands/impersonate/resolve-user.ts @@ -10,6 +10,18 @@ type ImpersonationUserCandidate = { id: string; }; +export type ImpersonationSearchContext = { + secretKey: string; + appLabel?: string; + instanceLabel?: string; +}; + +function searchScope(ctx: ImpersonationSearchContext): string { + if (ctx.appLabel && ctx.instanceLabel) return ` on ${ctx.appLabel} (${ctx.instanceLabel})`; + if (ctx.instanceLabel) return ` on the ${ctx.instanceLabel} instance`; + return ""; +} + /** * Resolve the `[user]` positional argument (or its absence) to a single * `user_...` ID: @@ -17,7 +29,8 @@ type ImpersonationUserCandidate = { * - `user_...` → used directly, no lookup. * - contains `@` → exact match via `email_address` filter. * - otherwise → fuzzy match via `query`. - * - 0 matches → usage error. 1 match → used directly. + * - 0 matches → usage error naming the searched app/instance. 1 match → used + * directly. * - 2+ matches: human mode opens the picker (no prefilled query support — * see pick-user.ts); agent mode errors with candidate IDs. * - no argument: human mode opens the picker; agent mode errors (agent mode @@ -25,8 +38,9 @@ type ImpersonationUserCandidate = { */ export async function resolveImpersonationTarget( user: string | undefined, - secretKey: string, + ctx: ImpersonationSearchContext, ): Promise { + const secretKey = ctx.secretKey; if (user === undefined) { if (isAgent()) { throwUsageError("A user is required in agent mode. Pass it as a positional argument."); @@ -55,7 +69,7 @@ export async function resolveImpersonationTarget( const users = Array.isArray(response.body) ? (response.body as ImpersonationUserCandidate[]) : []; if (users.length === 0) { - throwUsageError(`No user found matching "${user}".`); + throwUsageError(`No user found matching "${user}"${searchScope(ctx)}.`); } if (users.length === 1) { diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 4b6ee4ee..c6ee420b 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -6,13 +6,13 @@ Manage direct Clerk user resources with first-class commands. Use `clerk api` fo Most `clerk users` commands accept the same targeting flags: -| Flag | Description | -| ------------------ | --------------------------------------------------------------------------------- | -| `--secret-key ` | Use a specific Backend API secret key directly | -| `--app ` | Target an application directly, even outside a linked project | -| `--instance ` | Target `dev`, `prod`, or a full instance ID. Defaults to the development instance | -| `--dry-run` | Preview the request without executing it, where supported | -| `--yes` | Skip confirmation prompts for mutating commands | +| Flag | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--secret-key ` | Use a specific Backend API secret key directly | +| `--app ` | Target an application directly, even outside a linked project | +| `--instance ` | Target `dev`, `prod`, or a full instance ID. Without it, single-instance apps use development; interactive flows that hit a multi-instance app prompt for one in human mode | +| `--dry-run` | Preview the request without executing it, where supported | +| `--yes` | Skip confirmation prompts for mutating commands | Authentication is resolved in this order: @@ -42,7 +42,7 @@ Two complementary mechanisms for JSON input work across the users command family ### `clerk users list` -List users from the target instance. In human mode without a linked project, an env var, or a targeting flag, the command opens the same application picker as `clerk users create` so you can choose an instance interactively. +List users from the target instance. In human mode without a linked project, an env var, or a targeting flag, the command opens the same application picker as `clerk users create`; if the picked application has more than one instance, an instance picker follows (no silent development default). ```sh clerk users list diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.test.ts b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts index 0f61dd4d..c5b58fd9 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.test.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts @@ -7,8 +7,13 @@ const mockResolveProfile = mock(); const mockFetchApplication = mock(); const mockFetchAppsTolerantly = mock(); const mockPickOrCreateApp = mock(); +const mockSelect = mock(); const mockIsHuman = mock(() => true); +mock.module("../../../lib/listage.ts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), +})); + mock.module("../../../lib/config.ts", () => ({ resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), @@ -22,15 +27,21 @@ mock.module("../../../lib/config.ts", () => ({ publishable_key: string; }[]; }, - _instance?: string, + instance?: string, ) => { - const development = app.instances.find((i) => i.environment_type === "development"); - if (!development) return { found: false, instanceId: "unknown", instanceLabel: "unknown" }; + // Mirrors the real resolver: an env-type or instance-id hint wins, + // otherwise fall back to the development instance. Labels use the + // matched instance's environment type. + const hint = instance ?? "development"; + const matched = app.instances.find( + (i) => i.environment_type === hint || i.instance_id === hint, + ); + if (!matched) return { found: false, instanceId: hint, instanceLabel: hint }; return { found: true, - instance: development, - instanceId: development.instance_id, - instanceLabel: "development", + instance: matched, + instanceId: matched.instance_id, + instanceLabel: matched.environment_type, }; }, })); @@ -61,6 +72,7 @@ describe("resolveUsersInstanceContext", () => { mockFetchApplication.mockReset(); mockFetchAppsTolerantly.mockReset(); mockPickOrCreateApp.mockReset(); + mockSelect.mockReset(); mockIsHuman.mockReturnValue(true); mockResolveProfile.mockResolvedValue(undefined); }); @@ -121,6 +133,7 @@ describe("resolveUsersInstanceContext", () => { const ctx = await resolveUsersInstanceContext({}); expect(mockPickOrCreateApp).toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); expect(ctx.secretKey).toBe("sk_test_picked"); expect(ctx.appId).toBe("app_picked"); expect(ctx.appLabel).toBe("Picked"); @@ -129,6 +142,146 @@ describe("resolveUsersInstanceContext", () => { expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); + test("prompts for an instance when the picked app has multiple instances", async () => { + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + mockFetchAppsTolerantly.mockResolvedValue([{ application_id: "app_picked", name: "Picked" }]); + mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_picked", + name: "Picked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_picked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_picked", + }, + ], + }); + mockSelect.mockResolvedValue("ins_prod"); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(mockSelect.mock.calls[0]?.[0]).toMatchObject({ + choices: [ + { name: "ins_dev (development)", value: "ins_dev" }, + { name: "ins_prod (production)", value: "ins_prod" }, + ], + }); + expect(ctx.secretKey).toBe("sk_live_picked"); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + }); + + test("does not prompt for an instance when --instance is passed alongside the app picker", async () => { + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + mockFetchAppsTolerantly.mockResolvedValue([{ application_id: "app_picked", name: "Picked" }]); + mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_picked", + name: "Picked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_picked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_picked", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({ instance: "production" }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + }); + + test("prompts even when the app comes from a linked profile", async () => { + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_linked", + }, + ], + }); + mockSelect.mockResolvedValue("ins_prod"); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + expect(ctx.secretKey).toBe("sk_live_linked"); + }); + + test("does not prompt in agent mode", async () => { + mockIsHuman.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_linked", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(ctx.instanceId).toBe("ins_dev"); + }); + test("re-throws NOT_LINKED in agent mode without invoking picker", async () => { mockIsHuman.mockReturnValue(false); mockResolveAppContext.mockRejectedValue( diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index f57619c0..8fb5d7fc 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -14,6 +14,7 @@ import { import { getBapiBaseUrl } from "../../../lib/environment.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; +import { select } from "../../../lib/listage.ts"; import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; import { isHuman } from "../../../mode.ts"; @@ -139,6 +140,7 @@ export async function resolveUsersInstanceContext( let appId: string | undefined = options.app; let instanceHint: string | undefined = options.instance; + let appPickedInteractively = false; if (!appId) { try { @@ -156,6 +158,7 @@ export async function resolveUsersInstanceContext( message: "Select a Clerk application to use:", }); appId = picked.application_id; + appPickedInteractively = true; } else { throw error; } @@ -163,6 +166,25 @@ export async function resolveUsersInstanceContext( } const app = await withApiContext(fetchApplication(appId), "Failed to resolve instance context"); + + // Never guess silently between dev and prod: in human mode, any app with + // more than one instance prompts unless --instance pinned it. Users usually + // exist on only one instance, so a silent development default makes lookups + // fail confusingly. Agent mode never prompts and keeps the dev default. + const shouldPromptForInstance = appPickedInteractively + ? !instanceHint + : !options.instance && isHuman(); + + if (shouldPromptForInstance && app.instances.length > 1) { + instanceHint = await select({ + message: "Select an instance to use:", + choices: app.instances.map((entry) => ({ + name: `${entry.instance_id} (${entry.environment_type})`, + value: entry.instance_id, + })), + }); + } + const resolved = resolveFetchedApplicationInstance(appId, app, instanceHint); if (!resolved.found) { throw new CliError(`Instance ${resolved.instanceId} not found in application.`, { diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..c8deae64 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -248,13 +248,34 @@ describe("config", () => { expect(result).toMatchObject({ found: true, instanceId: "ins_custom_123", - instanceLabel: "ins_custom_123", + instanceLabel: "staging", }); if (result.found) { expect(result.instance.instance_id).toBe("ins_custom_123"); } }); + test("labels a production instance targeted by literal id as production", () => { + const prodApp = { + application_id: "app_123", + instances: [ + { + instance_id: "ins_prod_123", + environment_type: "production", + publishable_key: "pk_live_123", + }, + ], + }; + + const result = resolveFetchedApplicationInstance("app_123", prodApp, "ins_prod_123"); + + expect(result).toMatchObject({ + found: true, + instanceId: "ins_prod_123", + instanceLabel: "production", + }); + }); + test("returns explicit missing state for unknown literal instance ids", () => { const result = resolveFetchedApplicationInstance("app_123", app, "ins_missing_123"); @@ -286,7 +307,7 @@ describe("config", () => { appId: "app_123", appLabel: "My App", instanceId: "ins_custom_123", - instanceLabel: "ins_custom_123", + instanceLabel: "staging", }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..830a7a92 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -286,7 +286,9 @@ export function resolveFetchedApplicationInstance( found: true, instance: matched, instanceId: matched.instance_id, - instanceLabel: instance, + // Label by environment type, not the raw id — downstream guardrails + // (e.g. the production impersonation warning) key off this label. + instanceLabel: matched.environment_type || instance, }; } diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index 4dee21c9..398bed40 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -227,10 +227,12 @@ describe("users commands", () => { "/v1/users": CREATED_USER, }); - // Picker returns app_1; wizard then prompts for the optional curated set - // because MOCK_APP's publishable key does not decode to a valid fapiHost - // (the wizard skips the FAPI fetch and falls back to optional curated fields). + // Picker returns app_1; MOCK_APP has two instances so an instance picker + // follows; the wizard then prompts for the optional curated set because + // MOCK_APP's publishable key does not decode to a valid fapiHost (the + // wizard skips the FAPI fetch and falls back to optional curated fields). mockPrompts.search("app_1"); + mockPrompts.select("development"); mockPrompts.input("alice@example.com"); // email mockPrompts.input(""); // phone mockPrompts.input(""); // username From 087909f934626c9f90457a7a33957eea0bb3f825 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 3 Jul 2026 18:30:46 -0300 Subject: [PATCH 10/14] refactor(users): collapse instance-prompt condition to a single guard The picker branch already guarantees human mode and an untouched instance hint, so both ternary arms were equivalent; drop the dead appPickedInteractively flag. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc --- .../src/commands/users/interactive/instance-context.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index 8fb5d7fc..c9488cb3 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -140,7 +140,6 @@ export async function resolveUsersInstanceContext( let appId: string | undefined = options.app; let instanceHint: string | undefined = options.instance; - let appPickedInteractively = false; if (!appId) { try { @@ -158,7 +157,6 @@ export async function resolveUsersInstanceContext( message: "Select a Clerk application to use:", }); appId = picked.application_id; - appPickedInteractively = true; } else { throw error; } @@ -171,11 +169,7 @@ export async function resolveUsersInstanceContext( // more than one instance prompts unless --instance pinned it. Users usually // exist on only one instance, so a silent development default makes lookups // fail confusingly. Agent mode never prompts and keeps the dev default. - const shouldPromptForInstance = appPickedInteractively - ? !instanceHint - : !options.instance && isHuman(); - - if (shouldPromptForInstance && app.instances.length > 1) { + if (!options.instance && isHuman() && app.instances.length > 1) { instanceHint = await select({ message: "Select an instance to use:", choices: app.instances.map((entry) => ({ From 0fd7642c393be746bd8c0a50a7172262a6598a1e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 7 Jul 2026 09:12:19 -0300 Subject: [PATCH 11/14] test(impersonate): cover sk_live_ production warning without instance label --- .../src/commands/impersonate/impersonate.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli-core/src/commands/impersonate/impersonate.test.ts b/packages/cli-core/src/commands/impersonate/impersonate.test.ts index c3a6f2b6..dc389411 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.test.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.test.ts @@ -149,6 +149,15 @@ describe("impersonate", () => { expect(captured.err).toContain("production — signs you in as this user and bypasses their MFA"); }); + test("sk_live_ secret key without an instance label still prints the guardrail warning", async () => { + const { instanceLabel: _drop, ...rest } = CTX; + mockResolveUsersInstanceContext.mockResolvedValue({ ...rest, secretKey: "sk_live_123" }); + + await impersonate({ user: "user_2x9k", print: true }); + + expect(captured.err).toContain("production — signs you in as this user and bypasses their MFA"); + }); + test("--expires-in overrides the default 3600s lifetime", async () => { await impersonate({ user: "user_2x9k", expiresIn: 900, print: true, yes: true }); From 7ac180545b59505b7e7b7a758ac70f9a25bcbd1b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 7 Jul 2026 18:20:36 -0300 Subject: [PATCH 12/14] feat(impersonate): print a revoke hint after creating a token BAPI has no list endpoint for actor tokens, so the creation output is the only place a human can learn the act_... ID needed to revoke. Print "Revoke with: clerk imp revoke " to stderr, keeping stdout pipe-clean. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc --- packages/cli-core/src/commands/impersonate/README.md | 4 +++- .../cli-core/src/commands/impersonate/impersonate.test.ts | 1 + packages/cli-core/src/commands/impersonate/impersonate.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md index 300cf252..aa20c546 100644 --- a/packages/cli-core/src/commands/impersonate/README.md +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -92,7 +92,9 @@ never prompts and proceeds directly. ## Output modes The sign-in URL from BAPI's response is always printed **verbatim** via -`log.data()` — never reconstructed client-side. +`log.data()` — never reconstructed client-side. Human mode also prints a +`Revoke with: clerk imp revoke ` hint to stderr — BAPI has no list +endpoint for actor tokens, so creation is the only moment the ID is visible. | Condition | Behavior | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/cli-core/src/commands/impersonate/impersonate.test.ts b/packages/cli-core/src/commands/impersonate/impersonate.test.ts index dc389411..e2faf8d5 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.test.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.test.ts @@ -126,6 +126,7 @@ describe("impersonate", () => { }), }); expect(captured.out).toBe(SIGN_IN_URL); + expect(captured.err).toContain("Revoke with: clerk imp revoke act_1"); expect(mockOpenBrowser).toHaveBeenCalledWith(SIGN_IN_URL); }); diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index c29a9144..d5e4fa40 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -150,6 +150,9 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise Date: Tue, 7 Jul 2026 18:55:05 -0300 Subject: [PATCH 13/14] refactor(impersonate): extract typed BAPI actor-token and user-search helpers Address review feedback by moving library-esque BAPI calls out of the commands into named, typed helpers: - lib/actor-tokens.ts: createActorToken/revokeActorToken own the actor-token request/response contract; impersonate and revoke consume them. - lib/users.ts: searchUsers centralizes GET /users; resolve-user and pick-user drop their hand-rolled query/limit/body-guard duplication. - lib/errors.ts: add BillingError for standardized plan-gating (402) and quota (422) failures, carrying a reason plus limit/used. Behavior is unchanged; existing command tests plus new lib unit tests cover it. --- .../src/commands/impersonate/impersonate.ts | 45 ++++++------- .../src/commands/impersonate/resolve-user.ts | 23 +------ .../src/commands/impersonate/revoke.ts | 12 +--- .../commands/users/interactive/pick-user.ts | 27 ++------ .../cli-core/src/lib/actor-tokens.test.ts | 55 ++++++++++++++++ packages/cli-core/src/lib/actor-tokens.ts | 66 +++++++++++++++++++ packages/cli-core/src/lib/errors.test.ts | 40 ++++++++++- packages/cli-core/src/lib/errors.ts | 56 ++++++++++++++++ packages/cli-core/src/lib/users.ts | 45 +++++++++++++ 9 files changed, 295 insertions(+), 74 deletions(-) create mode 100644 packages/cli-core/src/lib/actor-tokens.test.ts create mode 100644 packages/cli-core/src/lib/actor-tokens.ts diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index d5e4fa40..be68faeb 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -1,7 +1,9 @@ import { bold, cyan, dim } from "../../lib/color.ts"; -import { bapiRequest } from "../../lib/bapi.ts"; +import { createActorToken } from "../../lib/actor-tokens.ts"; import { BapiError, + BILLING_ERROR_REASON, + BillingError, CliError, ERROR_CODE, throwUserAbort, @@ -51,7 +53,8 @@ function actorTokenErrorToCliError(error: unknown): CliError | undefined { if (!(error instanceof BapiError)) return undefined; if (error.status === 402) { - return new CliError("Impersonation isn't enabled on this app's plan.", { + return new BillingError("Impersonation isn't enabled on this app's plan.", { + reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, }); } @@ -64,8 +67,11 @@ function actorTokenErrorToCliError(error: unknown): CliError | undefined { limit !== undefined && used !== undefined ? ` (used ${used}/${limit} this billing period)` : ""; - return new CliError(`Impersonation limit exceeded${quota}.`, { + return new BillingError(`Impersonation limit exceeded${quota}.`, { + reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + limit, + used, }); } @@ -103,21 +109,14 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise - bapiRequest({ - method: "POST", - path: "/actor_tokens", - secretKey: ctx.secretKey, - body: JSON.stringify(payload), + createActorToken(ctx.secretKey, { + userId, + actor: { sub: actor.sub, iss: actor.iss }, + expiresInSeconds: expiresIn, }), ), "Failed to create impersonation session", @@ -128,13 +127,11 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise { instance: options.instance, }); - const response = await withApiContext( + const body = await withApiContext( withSpinner(`Revoking actor token ${options.actorTokenId}...`, () => - bapiRequest({ - method: "POST", - path: `/actor_tokens/${options.actorTokenId}/revoke`, - secretKey: ctx.secretKey, - }), + revokeActorToken(ctx.secretKey, options.actorTokenId), ), `Failed to revoke actor token ${options.actorTokenId}`, ); - const body = response.body as { id?: string; status?: string }; - if (isAgent()) { log.data( JSON.stringify({ id: body.id ?? options.actorTokenId, status: body.status ?? "revoked" }), diff --git a/packages/cli-core/src/commands/users/interactive/pick-user.ts b/packages/cli-core/src/commands/users/interactive/pick-user.ts index 6aeedfe1..cb3f10d1 100644 --- a/packages/cli-core/src/commands/users/interactive/pick-user.ts +++ b/packages/cli-core/src/commands/users/interactive/pick-user.ts @@ -1,5 +1,5 @@ import { search, Separator } from "../../../lib/listage.ts"; -import { bapiRequest } from "../../../lib/bapi.ts"; +import { type BapiUserSummary, searchUsers } from "../../../lib/users.ts"; export type PickUserOptions = { secretKey: string; @@ -8,15 +8,7 @@ export type PickUserOptions = { const PICKER_LIMIT = 20; -type UserSummary = { - id: string; - first_name?: string | null; - last_name?: string | null; - username?: string | null; - email_addresses?: Array<{ email_address?: string }> | null; -}; - -export function formatUserChoice(user: UserSummary): string { +export function formatUserChoice(user: BapiUserSummary): string { const email = user.email_addresses?.[0]?.email_address ?? "no email"; const nameParts = [user.first_name, user.last_name].filter( (part): part is string => typeof part === "string" && part.length > 0, @@ -33,16 +25,11 @@ export async function pickUser(options: PickUserOptions): Promise { message: options.message ?? "Pick a user:", source: async (term) => { // Request one extra so we can flag overflow with a refine-search hint. - const params = new URLSearchParams(); - if (term) params.set("query", term); - params.set("limit", String(PICKER_LIMIT + 1)); - const response = await bapiRequest({ - method: "GET", - path: `/users?${params}`, - secretKey: options.secretKey, - }); - const body = response.body; - const allUsers = Array.isArray(body) ? (body as UserSummary[]) : []; + const allUsers = await searchUsers( + options.secretKey, + { query: term ?? "" }, + PICKER_LIMIT + 1, + ); const hasMore = allUsers.length > PICKER_LIMIT; const users = hasMore ? allUsers.slice(0, PICKER_LIMIT) : allUsers; diff --git a/packages/cli-core/src/lib/actor-tokens.test.ts b/packages/cli-core/src/lib/actor-tokens.test.ts new file mode 100644 index 00000000..4e487fd5 --- /dev/null +++ b/packages/cli-core/src/lib/actor-tokens.test.ts @@ -0,0 +1,55 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockBapiRequest = mock(); +mock.module("./bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const { createActorToken, revokeActorToken } = await import("./actor-tokens.ts"); + +describe("createActorToken", () => { + beforeEach(() => { + mockBapiRequest.mockReset(); + }); + + test("POSTs the snake_case actor-token contract and returns the typed token", async () => { + mockBapiRequest.mockResolvedValue({ body: { id: "act_1", url: "https://example.com/ticket" } }); + + const token = await createActorToken("sk_test_123", { + userId: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expiresInSeconds: 900, + }); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens", + secretKey: "sk_test_123", + body: JSON.stringify({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 900, + }), + }); + expect(token).toEqual({ id: "act_1", url: "https://example.com/ticket" }); + }); +}); + +describe("revokeActorToken", () => { + beforeEach(() => { + mockBapiRequest.mockReset(); + }); + + test("POSTs to the revoke path and returns the parsed body", async () => { + mockBapiRequest.mockResolvedValue({ body: { id: "act_1", status: "revoked" } }); + + const result = await revokeActorToken("sk_test_123", "act_1"); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens/act_1/revoke", + secretKey: "sk_test_123", + }); + expect(result).toEqual({ id: "act_1", status: "revoked" }); + }); +}); diff --git a/packages/cli-core/src/lib/actor-tokens.ts b/packages/cli-core/src/lib/actor-tokens.ts new file mode 100644 index 00000000..92e5d687 --- /dev/null +++ b/packages/cli-core/src/lib/actor-tokens.ts @@ -0,0 +1,66 @@ +/** + * Backend API (BAPI) actor-token client. + * + * Actor tokens back the `clerk impersonate` flow: creating one returns a + * sign-in URL that logs the caller in as another user, stamped with the + * `actor` who initiated it. This module owns the BAPI request/response + * contract (the snake_case wire shape) so commands work with named types + * instead of hand-built object literals. + */ + +import { bapiRequest } from "./bapi.ts"; + +/** The initiator stamped onto an actor token, echoed into the session's JWT. */ +export type ActorTokenActor = { + sub: string; + iss: string; +}; + +export type CreateActorTokenRequest = { + userId: string; + actor: ActorTokenActor; + expiresInSeconds: number; +}; + +/** A freshly created actor token: `url` signs the caller in as the target user. */ +export type ActorToken = { + id: string; + url: string; +}; + +/** Result of revoking an actor token. Fields are optional — BAPI may echo them. */ +export type RevokedActorToken = { + id?: string; + status?: string; +}; + +export async function createActorToken( + secretKey: string, + request: CreateActorTokenRequest, +): Promise { + const response = await bapiRequest({ + method: "POST", + path: "/actor_tokens", + secretKey, + body: JSON.stringify({ + user_id: request.userId, + actor: { sub: request.actor.sub, iss: request.actor.iss }, + expires_in_seconds: request.expiresInSeconds, + }), + }); + + return response.body as ActorToken; +} + +export async function revokeActorToken( + secretKey: string, + actorTokenId: string, +): Promise { + const response = await bapiRequest({ + method: "POST", + path: `/actor_tokens/${actorTokenId}/revoke`, + secretKey, + }); + + return response.body as RevokedActorToken; +} diff --git a/packages/cli-core/src/lib/errors.test.ts b/packages/cli-core/src/lib/errors.test.ts index ee27d014..577331c3 100644 --- a/packages/cli-core/src/lib/errors.test.ts +++ b/packages/cli-core/src/lib/errors.test.ts @@ -1,5 +1,13 @@ import { test, expect, describe } from "bun:test"; -import { PlapiError, FapiError, BapiError } from "./errors.ts"; +import { + PlapiError, + FapiError, + BapiError, + BillingError, + BILLING_ERROR_REASON, + CliError, + ERROR_CODE, +} from "./errors.ts"; describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { test("parses a standard single-error envelope", () => { @@ -151,3 +159,33 @@ describe("BapiError factories", () => { expect(err.headers.get("x-y")).toBe("z"); }); }); + +describe("BillingError", () => { + test("is a CliError carrying the plan-not-enabled reason and caller-supplied code", () => { + const err = new BillingError("Impersonation isn't enabled on this app's plan.", { + reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, + code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + }); + expect(err).toBeInstanceOf(CliError); + expect(err.name).toBe("BillingError"); + expect(err.reason).toBe(BILLING_ERROR_REASON.PLAN_NOT_ENABLED); + expect(err.code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED); + expect(err.limit).toBeUndefined(); + expect(err.used).toBeUndefined(); + }); + + test("carries quota figures for the quota-exceeded reason", () => { + const err = new BillingError( + "Impersonation limit exceeded (used 100/100 this billing period).", + { + reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, + code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + limit: 100, + used: 100, + }, + ); + expect(err.reason).toBe(BILLING_ERROR_REASON.QUOTA_EXCEEDED); + expect(err.limit).toBe(100); + expect(err.used).toBe(100); + }); +}); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 579ad19c..2ead49f2 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -69,6 +69,21 @@ export const AUTH_ERROR_REASON = { export type AuthErrorReason = (typeof AUTH_ERROR_REASON)[keyof typeof AUTH_ERROR_REASON]; +/** + * Why a billing/usage guardrail rejected a request. + * + * - `plan_not_enabled` — the feature isn't included in the app's subscription + * plan (typically surfaced as HTTP 402). + * - `quota_exceeded` — the usage quota for the current billing period is + * exhausted (typically surfaced as HTTP 422). + */ +export const BILLING_ERROR_REASON = { + PLAN_NOT_ENABLED: "plan_not_enabled", + QUOTA_EXCEEDED: "quota_exceeded", +} as const; + +export type BillingErrorReason = (typeof BILLING_ERROR_REASON)[keyof typeof BILLING_ERROR_REASON]; + interface CliErrorOptions { /** Machine-readable error code for programmatic consumption. */ code?: ErrorCode; @@ -83,6 +98,14 @@ interface AuthErrorOptions extends Omit { reason: AuthErrorReason; } +interface BillingErrorOptions extends CliErrorOptions { + reason: BillingErrorReason; + /** Quota ceiling for the current billing period, when the API reports it. */ + limit?: number; + /** Amount of the quota already consumed, when the API reports it. */ + used?: number; +} + /** * General-purpose CLI error for user-facing messages. * @@ -150,6 +173,39 @@ export class AuthError extends CliError { } } +/** + * A billing or usage guardrail rejected the request. + * + * Throw this for plan-gating (feature not on the plan) and quota-exhaustion + * failures so every command surfaces them the same way. The `reason` + * discriminates the two cases for programmatic consumers, and `limit`/`used` + * carry quota figures when the API reports them. Callers still supply a + * feature-specific `code` (e.g. {@link ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED}) + * and a human-readable message. + * + * @example + * ```ts + * throw new BillingError("Impersonation isn't enabled on this app's plan.", { + * reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, + * code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + * }); + * ``` + */ +export class BillingError extends CliError { + public reason: BillingErrorReason; + public limit?: number; + public used?: number; + + constructor(message: string, options: BillingErrorOptions) { + const { reason, limit, used, ...rest } = options; + super(message, rest); + this.name = "BillingError"; + this.reason = reason; + this.limit = limit; + this.used = used; + } +} + /** * Signals that the user cancelled an interactive prompt or confirmation. * diff --git a/packages/cli-core/src/lib/users.ts b/packages/cli-core/src/lib/users.ts index 6a4d4641..5cb5020d 100644 --- a/packages/cli-core/src/lib/users.ts +++ b/packages/cli-core/src/lib/users.ts @@ -1,3 +1,4 @@ +import { bapiRequest } from "./bapi.ts"; import { ERROR_CODE, throwUsageError } from "./errors.ts"; const USERS_INVALID_JSON_MESSAGE = "User payload must be a JSON object."; @@ -5,6 +6,50 @@ const REDACTED = "[REDACTED]"; const DIRECT_REDACT_KEYS = new Set(["password", "code"]); const OBJECT_REDACT_KEYS = new Set(["private_metadata", "unsafe_metadata"]); +/** A user record as returned by the BAPI `GET /users` search endpoint. */ +export type BapiUserSummary = { + id: string; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + email_addresses?: Array<{ email_address?: string }> | null; +}; + +/** + * How to filter the user search: an exact email match or a fuzzy query. An + * empty `query` returns the unfiltered first page (used by the interactive + * picker before the user types). + */ +export type UserSearchFilter = { email: string } | { query: string }; + +/** + * Search users via BAPI `GET /users`, returning at most `limit` records. + * + * Centralizes the `/users` request so commands don't each hand-roll the query + * string, limit handling, and `Array.isArray` body guard. + */ +export async function searchUsers( + secretKey: string, + filter: UserSearchFilter, + limit: number, +): Promise { + const params = new URLSearchParams(); + if ("email" in filter) { + params.set("email_address", filter.email); + } else if (filter.query) { + params.set("query", filter.query); + } + params.set("limit", String(limit)); + + const response = await bapiRequest({ + method: "GET", + path: `/users?${params}`, + secretKey, + }); + + return Array.isArray(response.body) ? (response.body as BapiUserSummary[]) : []; +} + export function buildCreateUserPayload(options: { email?: string; phone?: string; From c247b9093b278f626341aaa03b34ece0858ad9d2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 7 Jul 2026 19:14:11 -0300 Subject: [PATCH 14/14] fix(impersonate): resolve instance explicitly instead of defaulting in agent mode wyattjoh flagged that defaulting to development is data-dependent: the same command resolves a different instance once the app gains a production instance, breaking repeatability. Now, when an app has multiple instances and --instance isn't pinned, human mode prompts and agent mode errors asking for an explicit --instance rather than silently picking development. Also pin --app/--instance in the revoke hint so it can't target a different instance than the token was created on. --- .changeset/interactive-instance-picker.md | 2 +- .../src/commands/impersonate/README.md | 7 ++-- .../src/commands/impersonate/impersonate.ts | 9 ++++-- .../interactive/instance-context.test.ts | 27 +++++++++++++++- .../users/interactive/instance-context.ts | 32 ++++++++++++------- packages/cli-core/src/lib/users.ts | 3 -- 6 files changed, 59 insertions(+), 21 deletions(-) diff --git a/.changeset/interactive-instance-picker.md b/.changeset/interactive-instance-picker.md index dac7340e..9ed07507 100644 --- a/.changeset/interactive-instance-picker.md +++ b/.changeset/interactive-instance-picker.md @@ -2,4 +2,4 @@ "clerk": patch --- -Prompt for an instance instead of silently defaulting to development: interactive flows (`clerk impersonate`, `clerk impersonate revoke`, `clerk users open`, the create wizard, and the application-picker fallback) now ask in human mode whenever the resolved app has more than one instance and no `--instance` flag pins one. User lookups that find no match name the searched app and instance in the error, and instances targeted by raw ID are labeled by their environment type so the production impersonation warning always fires. +Resolve the instance explicitly instead of silently defaulting to development: interactive flows (`clerk impersonate`, `clerk impersonate revoke`, `clerk users open`, the create wizard, and the application-picker fallback) now ask in human mode whenever the resolved app has more than one instance and no `--instance` flag pins one, and agent mode errors asking for an explicit `--instance` rather than defaulting — so the same command can't resolve a different instance depending on which instances the app happens to have. User lookups that find no match name the searched app and instance in the error, and instances targeted by raw ID are labeled by their environment type so the production impersonation warning always fires. diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md index aa20c546..eff89373 100644 --- a/packages/cli-core/src/commands/impersonate/README.md +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -72,8 +72,11 @@ resolved app has more than one instance and no `--instance` flag was passed, "Select an instance to use:" instead of silently defaulting to development — even in a linked project. Users usually exist on only one instance (and actor tokens are instance-scoped), so a silent development default makes lookups and -revokes fail confusingly. Agent mode never prompts and keeps the development -default; `--instance` or `--secret-key` always pins the instance. +revokes fail confusingly. Agent mode never prompts: when the app has more than +one instance it errors and asks for an explicit `--instance` rather than +defaulting, so the same command can't resolve a different instance depending on +which instances the app happens to have. `--instance` or `--secret-key` always +pins the instance. ## Confirmation and the production guardrail diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index be68faeb..100e6a99 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -148,8 +148,13 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise { expect(ctx.secretKey).toBe("sk_live_linked"); }); - test("does not prompt in agent mode", async () => { + test("errors in agent mode when the app has multiple instances and no --instance", async () => { mockIsHuman.mockReturnValue(false); mockResolveAppContext.mockResolvedValue({ appId: "app_linked", @@ -276,6 +276,31 @@ describe("resolveUsersInstanceContext", () => { ], }); + await expect(resolveUsersInstanceContext({})).rejects.toThrow(/multiple instances/); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + test("agent mode resolves without prompting when the app has a single instance", async () => { + mockIsHuman.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + ], + }); + const ctx = await resolveUsersInstanceContext({}); expect(mockSelect).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index c9488cb3..4f6db1c6 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -165,18 +165,26 @@ export async function resolveUsersInstanceContext( const app = await withApiContext(fetchApplication(appId), "Failed to resolve instance context"); - // Never guess silently between dev and prod: in human mode, any app with - // more than one instance prompts unless --instance pinned it. Users usually - // exist on only one instance, so a silent development default makes lookups - // fail confusingly. Agent mode never prompts and keeps the dev default. - if (!options.instance && isHuman() && app.instances.length > 1) { - instanceHint = await select({ - message: "Select an instance to use:", - choices: app.instances.map((entry) => ({ - name: `${entry.instance_id} (${entry.environment_type})`, - value: entry.instance_id, - })), - }); + // Never guess silently between instances. Defaulting to development would + // make the same command resolve differently depending on which instances an + // app happens to have (e.g. once a production instance is added), breaking + // the guarantee that a command produces a repeatable result. So when the app + // has more than one instance and --instance didn't pin one, resolve the + // ambiguity explicitly: human mode prompts, agent mode errors. + if (!options.instance && app.instances.length > 1) { + if (isHuman()) { + instanceHint = await select({ + message: "Select an instance to use:", + choices: app.instances.map((entry) => ({ + name: `${entry.instance_id} (${entry.environment_type})`, + value: entry.instance_id, + })), + }); + } else { + throwUsageError( + "This application has multiple instances. Pass --instance to choose one explicitly.", + ); + } } const resolved = resolveFetchedApplicationInstance(appId, app, instanceHint); diff --git a/packages/cli-core/src/lib/users.ts b/packages/cli-core/src/lib/users.ts index 5cb5020d..d43a767e 100644 --- a/packages/cli-core/src/lib/users.ts +++ b/packages/cli-core/src/lib/users.ts @@ -6,7 +6,6 @@ const REDACTED = "[REDACTED]"; const DIRECT_REDACT_KEYS = new Set(["password", "code"]); const OBJECT_REDACT_KEYS = new Set(["private_metadata", "unsafe_metadata"]); -/** A user record as returned by the BAPI `GET /users` search endpoint. */ export type BapiUserSummary = { id: string; first_name?: string | null; @@ -23,8 +22,6 @@ export type BapiUserSummary = { export type UserSearchFilter = { email: string } | { query: string }; /** - * Search users via BAPI `GET /users`, returning at most `limit` records. - * * Centralizes the `/users` request so commands don't each hand-roll the query * string, limit handling, and `Array.isArray` body guard. */