Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/impersonate-upgrade-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Guide you to the billing page to add the impersonation add-on when `clerk impersonate` is blocked by your plan (402) or your billing-period limit (422). In an interactive terminal it offers to open the page for you; `--yes` opens it directly, `--print` and agent mode surface the URL without opening a browser.
24 changes: 21 additions & 3 deletions packages/cli-core/src/commands/impersonate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,27 @@ endpoint for actor tokens, so creation is the only moment the ID is visible.
| `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) |
| `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) |

## Billing block → upgrade nudge

`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).
app's subscription plan, and `422` when the impersonation quota for the billing
period is exhausted. Both mean no session was created, so the command exits
non-zero. The messages are value-framed and numberless (BAPI has no quota-read
endpoint, so the CLI never shows an `X/5`-style counter):

- `402` → `Impersonation is available as an add-on.`
- `422` → `You've reached your impersonation limit this billing period.`

Both attach the account-level billing page — `<dashboard>/settings/billing`
(from `getDashboardUrl()`, honoring `CLERK_DASHBOARD_URL`) — as `docsUrl` so the
add-on is one click away. How the URL is surfaced depends on the output mode:

| Condition | Behavior |
| ------------------------- | ----------------------------------------------------------------------- |
| Agent mode | JSON error with the billing URL as `docsUrl`. Never opens a browser. |
| `--print` | Prints the URL beneath the error. No prompt, no browser. |
| `--yes` | Opens the billing page immediately (prints it if headless). No prompt. |
| TTY human, no `--yes` | Prompts "Add more impersonations now?" (default **Yes**); opens on yes. |
| Non-TTY human, no `--yes` | Prints the URL beneath the error. No prompt, no browser. |

No part of this command is mocked or stubbed.
119 changes: 89 additions & 30 deletions packages/cli-core/src/commands/impersonate/impersonate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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";
import { BapiError, BillingError, CliError, ERROR_CODE } from "../../lib/errors.ts";
import { getDashboardUrl } from "../../lib/environment.ts";

const mockRequireLoginEmail = mock();
const mockBuildActorStamp = mock();
Expand Down Expand Up @@ -49,6 +50,17 @@ mock.module("../../lib/spinner.ts", () => ({
const { impersonate } = await import("./impersonate.ts");

const SIGN_IN_URL = "https://example.clerk.accounts.dev/v1/tickets/accept?ticket=tkt_abc";
const BILLING_URL = `${getDashboardUrl().replace(/\/$/, "")}/settings/billing`;

function reject422(): void {
mockBapiRequest.mockRejectedValue(
new BapiError(
422,
JSON.stringify({ errors: [{ message: "limit exceeded", meta: { limit: 100, used: 100 } }] }),
new Headers(),
),
);
}

const CTX = {
secretKey: "sk_test_123",
Expand Down Expand Up @@ -223,7 +235,7 @@ describe("impersonate", () => {
});
});

test("402 from BAPI surfaces the plan-gate error, distinct from the quota error", async () => {
test("402 surfaces a value-framed add-on message with the billing page as docsUrl", async () => {
mockBapiRequest.mockRejectedValue(
new BapiError(402, JSON.stringify({ errors: [{ message: "not enabled" }] }), new Headers()),
);
Expand All @@ -235,21 +247,14 @@ describe("impersonate", () => {
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);
expect(error).toBeInstanceOf(BillingError);
expect((error as BillingError).message).toBe("Impersonation is available as an add-on.");
expect((error as BillingError).code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED);
expect((error as BillingError).docsUrl).toBe(BILLING_URL);
});

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(),
),
);
test("422 surfaces a numberless limit message with the billing page as docsUrl", async () => {
reject422();

let error: unknown;
try {
Expand All @@ -258,21 +263,16 @@ describe("impersonate", () => {
error = caught;
}

expect(error).toBeInstanceOf(CliError);
expect((error as CliError).message).toBe(
"Impersonation limit exceeded (used 100/100 this billing period).",
expect(error).toBeInstanceOf(BillingError);
expect((error as BillingError).message).toBe(
"You've reached your impersonation limit this billing period.",
);
expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED);
expect((error as BillingError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED);
expect((error as BillingError).docsUrl).toBe(BILLING_URL);
});

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(),
),
);
test("422 drops the used/limit counts even when BAPI reports them in meta", async () => {
reject422();

let error: unknown;
try {
Expand All @@ -281,8 +281,67 @@ describe("impersonate", () => {
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);
expect((error as BillingError).message).not.toMatch(/100/);
});

test("billing block in agent mode throws with docsUrl and never prompts or opens a browser", async () => {
setMode("agent");
reject422();

await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError);
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockOpenBrowser).not.toHaveBeenCalled();
});

test("billing block with --print prints the URL via the error, no prompt, no browser", async () => {
reject422();

await expect(impersonate({ user: "user_2x9k", print: true, yes: true })).rejects.toBeInstanceOf(
BillingError,
);
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockOpenBrowser).not.toHaveBeenCalled();
});

test("billing block with --yes opens the billing page without prompting, then exits non-zero", async () => {
reject422();

await expect(impersonate({ user: "user_2x9k", yes: true })).rejects.toBeInstanceOf(
BillingError,
);
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockOpenBrowser).toHaveBeenCalledWith(BILLING_URL);
});

test("billing block in a TTY prompts to upgrade and opens the billing page on yes", async () => {
reject422();
mockConfirm.mockResolvedValueOnce(true); // "Impersonate ...?"
mockConfirm.mockResolvedValueOnce(true); // "Add more impersonations now?"

await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError);
expect(mockConfirm).toHaveBeenLastCalledWith(
expect.objectContaining({ message: "Add more impersonations now?", default: true }),
);
expect(mockOpenBrowser).toHaveBeenCalledWith(BILLING_URL);
});

test("billing block in a TTY leaves the browser closed when the upgrade prompt is declined", async () => {
reject422();
mockConfirm.mockResolvedValueOnce(true); // "Impersonate ...?"
mockConfirm.mockResolvedValueOnce(false); // "Add more impersonations now?"

await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError);
expect(mockOpenBrowser).not.toHaveBeenCalled();
});

test("billing block in non-TTY human mode never opens a browser (no --yes)", async () => {
setStdinTTY(false);
reject422();

// Pre-flight "Impersonate ...?" confirm still resolves (mocked); the upgrade
// nudge must NOT open a browser because stdin is not a TTY and --yes is absent.
await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError);
expect(mockConfirm).toHaveBeenCalledTimes(1); // only the pre-flight confirm, no upgrade prompt
expect(mockOpenBrowser).not.toHaveBeenCalled();
Comment thread
rafa-thayto marked this conversation as resolved.
});
});
47 changes: 36 additions & 11 deletions packages/cli-core/src/commands/impersonate/impersonate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
throwUserAbort,
withApiContext,
} from "../../lib/errors.ts";
import { getDashboardUrl } from "../../lib/environment.ts";
import { log } from "../../lib/log.ts";
import { openBrowser } from "../../lib/open.ts";
import { confirm } from "../../lib/prompts.ts";
Expand Down Expand Up @@ -49,35 +50,55 @@ async function openOrWarn(url: string): Promise<void> {
}
}

// The impersonation add-on is bought per account, not per app/instance, so this
// is deliberately the account-level billing page, not the per-instance dashboard path.
function impersonationBillingUrl(): string {
return `${getDashboardUrl().replace(/\/$/, "")}/settings/billing`;
}

function actorTokenErrorToCliError(error: unknown): CliError | undefined {
if (!(error instanceof BapiError)) return undefined;

if (error.status === 402) {
return new BillingError("Impersonation isn't enabled on this app's plan.", {
return new BillingError("Impersonation is available as an add-on.", {
reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED,
code: ERROR_CODE.IMPERSONATION_NOT_ENABLED,
docsUrl: impersonationBillingUrl(),
});
}

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 BillingError(`Impersonation limit exceeded${quota}.`, {
return new BillingError("You've reached your impersonation limit this billing period.", {
reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED,
code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED,
limit,
used,
docsUrl: impersonationBillingUrl(),
});
}

return undefined;
}

// The session was never created, so the caller still rethrows to exit non-zero
// after this — nudging only decides whether to actively open the billing page.
// Agent, --print, and non-TTY stay passive: the global handler already surfaces
// the URL, and we can't assume consent to open without a prompt or --yes.
async function nudgeToBilling(error: BillingError, options: ImpersonateOptions): Promise<void> {
const url = error.docsUrl;
if (!url || isAgent() || options.print) return;

if (options.yes) {
await openOrWarn(url);
return;
}

if (!process.stdin.isTTY) return;

const upgrade = await confirm({ message: "Add more impersonations now?", default: true });
if (upgrade) {
await openOrWarn(url);
}
}

export async function impersonate(options: ImpersonateOptions = {}): Promise<void> {
const loginEmail = await requireLoginEmail();

Expand Down Expand Up @@ -123,6 +144,10 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise<voi
);
} catch (error) {
const cliError = actorTokenErrorToCliError(error);
if (cliError instanceof BillingError) {
await nudgeToBilling(cliError, options);
throw cliError;
}
if (cliError) throw cliError;
throw error;
}
Expand Down
27 changes: 10 additions & 17 deletions packages/cli-core/src/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,30 +162,23 @@ describe("BapiError factories", () => {

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.", {
const err = new BillingError("Impersonation is available as an add-on.", {
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,
},
);
});

test("carries a docsUrl to the billing page for the quota-exceeded reason", () => {
const err = new BillingError("You've reached your impersonation limit this billing period.", {
reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED,
code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED,
docsUrl: "https://dashboard.clerk.com/settings/billing",
});
expect(err.reason).toBe(BILLING_ERROR_REASON.QUOTA_EXCEEDED);
expect(err.limit).toBe(100);
expect(err.used).toBe(100);
expect(err.docsUrl).toBe("https://dashboard.clerk.com/settings/billing");
});
});
20 changes: 6 additions & 14 deletions packages/cli-core/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ interface AuthErrorOptions extends Omit<CliErrorOptions, "code"> {

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;
}

/**
Expand Down Expand Up @@ -188,31 +184,27 @@ export class AuthError extends CliError {
*
* 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.
* discriminates the two cases for programmatic consumers. Callers still supply
* a feature-specific `code` (e.g. {@link ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED})
* and a human-readable message, and typically a `docsUrl` pointing to the
* relevant billing page.
*
* @example
* ```ts
* throw new BillingError("Impersonation isn't enabled on this app's plan.", {
* throw new BillingError("Impersonation is available as an add-on.", {
* 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;
const { reason, ...rest } = options;
super(message, rest);
this.name = "BillingError";
this.reason = reason;
this.limit = limit;
this.used = used;
}
}

Expand Down