From 6026313138e93384c9877efaa0e0e45de69d6c7f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:12:45 -0300 Subject: [PATCH 01/28] feat(webhooks): add webhook error codes --- packages/cli-core/src/lib/errors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..025b2fbf 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,12 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** Referenced webhook endpoint not found. */ + WEBHOOK_ENDPOINT_NOT_FOUND: "webhook_endpoint_not_found", + /** Referenced webhook message (delivery) not found. */ + WEBHOOK_MESSAGE_NOT_FOUND: "webhook_message_not_found", + /** Event type is not in the instance's event-type catalog. */ + UNKNOWN_EVENT_TYPE: "unknown_event_type", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 4f0739b75ca247beb163dc0d35b944abcb55b330 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:13:56 -0300 Subject: [PATCH 02/28] feat(webhooks): persist per-instance relay state in CLI config --- packages/cli-core/src/lib/config.test.ts | 33 ++++++++++++++++++++++++ packages/cli-core/src/lib/config.ts | 25 +++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..49617511 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -16,6 +16,8 @@ const { resolveInstanceId, resolveAppContext, resolveFetchedApplicationInstance, + getRelayEntry, + setRelayEntry, _setConfigDir, } = await import("./config.ts"); type Profile = @@ -71,6 +73,37 @@ describe("config", () => { expect(result.auth).toEqual({ production: { userId: "user_legacy" } }); }); + test("getRelayEntry returns undefined when nothing is persisted", async () => { + expect(await getRelayEntry("ins_123")).toBeUndefined(); + }); + + test("setRelayEntry and getRelayEntry roundtrip per instance", async () => { + await setRelayEntry("ins_a", { token: "Ab12Cd34Ef" }); + await setRelayEntry("ins_b", { token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "Ab12Cd34Ef" }); + expect(await getRelayEntry("ins_b")).toEqual({ token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + }); + + test("setRelayEntry overwrites only the targeted instance", async () => { + await setRelayEntry("ins_a", { token: "tokenAAAAA" }); + await setRelayEntry("ins_a", { token: "tokenAAAAA", endpoint_id: "ep_2" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "tokenAAAAA", endpoint_id: "ep_2" }); + }); + + test("readConfig preserves relay through the legacy-auth migration", async () => { + const legacyConfig = { + auth: { userId: "user_legacy" }, + profiles: {}, + relay: { ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }, + }; + await Bun.write(`${tempDir}/config.json`, JSON.stringify(legacyConfig)); + + const result = await readConfig(); + expect(result.relay).toEqual({ ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }); + }); + test("setAuth and getAuth", async () => { expect(await getAuth()).toBeUndefined(); await setAuth({ userId: "user_456" }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..b36729ac 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -45,10 +45,17 @@ export function profileLabel(profile: Profile): string { return profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId; } +/** Persisted Svix relay state for `clerk webhooks listen`, keyed by instance ID. */ +interface RelayEntry { + token: string; + endpoint_id?: string; +} + interface ClerkConfig { environment?: string; auth?: Record; profiles: Record; + relay?: Record; } function defaultConfig(): ClerkConfig { @@ -65,6 +72,10 @@ function migrateRawConfig(raw: Record): ClerkConfig { profiles: (raw.profiles as Record) ?? {}, }; + if (raw.relay && typeof raw.relay === "object") { + config.relay = raw.relay as Record; + } + if (raw.auth && typeof raw.auth === "object") { const auth = raw.auth as Record; if (typeof auth.userId === "string") { @@ -169,6 +180,18 @@ export async function moveProfile(oldKey: string, newKey: string): Promise await writeConfig(config); } +export async function getRelayEntry(instanceId: string): Promise { + const config = await readConfig(); + return config.relay?.[instanceId]; +} + +export async function setRelayEntry(instanceId: string, entry: RelayEntry): Promise { + const config = await readConfig(); + if (!config.relay) config.relay = {}; + config.relay[instanceId] = entry; + await writeConfig(config); +} + export async function listProfiles(): Promise> { const config = await readConfig(); return config.profiles; @@ -362,4 +385,4 @@ export async function resolveAppContext( }; } -export type { Auth, Profile, ClerkConfig, AppContextOptions }; +export type { Auth, Profile, ClerkConfig, AppContextOptions, RelayEntry }; From 0ee17a4f99e384d7dc0fe28a020d1167176cc9f4 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:16:07 -0300 Subject: [PATCH 03/28] feat(webhooks): add typed PLAPI client functions for the 13 webhook routes --- packages/cli-core/src/lib/plapi.test.ts | 235 ++++++++++++++++++++++++ packages/cli-core/src/lib/plapi.ts | 229 +++++++++++++++++++++++ 2 files changed, 464 insertions(+) diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8..b205084a 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,6 +19,19 @@ const { getApplicationDomainStatus, triggerApplicationDomainDNSCheck, listApplicationDomains, + listWebhookEndpoints, + getWebhookEndpoint, + createWebhookEndpoint, + updateWebhookEndpoint, + deleteWebhookEndpoint, + getWebhookEndpointSecret, + rotateWebhookEndpointSecret, + listWebhookEventTypes, + listWebhookMessages, + resendWebhookMessage, + recoverWebhookMessages, + sendWebhookExample, + getWebhookPortalUrl, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -563,3 +576,225 @@ describe("plapi", () => { }); }); }); + +describe("plapi webhooks", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + type CapturedRequest = { + url: URL; + method: string; + body: string | undefined; + contentType: string | null; + }; + + let captured: CapturedRequest[]; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "test_key_123"; + captured = []; + stubFetch(async (input, init) => { + captured.push({ + url: new URL(input.toString()), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : undefined, + contentType: new Headers(init?.headers).get("Content-Type"), + }); + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + const PREFIX = "/v1/platform/applications/app_1/instances/ins_1/webhooks"; + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1"), + method: "GET", + path: PREFIX, + body: undefined as string | undefined, + }, + { + name: "getWebhookEndpoint", + call: () => getWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "createWebhookEndpoint", + call: () => + createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }), + method: "POST", + path: PREFIX, + body: '{"url":"https://example.com/webhooks","version":1}', + }, + { + name: "updateWebhookEndpoint", + call: () => updateWebhookEndpoint("app_1", "ins_1", "ep_1", { description: "d" }), + method: "PATCH", + path: `${PREFIX}/ep_1`, + body: '{"description":"d"}', + }, + { + name: "deleteWebhookEndpoint", + call: () => deleteWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "DELETE", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "getWebhookEndpointSecret", + call: () => getWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/secret`, + body: undefined, + }, + { + name: "rotateWebhookEndpointSecret", + call: () => rotateWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "POST", + path: `${PREFIX}/ep_1/secret/rotate`, + body: undefined, + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1"), + method: "GET", + path: `${PREFIX}/event_types`, + body: undefined, + }, + { + name: "listWebhookMessages", + call: () => listWebhookMessages("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/messages`, + body: undefined, + }, + { + name: "resendWebhookMessage", + call: () => resendWebhookMessage("app_1", "ins_1", "ep_1", "msg_1"), + method: "POST", + path: `${PREFIX}/ep_1/messages/msg_1/resend`, + body: undefined, + }, + { + name: "recoverWebhookMessages", + call: () => + recoverWebhookMessages("app_1", "ins_1", "ep_1", { since: "2026-05-01T00:00:00Z" }), + method: "POST", + path: `${PREFIX}/ep_1/recover`, + body: '{"since":"2026-05-01T00:00:00Z"}', + }, + { + name: "sendWebhookExample", + call: () => sendWebhookExample("app_1", "ins_1", "ep_1", "user.created"), + method: "POST", + path: `${PREFIX}/ep_1/send_example`, + body: '{"event_type":"user.created"}', + }, + { + name: "getWebhookPortalUrl", + call: () => getWebhookPortalUrl("app_1", "ins_1"), + method: "POST", + path: `${PREFIX}/url`, + body: "{}", + }, + ])("$name sends $method $path", async ({ call, method, path, body }) => { + await call(); + + expect(captured).toHaveLength(1); + const request = captured[0]!; + expect(request.method).toBe(method); + expect(request.url.pathname).toBe(path); + expect(request.body).toBe(body); + expect(request.contentType).toBe(body === undefined ? null : "application/json"); + }); + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookMessages", + call: () => + listWebhookMessages("app_1", "ins_1", "ep_1", { limit: 50, iterator: "iter_abc" }), + }, + ])("$name translates --iterator to the starting_after query param", async ({ call }) => { + await call(); + + const url = captured[0]!.url; + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.get("starting_after")).toBe("iter_abc"); + expect(url.searchParams.has("iterator")).toBe(false); + }); + + test("list functions omit pagination params when not provided", async () => { + await listWebhookEndpoints("app_1", "ins_1"); + + const url = captured[0]!.url; + expect(url.search).toBe(""); + }); + + test("listWebhookMessages forwards the status filter", async () => { + await listWebhookMessages("app_1", "ins_1", "ep_1", { status: "fail" }); + + expect(captured[0]!.url.searchParams.get("status")).toBe("fail"); + }); + + test("recoverWebhookMessages includes until only when provided", async () => { + await recoverWebhookMessages("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + + expect(captured[0]!.body).toBe( + '{"since":"2026-05-01T00:00:00Z","until":"2026-05-01T01:00:00Z"}', + ); + }); + + test("createWebhookEndpoint serializes optional fields", async () => { + await createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + + expect(JSON.parse(captured[0]!.body!)).toEqual({ + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + }); + + test("throws PlapiError on non-ok responses", async () => { + stubFetch( + async () => new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { status: 404 }), + ); + + await expect(getWebhookEndpoint("app_1", "ins_1", "ep_missing")).rejects.toBeInstanceOf( + PlapiError, + ); + }); +}); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 53f78ba2..1a5db643 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -331,3 +331,232 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +// ── Webhooks (instance-scoped /webhooks routes) ────────────────────────── + +export type WebhookEndpoint = { + id: string; + url: string; + version: number; + description?: string; + disabled: boolean; + filter_types?: string[] | null; + channels?: string[] | null; + created_at: string; + updated_at: string; +}; + +export type WebhookCursor = { + starting_after: string | null; + ending_before: string | null; + has_next_page: boolean; +}; + +export type WebhookEndpointList = { + data: WebhookEndpoint[]; + cursor: WebhookCursor; +}; + +export type WebhookEventType = { + name: string; + description?: string; + archived: boolean; + created_at: string; + updated_at: string; +}; + +export type WebhookEventTypeList = { + data: WebhookEventType[]; + cursor: WebhookCursor; +}; + +export const WEBHOOK_MESSAGE_STATUSES = ["success", "pending", "fail", "sending"] as const; +export type WebhookMessageStatus = (typeof WEBHOOK_MESSAGE_STATUSES)[number]; + +export type WebhookMessage = { + id: string; + event_type: string; + status: WebhookMessageStatus; + next_attempt: string | null; + payload: unknown; + created_at: string; +}; + +export type WebhookMessageList = { + data: WebhookMessage[]; + cursor: WebhookCursor; +}; + +export type CreateWebhookEndpointParams = { + url: string; + version: 1; + description?: string; + disabled?: boolean; + filter_types?: string[]; + channels?: string[]; +}; + +export type UpdateWebhookEndpointParams = Partial; + +export type WebhookPageParams = { + limit?: number; + iterator?: string; +}; + +function webhooksUrl(applicationId: string, instanceId: string, path = ""): URL { + return new URL( + `/v1/platform/applications/${applicationId}/instances/${instanceId}/webhooks${path}`, + getPlapiBaseUrl(), + ); +} + +/** + * The CLI flag is `--iterator` (the Svix pagination concept); the wire query + * param is Clerk's cursor convention `starting_after`. The translation lives + * here so commands never see the wire name. + */ +function appendPageParams(url: URL, params?: WebhookPageParams): void { + if (typeof params?.limit === "number") { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.iterator) { + url.searchParams.set("starting_after", params.iterator); + } +} + +export async function listWebhookEndpoints( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function getWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createWebhookEndpoint( + applicationId: string, + instanceId: string, + params: CreateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function updateWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, + params: UpdateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("PATCH", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function deleteWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + await plapiFetch("DELETE", url); +} + +export async function getWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise<{ secret: string }> { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret`); + const response = await plapiFetch("GET", url); + return response.json() as Promise<{ secret: string }>; +} + +export async function rotateWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret/rotate`); + await plapiFetch("POST", url); +} + +export async function listWebhookEventTypes( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, "/event_types"); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function listWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + params?: WebhookPageParams & { status?: WebhookMessageStatus }, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages`); + appendPageParams(url, params); + if (params?.status) { + url.searchParams.set("status", params.status); + } + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function resendWebhookMessage( + applicationId: string, + instanceId: string, + endpointId: string, + messageId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages/${messageId}/resend`); + await plapiFetch("POST", url); +} + +export async function recoverWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + window: { since: string; until?: string }, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/recover`); + const body: { since: string; until?: string } = { since: window.since }; + if (window.until) body.until = window.until; + await plapiFetch("POST", url, { body: JSON.stringify(body) }); +} + +export async function sendWebhookExample( + applicationId: string, + instanceId: string, + endpointId: string, + eventType: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/send_example`); + await plapiFetch("POST", url, { body: JSON.stringify({ event_type: eventType }) }); +} + +export async function getWebhookPortalUrl( + applicationId: string, + instanceId: string, +): Promise<{ url: string }> { + const url = webhooksUrl(applicationId, instanceId, "/url"); + const response = await plapiFetch("POST", url, { body: JSON.stringify({}) }); + return response.json() as Promise<{ url: string }>; +} From 24e05aa1484c3c190c398643d592d58d30bbc569 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:16:40 -0300 Subject: [PATCH 04/28] feat(webhooks): register webhooks command group with auth preAction gate --- packages/cli-core/src/cli-program.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index bef0fcce..35d02c6f 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -47,6 +47,7 @@ import { update } from "./commands/update/index.ts"; import { deploy } from "./commands/deploy/index.ts"; import { deployStatus } from "./commands/deploy/status-command.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; +import { getAuthToken } from "./lib/plapi.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; import { registerExtras } from "@clerk/cli-extras"; @@ -466,6 +467,29 @@ export function createProgram() { }), ); + const webhooks = program + .command("webhooks") + .description("Manage webhook endpoints and deliveries") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk webhooks list", description: "List webhook endpoints" }, + { + command: "clerk webhooks create --url https://example.com/api/webhooks", + description: "Create an endpoint and print its signing secret", + }, + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + ]); + + webhooks.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.name() === "verify") return; // pure offline HMAC, no auth gate + await getAuthToken(); + }); + const env = program .command("env") .description("Manage environment variables") From a20e865cff36ce25a584c5c050e85ddcd482330d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:19:00 -0300 Subject: [PATCH 05/28] feat(webhooks): add 'webhooks list' command --- packages/cli-core/src/cli-program.ts | 20 +++ .../cli-core/src/commands/webhooks/README.md | 44 +++++ .../cli-core/src/commands/webhooks/index.ts | 5 + .../src/commands/webhooks/list.test.ts | 154 ++++++++++++++++++ .../cli-core/src/commands/webhooks/list.ts | 69 ++++++++ .../cli-core/src/commands/webhooks/shared.ts | 109 +++++++++++++ 6 files changed, 401 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/README.md create mode 100644 packages/cli-core/src/commands/webhooks/index.ts create mode 100644 packages/cli-core/src/commands/webhooks/list.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/list.ts create mode 100644 packages/cli-core/src/commands/webhooks/shared.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 35d02c6f..3a6d114c 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -48,6 +48,7 @@ import { deploy } from "./commands/deploy/index.ts"; import { deployStatus } from "./commands/deploy/status-command.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; import { getAuthToken } from "./lib/plapi.ts"; +import { webhooks as webhooksHandlers } from "./commands/webhooks/index.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; import { registerExtras } from "@clerk/cli-extras"; @@ -490,6 +491,25 @@ export function createProgram() { await getAuthToken(); }); + webhooks + .command("list") + .description("List webhook endpoints for the instance") + .option("--limit ", "Maximum endpoints to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { command: "clerk webhooks list", description: "List webhook endpoints" }, + { command: "clerk webhooks list --limit 10", description: "List the first 10 endpoints" }, + { + command: "clerk webhooks list --iterator iter_abc", + description: "Fetch the next page using a previous response's cursor", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.list(cmd.optsWithGlobals() as Parameters[0]), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md new file mode 100644 index 00000000..5d8c3c34 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -0,0 +1,44 @@ +# Webhooks Commands + +> The 13 PLAPI webhook routes these commands call are being built in parallel in `clerk_go` and may not exist yet in every environment. The CLI is built against the final spec's request/response shapes; unit tests mock the PLAPI layer. + +Manage webhook endpoints and deliveries for the linked instance: CRUD, delivery inspection, local forwarding (`listen`), replay, and offline signature verification. + +## Group-level options + +Inherited by every subcommand via `optsWithGlobals()`: + +| Option | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `--app ` | Application ID to target (works from any directory). | +| `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). | +| `--json` | Force machine output in a human TTY. Agent mode (`isAgent()`) always behaves as if `--json` were set. | + +Auth: every subcommand except `verify` is gated by a `preAction` hook calling `getAuthToken()` (accepts `ak_` keys or an OAuth session; never `sk_`). `verify` is pure offline HMAC — no auth, and it ignores `--app`/`--instance`. + +Output contract: stdout carries bare domain JSON via `log.data()` (pipeable); stderr carries human UI and, in agent mode, structured error JSON `{"error":{code,message,docsUrl?}}`. No `{ok,data,error}` envelope. Exit codes: 0 success, 1 failure, 2 usage error, 130 SIGINT. + +Pagination: list-shaped commands fetch ONE page (`--limit` 1-250, default 100). When `cursor.has_next_page` is true, the next `--iterator` value is printed as a stderr hint. The `--iterator` flag value is sent on the wire as the `starting_after` query param. + +All routes below are relative to `/v1/platform/applications/{applicationID}/instances/{envOrInsID}`. + +## `clerk webhooks list` + +Lists webhook endpoints for the instance. + +```sh +clerk webhooks list [--limit N] [--iterator C] +``` + +| Option | Description | +| ---------------- | ------------------------------------------------- | +| `--limit ` | Maximum endpoints to return (1-250, default 100). | +| `--iterator ` | Pagination cursor from the previous response. | + +Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode prints the full `{ data, cursor }` response on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------- | ---------------------------------- | +| `GET` | `/webhooks` | List webhook endpoints (one page). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts new file mode 100644 index 00000000..a500ae81 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -0,0 +1,5 @@ +import { webhooksList } from "./list.ts"; + +export const webhooks = { + list: webhooksList, +}; diff --git a/packages/cli-core/src/commands/webhooks/list.test.ts b/packages/cli-core/src/commands/webhooks/list.test.ts new file mode 100644 index 00000000..240f3717 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.test.ts @@ -0,0 +1,154 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEndpoints = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEndpoints: (...args: unknown[]) => mockListWebhookEndpoints(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksList } = await import("./list.ts"); + +const mockEndpoints = [ + { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + id: "ep_2", + url: "https://example.com/other", + version: 1, + disabled: true, + filter_types: null, + channels: null, + created_at: "2026-06-02T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", + }, +]; + +function listResponse(overrides: Partial<{ data: unknown[]; has_next_page: boolean }> = {}) { + return { + data: overrides.data ?? mockEndpoints, + cursor: { + starting_after: "iter_next", + ending_before: null, + has_next_page: overrides.has_next_page ?? false, + }, + }; +} + +describe("webhooks list", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEndpoints.mockResolvedValue(listResponse()); + }); + + afterEach(() => { + mockListWebhookEndpoints.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksList(); + + expect(mockResolveAppContext).toHaveBeenCalledWith({}); + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksList({ limit: 25, iterator: "iter_prev" }); + + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 25, + iterator: "iter_prev", + }); + }); + + test("forwards --app and --instance to context resolution", async () => { + await webhooksList({ app: "app_2", instance: "prod" }); + + expect(mockResolveAppContext).toHaveBeenCalledWith({ app: "app_2", instance: "prod" }); + }); + + test("prints a human-readable table by default", async () => { + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("disabled"); + expect(captured.err).toContain("2 endpoints returned"); + }); + + test("warns when no endpoints exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ data: [] })); + + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No webhook endpoints found."); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ has_next_page: true })); + + await webhooksList(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("omits the pagination hint on the last page", async () => { + await webhooksList(); + + expect(captured.err).not.toContain("--iterator"); + }); + + test("outputs the full list response as JSON with --json", async () => { + await webhooksList({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksList(); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/list.ts b/packages/cli-core/src/commands/webhooks/list.ts new file mode 100644 index 00000000..c9987cad --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.ts @@ -0,0 +1,69 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEndpoints, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksListOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +const COLUMN_PADDING = 2; + +function endpointStatus(endpoint: WebhookEndpoint): string { + return endpoint.disabled ? "disabled" : "enabled"; +} + +function endpointEvents(endpoint: WebhookEndpoint): string { + return endpoint.filter_types?.length ? endpoint.filter_types.join(",") : "all"; +} + +function formatEndpointsTable(endpoints: WebhookEndpoint[]): void { + const idWidth = Math.max("ID".length, ...endpoints.map((e) => e.id.length)) + COLUMN_PADDING; + const urlWidth = Math.max("URL".length, ...endpoints.map((e) => e.url.length)) + COLUMN_PADDING; + const statusWidth = + Math.max("STATUS".length, ...endpoints.map((e) => endpointStatus(e).length)) + COLUMN_PADDING; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("URL".padEnd(urlWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("EVENTS")}`, + ); + for (const endpoint of endpoints) { + log.info( + `${cyan(endpoint.id.padEnd(idWidth))}${endpoint.url.padEnd(urlWidth)}${endpointStatus(endpoint).padEnd(statusWidth)}${endpointEvents(endpoint)}`, + ); + } +} + +export async function webhooksList(options: WebhooksListOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEndpoints(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook endpoints", + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No webhook endpoints found."); + return; + } + + formatEndpointsTable(response.data); + const count = response.data.length; + log.info(`\n${count} endpoint${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/shared.ts b/packages/cli-core/src/commands/webhooks/shared.ts new file mode 100644 index 00000000..468914a4 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -0,0 +1,109 @@ +import { getRelayEntry } from "../../lib/config.ts"; +import { + CliError, + ERROR_CODE, + PlapiError, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import type { WebhookCursor } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; + +export interface WebhooksGlobalOptions { + app?: string; + instance?: string; + json?: boolean; +} + +export const DEFAULT_PAGE_LIMIT = 100; + +export function shouldOutputJson(options: { json?: boolean }): boolean { + return Boolean(options.json) || isAgent(); +} + +/** Bare domain JSON on stdout — the only stdout writer for webhook commands. */ +export function printJson(data: unknown): void { + log.data(JSON.stringify(data, null, 2)); +} + +/** Stderr hint with the next `--iterator` value. The CLI never auto-paginates. */ +export function printIteratorHint(cursor: WebhookCursor): void { + if (cursor.has_next_page && cursor.starting_after) { + log.info(`More available — re-run with \`--iterator ${cursor.starting_after}\``); + } +} + +/** Map a PLAPI 404 on an endpoint-addressed route to a typed CLI error. */ +export async function rejectEndpointNotFound( + promise: Promise, + endpointId: string, +): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + throw new CliError(`No webhook endpoint with ID ${endpointId} was found.`, { + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + } + throw error; + } +} + +/** Map a PLAPI 404 on a message-addressed route to a typed CLI error. */ +export async function rejectMessageNotFound(promise: Promise, messageId: string): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + throw new CliError(`No webhook message with ID ${messageId} was found.`, { + code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, + }); + } + throw error; + } +} + +/** + * Destructive-command gate: prompt in human mode, require `--yes` in agent + * mode. Declining the prompt aborts cleanly via UserAbortError. + */ +export async function confirmDestructive( + message: string, + options: { yes?: boolean }, +): Promise { + if (options.yes) return; + if (isAgent()) { + throwUsageError("This action requires confirmation. Pass --yes to proceed in agent mode."); + } + const { confirm } = await import("../../lib/prompts.ts"); + const proceed = await confirm({ message, default: false }); + if (!proceed) throwUserAbort(); +} + +/** + * Resolve `--endpoint`, falling back to the instance's persisted relay + * endpoint (`trigger`, `messages`, and `replay ` convenience rule). + */ +export async function resolveEndpointOrRelay( + endpointFlag: string | undefined, + instanceId: string, +): Promise { + if (endpointFlag) return endpointFlag; + const entry = await getRelayEntry(instanceId); + if (entry?.endpoint_id) return entry.endpoint_id; + return throwUsageError( + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + ); +} + +/** Split a comma-separated flag value into trimmed, non-empty entries. */ +export function splitCommaList(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return parts; +} From c39e486705a4a77bceaae1638a8b3589b71bcc54 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:20:03 -0300 Subject: [PATCH 06/28] feat(webhooks): add 'webhooks get' command --- packages/cli-core/src/cli-program.ts | 21 ++++ .../cli-core/src/commands/webhooks/README.md | 16 +++ .../src/commands/webhooks/get.test.ts | 107 ++++++++++++++++++ .../cli-core/src/commands/webhooks/get.ts | 46 ++++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 192 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/get.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/get.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 3a6d114c..45fe6a20 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -510,6 +510,27 @@ export function createProgram() { webhooksHandlers.list(cmd.optsWithGlobals() as Parameters[0]), ); + webhooks + .command("get") + .description("Show one webhook endpoint's configuration") + .argument("", "Webhook endpoint ID (ep_...)") + .setExamples([ + { command: "clerk webhooks get ep_2abc123", description: "Show an endpoint's config" }, + { + command: "clerk webhooks get ep_2abc123 --json", + description: "Emit the endpoint resource as JSON", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.get({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 5d8c3c34..1601e178 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -42,3 +42,19 @@ Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode pri | Method | Endpoint | Description | | ------ | ----------- | ---------------------------------- | | `GET` | `/webhooks` | List webhook endpoints (one page). | + +## `clerk webhooks get ` + +Prints one endpoint's configuration. A PLAPI 404 maps to error code `webhook_endpoint_not_found`. + +```sh +clerk webhooks get ep_2abc123 +``` + +Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------ | ------------------- | +| `GET` | `/webhooks/{endpointID}` | Fetch one endpoint. | diff --git a/packages/cli-core/src/commands/webhooks/get.test.ts b/packages/cli-core/src/commands/webhooks/get.test.ts new file mode 100644 index 00000000..1fb3f53b --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.test.ts @@ -0,0 +1,107 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksGet } = await import("./get.ts"); + +const mockEndpoint = { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created", "user.deleted"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", +}; + +describe("webhooks get", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpoint.mockResolvedValue(mockEndpoint); + }); + + afterEach(() => { + mockGetWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches the endpoint by ID", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + }); + + test("prints endpoint details on stderr in human mode", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("enabled"); + expect(captured.err).toContain("user.created, user.deleted"); + }); + + test("outputs the bare endpoint resource as JSON with --json", async () => { + await webhooksGet({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksGet({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + const promise = webhooksGet({ endpointId: "ep_missing" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(webhooksGet({ endpointId: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + message: "No webhook endpoint with ID ep_missing was found.", + }); + }); + + test("re-throws non-404 PLAPI errors untouched", async () => { + const original = new PlapiError(500, "{}"); + mockGetWebhookEndpoint.mockRejectedValue(original); + + await expect(webhooksGet({ endpointId: "ep_1" })).rejects.toBe(original); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts new file mode 100644 index 00000000..68686381 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -0,0 +1,46 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { getWebhookEndpoint, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksGetOptions extends WebhooksGlobalOptions { + endpointId: string; +} + +export function formatEndpointDetails(endpoint: WebhookEndpoint): void { + const rows: Array<[string, string]> = [ + ["ID", cyan(endpoint.id)], + ["URL", endpoint.url], + ["Status", endpoint.disabled ? "disabled" : "enabled"], + ["Description", endpoint.description || dim("(none)")], + ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], + ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], + ["Created", endpoint.created_at], + ["Updated", endpoint.updated_at], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; + for (const [label, value] of rows) { + log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); + } +} + +export async function webhooksGet(options: WebhooksGetOptions): Promise { + const ctx = await resolveAppContext(options); + const endpoint = await rejectEndpointNotFound( + getWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + formatEndpointDetails(endpoint); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index a500ae81..32669a9b 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,5 +1,7 @@ +import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; export const webhooks = { list: webhooksList, + get: webhooksGet, }; From 93a3320dc6be6b40fed4fd638a7c05c91fc12c30 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:21:06 -0300 Subject: [PATCH 07/28] feat(webhooks): add 'webhooks event-types' command --- packages/cli-core/src/cli-program.ts | 20 +++ .../cli-core/src/commands/webhooks/README.md | 14 ++ .../src/commands/webhooks/event-types.test.ts | 131 ++++++++++++++++++ .../src/commands/webhooks/event-types.ts | 53 +++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 220 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/event-types.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/event-types.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 45fe6a20..9fc2c6ce 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -531,6 +531,26 @@ export function createProgram() { }), ); + webhooks + .command("event-types") + .description("List the instance's webhook event-type catalog") + .option("--limit ", "Maximum event types to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { command: "clerk webhooks event-types", description: "List available event types" }, + { + command: "clerk webhooks event-types --json", + description: "Emit the catalog as JSON", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.eventTypes( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 1601e178..95bf4b94 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -58,3 +58,17 @@ Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpo | Method | Endpoint | Description | | ------ | ------------------------ | ------------------- | | `GET` | `/webhooks/{endpointID}` | Fetch one endpoint. | + +## `clerk webhooks event-types` + +Lists the Svix event-type catalog for the instance (`--limit`/`--iterator` as in `list`). Archived types are marked in human output. + +```sh +clerk webhooks event-types [--limit N] [--iterator C] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------- | --------------------------------------- | +| `GET` | `/webhooks/event_types` | List the event-type catalog (one page). | diff --git a/packages/cli-core/src/commands/webhooks/event-types.test.ts b/packages/cli-core/src/commands/webhooks/event-types.test.ts new file mode 100644 index 00000000..572c2d76 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.test.ts @@ -0,0 +1,131 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksEventTypes } = await import("./event-types.ts"); + +const mockEventTypes = [ + { + name: "user.created", + description: "A user was created", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + name: "session.removed", + description: "A session was removed", + archived: true, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, +]; + +function eventTypesResponse(hasNextPage = false) { + return { + data: mockEventTypes, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks event-types", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse()); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksEventTypes(); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksEventTypes({ limit: 5, iterator: "iter_prev" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 5, + iterator: "iter_prev", + }); + }); + + test("prints names and descriptions, marking archived types", async () => { + await webhooksEventTypes(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("A user was created"); + expect(captured.err).toContain("session.removed"); + expect(captured.err).toContain("(archived)"); + expect(captured.err).toContain("2 event types returned"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse(true)); + + await webhooksEventTypes(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("warns when the catalog is empty", async () => { + mockListWebhookEventTypes.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksEventTypes(); + + expect(captured.err).toContain("No event types found."); + }); + + test("outputs the full response as JSON with --json", async () => { + await webhooksEventTypes({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksEventTypes(); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/event-types.ts b/packages/cli-core/src/commands/webhooks/event-types.ts new file mode 100644 index 00000000..6abd119a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.ts @@ -0,0 +1,53 @@ +import { cyan, dim, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, type WebhookEventType } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksEventTypesOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +function formatEventTypesTable(eventTypes: WebhookEventType[]): void { + const nameWidth = Math.max("NAME".length, ...eventTypes.map((t) => t.name.length)) + 2; + + log.info(`${dim("NAME".padEnd(nameWidth))}${dim("DESCRIPTION")}`); + for (const eventType of eventTypes) { + const archived = eventType.archived ? ` ${yellow("(archived)")}` : ""; + log.info(`${cyan(eventType.name.padEnd(nameWidth))}${eventType.description ?? ""}${archived}`); + } +} + +export async function webhooksEventTypes(options: WebhooksEventTypesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEventTypes(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook event types", + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No event types found."); + return; + } + + formatEventTypesTable(response.data); + const count = response.data.length; + log.info(`\n${count} event type${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 32669a9b..622aa647 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,7 +1,9 @@ +import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; export const webhooks = { list: webhooksList, get: webhooksGet, + eventTypes: webhooksEventTypes, }; From a2ad165102bcb2d0c34c4be4e389c1ef4aa2c09c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:22:17 -0300 Subject: [PATCH 08/28] feat(webhooks): add 'webhooks secret' command with --rotate --- packages/cli-core/src/cli-program.ts | 30 +++++ .../cli-core/src/commands/webhooks/README.md | 17 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/secret.test.ts | 124 ++++++++++++++++++ .../cli-core/src/commands/webhooks/secret.ts | 50 +++++++ 5 files changed, 223 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/secret.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/secret.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9fc2c6ce..02b8e16d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -551,6 +551,36 @@ export function createProgram() { ), ); + webhooks + .command("secret") + .description("Print a webhook endpoint's signing secret") + .argument("", "Webhook endpoint ID (ep_...)") + .option( + "--rotate", + "Rotate the signing secret first. The old key keeps verifying for 24h (Svix dual-signing grace).", + ) + .option("--yes", "Skip the rotation confirmation prompt (required with --rotate in agent mode)") + .setExamples([ + { command: "clerk webhooks secret ep_2abc123", description: "Print the signing secret" }, + { + command: "export CLERK_WEBHOOK_SIGNING_SECRET=$(clerk webhooks secret ep_2abc123)", + description: "Export the secret into the environment", + }, + { + command: "clerk webhooks secret ep_2abc123 --rotate", + description: "Rotate, then print the new secret", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.secret({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 95bf4b94..2c041012 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -72,3 +72,20 @@ clerk webhooks event-types [--limit N] [--iterator C] | Method | Endpoint | Description | | ------ | ----------------------- | --------------------------------------- | | `GET` | `/webhooks/event_types` | List the event-type catalog (one page). | + +## `clerk webhooks secret ` + +Prints the endpoint's current signing secret. With `--rotate`, rotates first (prompts in human mode; requires `--yes` in agent mode), then prints the new secret. After rotation Svix dual-signs with old+new keys for 24h — the `svix-signature` header carries multiple space-separated entries during the grace window. + +```sh +clerk webhooks secret ep_2abc123 [--rotate [--yes]] +``` + +Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `export CLERK_WEBHOOK_SIGNING_SECRET=$(clerk webhooks secret ep_...)`), with all banners on stderr. JSON/agent mode prints `{ "secret": "whsec_..." }`. Plain `secret ` never prompts; `--yes` is only meaningful with `--rotate`. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | -------------------------------------- | -------------------------------------------- | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the signing secret. | +| `POST` | `/webhooks/{endpointID}/secret/rotate` | Rotate the signing secret (`--rotate` only). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 622aa647..7629ed0a 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,9 +1,11 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksSecret } from "./secret.ts"; export const webhooks = { list: webhooksList, get: webhooksGet, eventTypes: webhooksEventTypes, + secret: webhooksSecret, }; diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts new file mode 100644 index 00000000..2f7bd0fd --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -0,0 +1,124 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpointSecret = mock(); +const mockRotateWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), + rotateWebhookEndpointSecret: (...args: unknown[]) => mockRotateWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksSecret } = await import("./secret.ts"); + +describe("webhooks secret", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_abc123" }); + mockRotateWebhookEndpointSecret.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockGetWebhookEndpointSecret.mockReset(); + mockRotateWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prints the bare secret on stdout in human mode", async () => { + await webhooksSecret({ endpointId: "ep_1" }); + + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("Signing secret for"); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("outputs { secret } as JSON with --json", async () => { + await webhooksSecret({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + expect(captured.err).toBe(""); + }); + + test("outputs { secret } in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + }); + + test("--rotate prompts, rotates, then fetches the new secret", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("dual-signs"); + }); + + test("--rotate --yes skips the prompt", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + + test("--rotate aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toBeInstanceOf( + UserAbortError, + ); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + }); + + test("--rotate in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toBeInstanceOf(CliError); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/secret.ts b/packages/cli-core/src/commands/webhooks/secret.ts new file mode 100644 index 00000000..de9d0978 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -0,0 +1,50 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { getWebhookEndpointSecret, rotateWebhookEndpointSecret } from "../../lib/plapi.ts"; +import { + confirmDestructive, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksSecretOptions extends WebhooksGlobalOptions { + endpointId: string; + rotate?: boolean; + yes?: boolean; +} + +export async function webhooksSecret(options: WebhooksSecretOptions): Promise { + const ctx = await resolveAppContext(options); + + if (options.rotate) { + await confirmDestructive( + `Rotate the signing secret for ${options.endpointId}? The old key keeps verifying for 24h (dual-signing grace).`, + options, + ); + await rejectEndpointNotFound( + rotateWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + } + + const { secret } = await rejectEndpointNotFound( + getWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson({ secret }); + return; + } + + if (options.rotate) { + log.success( + `Signing secret rotated. The previous key remains valid for 24 hours while Svix dual-signs.`, + ); + } + log.info(`Signing secret for \`${options.endpointId}\`:`); + // Bare secret on stdout so $(clerk webhooks secret ep_...) is eval-friendly. + log.data(secret); +} From 381e9e5eae629fc50eff2ea5bb801f82c40135eb Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:23:20 -0300 Subject: [PATCH 09/28] feat(webhooks): add 'webhooks delete' command --- packages/cli-core/src/cli-program.ts | 22 ++++ .../cli-core/src/commands/webhooks/README.md | 14 +++ .../src/commands/webhooks/delete.test.ts | 101 ++++++++++++++++++ .../cli-core/src/commands/webhooks/delete.ts | 29 +++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 168 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/delete.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/delete.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 02b8e16d..fa8c63ca 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -581,6 +581,28 @@ export function createProgram() { }), ); + webhooks + .command("delete") + .description("Delete a webhook endpoint") + .argument("", "Webhook endpoint ID (ep_...)") + .option("--yes", "Skip the confirmation prompt (required in agent mode)") + .setExamples([ + { command: "clerk webhooks delete ep_2abc123", description: "Delete with confirmation" }, + { + command: "clerk webhooks delete ep_2abc123 --yes", + description: "Delete without prompting", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.delete({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 2c041012..245a81de 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -89,3 +89,17 @@ Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `ex | ------ | -------------------------------------- | -------------------------------------------- | | `GET` | `/webhooks/{endpointID}/secret` | Fetch the signing secret. | | `POST` | `/webhooks/{endpointID}/secret/rotate` | Rotate the signing secret (`--rotate` only). | + +## `clerk webhooks delete ` + +Hard-deletes an endpoint (Svix delete is hard; no shadow table). Prompts in human mode; agent mode requires `--yes` or fails with a usage error (exit 2). Declining the prompt exits cleanly. Success prints a stderr confirmation; stdout stays empty (the route returns `200 {}`). + +```sh +clerk webhooks delete ep_2abc123 [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| -------- | ------------------------ | --------------------------------------- | +| `DELETE` | `/webhooks/{endpointID}` | Delete the endpoint (returns `200 {}`). | diff --git a/packages/cli-core/src/commands/webhooks/delete.test.ts b/packages/cli-core/src/commands/webhooks/delete.test.ts new file mode 100644 index 00000000..928ab6ae --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.test.ts @@ -0,0 +1,101 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockDeleteWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + deleteWebhookEndpoint: (...args: unknown[]) => mockDeleteWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksDelete } = await import("./delete.ts"); + +describe("webhooks delete", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockDeleteWebhookEndpoint.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockDeleteWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prompts before deleting in human mode", async () => { + await webhooksDelete({ endpointId: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Deleted webhook endpoint"); + }); + + test("--yes skips the prompt", async () => { + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toBeInstanceOf(UserAbortError); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("agent mode with --yes deletes without prompting", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockDeleteWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksDelete({ endpointId: "ep_missing", yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts new file mode 100644 index 00000000..61c0480e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -0,0 +1,29 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { deleteWebhookEndpoint } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksDeleteOptions extends WebhooksGlobalOptions { + endpointId: string; + yes?: boolean; +} + +export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { + const ctx = await resolveAppContext(options); + + await confirmDestructive( + `Permanently delete webhook endpoint ${options.endpointId}? This cannot be undone.`, + options, + ); + + await rejectEndpointNotFound( + deleteWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + log.success(`Deleted webhook endpoint \`${options.endpointId}\``); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 7629ed0a..1f50699f 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,3 +1,4 @@ +import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; @@ -8,4 +9,5 @@ export const webhooks = { get: webhooksGet, eventTypes: webhooksEventTypes, secret: webhooksSecret, + delete: webhooksDelete, }; From e68bf754ad704c5757c49c673c99a7e91cfbc64f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:24:38 -0300 Subject: [PATCH 10/28] feat(webhooks): add 'webhooks update' command --- packages/cli-core/src/cli-program.ts | 37 +++++ .../cli-core/src/commands/webhooks/README.md | 16 +++ .../cli-core/src/commands/webhooks/get.ts | 22 +-- .../cli-core/src/commands/webhooks/index.ts | 2 + .../cli-core/src/commands/webhooks/shared.ts | 21 ++- .../src/commands/webhooks/update.test.ts | 130 ++++++++++++++++++ .../cli-core/src/commands/webhooks/update.ts | 63 +++++++++ 7 files changed, 270 insertions(+), 21 deletions(-) create mode 100644 packages/cli-core/src/commands/webhooks/update.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/update.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index fa8c63ca..a6224cf1 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -603,6 +603,43 @@ export function createProgram() { }), ); + webhooks + .command("update") + .description("Update a webhook endpoint's configuration") + .argument("", "Webhook endpoint ID (ep_...)") + .option("--url ", "New destination URL") + .option( + "--events ", + "Comma-separated event types to filter on (e.g. user.created,user.deleted)", + ) + .option("--description ", "New description") + .option("--channels ", "Comma-separated channels") + .option("--enable", "Re-enable a disabled endpoint") + .option("--disable", "Disable the endpoint") + .setExamples([ + { + command: "clerk webhooks update ep_2abc123 --url https://example.com/api/webhooks", + description: "Point the endpoint at a new URL", + }, + { + command: "clerk webhooks update ep_2abc123 --events user.created,user.deleted", + description: "Replace the event-type filter", + }, + { + command: "clerk webhooks update ep_2abc123 --enable", + description: "Re-enable an endpoint", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.update({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 245a81de..72e5d36e 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -103,3 +103,19 @@ clerk webhooks delete ep_2abc123 [--yes] | Method | Endpoint | Description | | -------- | ------------------------ | --------------------------------------- | | `DELETE` | `/webhooks/{endpointID}` | Delete the endpoint (returns `200 {}`). | + +## `clerk webhooks update ` + +Patches endpoint fields. Only the flags you pass are sent; everything else is omitted from the PATCH body. `--enable` maps to `{disabled: false}`, `--disable` to `{disabled: true}` (mutually exclusive; `--disabled` exists only on `create`). Passing no update flags is a usage error. + +```sh +clerk webhooks update ep_2abc123 [--url ...] [--events a,b] [--description ] [--channels a,b] [--enable | --disable] +``` + +Human mode prints the updated endpoint's details on stderr. JSON mode prints the updated endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------- | ------------------------ | ---------------------- | +| `PATCH` | `/webhooks/{endpointID}` | Patch endpoint fields. | diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts index 68686381..c364346a 100644 --- a/packages/cli-core/src/commands/webhooks/get.ts +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -1,8 +1,7 @@ -import { cyan, dim } from "../../lib/color.ts"; import { resolveAppContext } from "../../lib/config.ts"; -import { log } from "../../lib/log.ts"; -import { getWebhookEndpoint, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { getWebhookEndpoint } from "../../lib/plapi.ts"; import { + formatEndpointDetails, printJson, rejectEndpointNotFound, shouldOutputJson, @@ -13,23 +12,6 @@ export interface WebhooksGetOptions extends WebhooksGlobalOptions { endpointId: string; } -export function formatEndpointDetails(endpoint: WebhookEndpoint): void { - const rows: Array<[string, string]> = [ - ["ID", cyan(endpoint.id)], - ["URL", endpoint.url], - ["Status", endpoint.disabled ? "disabled" : "enabled"], - ["Description", endpoint.description || dim("(none)")], - ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], - ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], - ["Created", endpoint.created_at], - ["Updated", endpoint.updated_at], - ]; - const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; - for (const [label, value] of rows) { - log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); - } -} - export async function webhooksGet(options: WebhooksGetOptions): Promise { const ctx = await resolveAppContext(options); const endpoint = await rejectEndpointNotFound( diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 1f50699f..17141e66 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksSecret } from "./secret.ts"; +import { webhooksUpdate } from "./update.ts"; export const webhooks = { list: webhooksList, @@ -10,4 +11,5 @@ export const webhooks = { eventTypes: webhooksEventTypes, secret: webhooksSecret, delete: webhooksDelete, + update: webhooksUpdate, }; diff --git a/packages/cli-core/src/commands/webhooks/shared.ts b/packages/cli-core/src/commands/webhooks/shared.ts index 468914a4..881853f2 100644 --- a/packages/cli-core/src/commands/webhooks/shared.ts +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -1,3 +1,4 @@ +import { cyan, dim } from "../../lib/color.ts"; import { getRelayEntry } from "../../lib/config.ts"; import { CliError, @@ -7,7 +8,7 @@ import { throwUserAbort, } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import type { WebhookCursor } from "../../lib/plapi.ts"; +import type { WebhookCursor, WebhookEndpoint } from "../../lib/plapi.ts"; import { isAgent } from "../../mode.ts"; export interface WebhooksGlobalOptions { @@ -98,6 +99,24 @@ export async function resolveEndpointOrRelay( ); } +/** Labeled key/value detail rows for one endpoint, on stderr. */ +export function formatEndpointDetails(endpoint: WebhookEndpoint): void { + const rows: Array<[string, string]> = [ + ["ID", cyan(endpoint.id)], + ["URL", endpoint.url], + ["Status", endpoint.disabled ? "disabled" : "enabled"], + ["Description", endpoint.description || dim("(none)")], + ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], + ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], + ["Created", endpoint.created_at], + ["Updated", endpoint.updated_at], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; + for (const [label, value] of rows) { + log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); + } +} + /** Split a comma-separated flag value into trimmed, non-empty entries. */ export function splitCommaList(value: string | undefined): string[] | undefined { if (value === undefined) return undefined; diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts new file mode 100644 index 00000000..ef983d5d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -0,0 +1,130 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockUpdateWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksUpdate } = await import("./update.ts"); + +const updatedEndpoint = { + id: "ep_1", + url: "https://example.com/new", + version: 1, + description: "Updated", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks update", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockUpdateWebhookEndpoint.mockResolvedValue(updatedEndpoint); + }); + + afterEach(() => { + mockUpdateWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test.each([ + { + label: "--url", + options: { url: "https://example.com/new" }, + expected: { url: "https://example.com/new" }, + }, + { + label: "--description", + options: { description: "Updated" }, + expected: { description: "Updated" }, + }, + { + label: "--events (comma-separated)", + options: { events: "user.created, user.deleted" }, + expected: { filter_types: ["user.created", "user.deleted"] }, + }, + { + label: "--channels (comma-separated)", + options: { channels: "a,b" }, + expected: { channels: ["a", "b"] }, + }, + { label: "--enable", options: { enable: true }, expected: { disabled: false } }, + { label: "--disable", options: { disable: true }, expected: { disabled: true } }, + ])("$label maps to the PATCH body", async ({ options, expected }) => { + await webhooksUpdate({ endpointId: "ep_1", ...options }); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", expected); + }); + + test("omits disabled from the PATCH body when neither --enable nor --disable is set", async () => { + await webhooksUpdate({ endpointId: "ep_1", url: "https://example.com/new" }); + + const params = mockUpdateWebhookEndpoint.mock.calls[0]?.[3] as Record; + expect("disabled" in params).toBe(false); + }); + + test("--enable with --disable is a usage error", async () => { + await expect( + webhooksUpdate({ endpointId: "ep_1", enable: true, disable: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("no update flags at all is a usage error", async () => { + await expect(webhooksUpdate({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("prints the updated endpoint in human mode", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Updated webhook endpoint"); + expect(captured.err).toContain("https://example.com/new"); + }); + + test("outputs the updated endpoint resource as JSON with --json", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated", json: true }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + expect(captured.err).toBe(""); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockUpdateWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksUpdate({ endpointId: "ep_missing", description: "x" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/update.ts b/packages/cli-core/src/commands/webhooks/update.ts new file mode 100644 index 00000000..ba970c6c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.ts @@ -0,0 +1,63 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { updateWebhookEndpoint, type UpdateWebhookEndpointParams } from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksUpdateOptions extends WebhooksGlobalOptions { + endpointId: string; + url?: string; + events?: string; + description?: string; + channels?: string; + enable?: boolean; + disable?: boolean; +} + +export function buildUpdateParams(options: WebhooksUpdateOptions): UpdateWebhookEndpointParams { + if (options.enable && options.disable) { + throwUsageError("--enable and --disable are mutually exclusive."); + } + + const params: UpdateWebhookEndpointParams = {}; + if (options.url !== undefined) params.url = options.url; + if (options.description !== undefined) params.description = options.description; + const filterTypes = splitCommaList(options.events); + if (filterTypes !== undefined) params.filter_types = filterTypes; + const channels = splitCommaList(options.channels); + if (channels !== undefined) params.channels = channels; + if (options.enable) params.disabled = false; + if (options.disable) params.disabled = true; + + if (Object.keys(params).length === 0) { + throwUsageError( + "Nothing to update. Pass at least one of --url, --events, --description, --channels, --enable, or --disable.", + ); + } + return params; +} + +export async function webhooksUpdate(options: WebhooksUpdateOptions): Promise { + const params = buildUpdateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await rejectEndpointNotFound( + updateWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId, params), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + log.success(`Updated webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); +} From 0ebc40cd2a00ba11b01e29f8edb8b0fa58a709f5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:27:17 -0300 Subject: [PATCH 11/28] feat(webhooks): add 'webhooks create' command --- packages/cli-core/src/cli-program.ts | 32 +++++ .../cli-core/src/commands/webhooks/README.md | 19 +++ .../src/commands/webhooks/create.test.ts | 131 ++++++++++++++++++ .../cli-core/src/commands/webhooks/create.ts | 66 +++++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 250 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/create.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/create.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a6224cf1..555fc6c0 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -640,6 +640,38 @@ export function createProgram() { }), ); + webhooks + .command("create") + .description("Create a webhook endpoint and print its signing secret") + .option("--url ", "Destination URL (required)") + .option( + "--events ", + "Comma-separated event types to filter on (e.g. user.created,user.deleted)", + ) + .option("--description ", "Endpoint description") + .option("--channels ", "Comma-separated channels") + .option("--disabled", "Create the endpoint in a disabled state") + .setExamples([ + { + command: "clerk webhooks create --url https://example.com/api/webhooks", + description: "Create an endpoint receiving all events", + }, + { + command: + "clerk webhooks create --url https://example.com/api/webhooks --events user.created,user.deleted", + description: "Create an endpoint filtered to specific events", + }, + { + command: "clerk webhooks create --url https://example.com/api/webhooks --disabled", + description: "Create the endpoint disabled", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.create( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 72e5d36e..424b6eb3 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -119,3 +119,22 @@ Human mode prints the updated endpoint's details on stderr. JSON mode prints the | Method | Endpoint | Description | | ------- | ------------------------ | ---------------------- | | `PATCH` | `/webhooks/{endpointID}` | Patch endpoint fields. | + +## `clerk webhooks create` + +Creates an endpoint (always `version: 1`), then fetches and prints its signing secret. The backend lazily provisions the Svix app on the first create. Two network calls, client-orchestrated. + +```sh +clerk webhooks create --url [--events user.created,...] [--description ] [--channels a,b] [--disabled] +``` + +JSON mode emits the endpoint resource FLAT with one extra field: `signing_secret`. Human mode prints the details plus the unmasked secret on stderr. + +Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the command exits 1 with `Endpoint created (id: ep_...) but the signing secret could not be fetched. Run 'clerk webhooks secret ep_...' to retrieve it.` — no silent orphan. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------------------------------------- | +| `POST` | `/webhooks` | Create the endpoint (lazily provisions Svix app). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the new endpoint's signing secret. | diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts new file mode 100644 index 00000000..a9392c9a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -0,0 +1,131 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockCreateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksCreate } = await import("./create.ts"); + +const createdEndpoint = { + id: "ep_new", + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks create", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockCreateWebhookEndpoint.mockResolvedValue(createdEndpoint); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_new123" }); + }); + + afterEach(() => { + mockCreateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("missing --url is a usage error", async () => { + await expect(webhooksCreate({})).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("sends url and version 1 by default", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }); + }); + + test("maps optional flags to the create body", async () => { + await webhooksCreate({ + url: "https://example.com/webhooks", + events: "user.created, user.deleted", + description: "My endpoint", + channels: "a,b", + disabled: true, + }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created", "user.deleted"], + channels: ["a", "b"], + }); + }); + + test("fetches the signing secret after creating", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_new"); + }); + + test("emits the endpoint flat with signing_secret in JSON mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + expect(captured.err).toBe(""); + }); + + test("prints details and the unmasked secret in human mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Created webhook endpoint"); + expect(captured.err).toContain("ep_new"); + expect(captured.err).toContain("whsec_new123"); + }); + + test("partial failure: secret fetch error exits 1 with the recovery command", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(500, "{}")); + + const promise = webhooksCreate({ url: "https://example.com/webhooks" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(webhooksCreate({ url: "https://example.com/webhooks" })).rejects.toThrow( + "Endpoint created (id: ep_new) but the signing secret could not be fetched. " + + "Run 'clerk webhooks secret ep_new' to retrieve it.", + ); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/create.ts b/packages/cli-core/src/commands/webhooks/create.ts new file mode 100644 index 00000000..8ab4ceac --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.ts @@ -0,0 +1,66 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpointSecret, + type CreateWebhookEndpointParams, +} from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksCreateOptions extends WebhooksGlobalOptions { + url?: string; + events?: string; + description?: string; + channels?: string; + disabled?: boolean; +} + +function buildCreateParams(options: WebhooksCreateOptions): CreateWebhookEndpointParams { + if (!options.url) { + throwUsageError("Missing required --url ."); + } + + const params: CreateWebhookEndpointParams = { url: options.url, version: 1 }; + if (options.description !== undefined) params.description = options.description; + if (options.disabled) params.disabled = true; + const filterTypes = splitCommaList(options.events); + if (filterTypes?.length) params.filter_types = filterTypes; + const channels = splitCommaList(options.channels); + if (channels?.length) params.channels = channels; + return params; +} + +export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promise { + const params = buildCreateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await createWebhookEndpoint(ctx.appId, ctx.instanceId, params); + + let secret: string; + try { + ({ secret } = await getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id)); + } catch { + // Create is atomic; the secret fetch is a second call. Never leave a + // silent orphan — surface the new ID and the exact recovery command. + throw new CliError( + `Endpoint created (id: ${endpoint.id}) but the signing secret could not be fetched. ` + + `Run 'clerk webhooks secret ${endpoint.id}' to retrieve it.`, + ); + } + + if (shouldOutputJson(options)) { + printJson({ ...endpoint, signing_secret: secret }); + return; + } + + log.success(`Created webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); + log.info(`Signing secret: ${secret}`); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 17141e66..7c234700 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,3 +1,4 @@ +import { webhooksCreate } from "./create.ts"; import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; @@ -12,4 +13,5 @@ export const webhooks = { secret: webhooksSecret, delete: webhooksDelete, update: webhooksUpdate, + create: webhooksCreate, }; From 356135fe4917b7f22109b52a08221d1238843aab Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:28:43 -0300 Subject: [PATCH 12/28] feat(webhooks): add 'webhooks messages' command --- packages/cli-core/src/cli-program.ts | 35 ++++ .../cli-core/src/commands/webhooks/README.md | 16 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/messages.test.ts | 169 ++++++++++++++++++ .../src/commands/webhooks/messages.ts | 81 +++++++++ 5 files changed, 303 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/messages.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/messages.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 555fc6c0..0698882d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -672,6 +672,41 @@ export function createProgram() { ), ); + webhooks + .command("messages") + .description("List recent deliveries for an endpoint (the feed for `webhooks replay`)") + .option( + "--endpoint ", + "Endpoint to inspect (defaults to this instance's relay endpoint from `webhooks listen`)", + ) + .addOption( + createOption("--status ", "Filter by delivery status").choices([ + "success", + "pending", + "fail", + "sending", + ]), + ) + .option("--limit ", "Maximum deliveries to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { + command: "clerk webhooks messages --endpoint ep_2abc123", + description: "List recent deliveries for an endpoint", + }, + { + command: "clerk webhooks messages --status fail", + description: "List failed deliveries on the relay endpoint", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.messages( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 424b6eb3..ea32629e 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -138,3 +138,19 @@ Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the co | ------ | ------------------------------- | ------------------------------------------------- | | `POST` | `/webhooks` | Create the endpoint (lazily provisions Svix app). | | `GET` | `/webhooks/{endpointID}/secret` | Fetch the new endpoint's signing secret. | + +## `clerk webhooks messages` + +Lists recent deliveries (msg IDs, event type, status, full payload) for an endpoint — the discovery feed for `replay `. `--endpoint` defaults to the instance's persisted relay endpoint; without either, it's a usage error. + +```sh +clerk webhooks messages [--endpoint ] [--status success|pending|fail|sending] [--limit N] [--iterator C] +``` + +Human mode prints an `ID / EVENT TYPE / STATUS / CREATED` table on stderr (payloads only in JSON mode). JSON mode prints the full `{ data, cursor }` response, payloads included. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------------------------- | ------------------------------------------------------ | +| `GET` | `/webhooks/{endpointID}/messages` | List attempted deliveries (one page, optional status). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 7c234700..bcde4137 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksMessages } from "./messages.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksUpdate } from "./update.ts"; @@ -14,4 +15,5 @@ export const webhooks = { delete: webhooksDelete, update: webhooksUpdate, create: webhooksCreate, + messages: webhooksMessages, }; diff --git a/packages/cli-core/src/commands/webhooks/messages.test.ts b/packages/cli-core/src/commands/webhooks/messages.test.ts new file mode 100644 index 00000000..d250c68c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.test.ts @@ -0,0 +1,169 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookMessages: (...args: unknown[]) => mockListWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksMessages } = await import("./messages.ts"); + +const mockMessages = [ + { + id: "msg_1", + event_type: "user.created", + status: "success", + next_attempt: null, + payload: { object: "event" }, + created_at: "2026-06-09T12:00:00Z", + }, + { + id: "msg_2", + event_type: "user.deleted", + status: "fail", + next_attempt: "2026-06-09T12:05:00Z", + payload: { object: "event" }, + created_at: "2026-06-09T12:01:00Z", + }, +]; + +function messagesResponse(hasNextPage = false) { + return { + data: mockMessages, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks messages", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookMessages.mockResolvedValue(messagesResponse()); + }); + + afterEach(() => { + mockListWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("lists deliveries for an explicit --endpoint", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 100, + iterator: undefined, + status: undefined, + }); + }); + + test("defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksMessages(); + + expect(mockGetRelayEntry).toHaveBeenCalledWith("ins_1"); + expect(mockListWebhookMessages).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + expect.anything(), + ); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + await expect(webhooksMessages()).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + message: + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + }); + expect(mockListWebhookMessages).not.toHaveBeenCalled(); + }); + + test("forwards --status, --limit, and --iterator", async () => { + await webhooksMessages({ endpoint: "ep_1", status: "fail", limit: 10, iterator: "iter_x" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 10, + iterator: "iter_x", + status: "fail", + }); + }); + + test("prints a delivery table in human mode", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("msg_1"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("fail"); + expect(captured.err).toContain("2 deliveries returned"); + }); + + test("warns when the endpoint has no deliveries", async () => { + mockListWebhookMessages.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("No deliveries found"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookMessages.mockResolvedValue(messagesResponse(true)); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("outputs the full response (including payloads) as JSON with --json", async () => { + await webhooksMessages({ endpoint: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockListWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksMessages({ endpoint: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/messages.ts b/packages/cli-core/src/commands/webhooks/messages.ts new file mode 100644 index 00000000..ab07ad93 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.ts @@ -0,0 +1,81 @@ +import { cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { + listWebhookMessages, + type WebhookMessage, + type WebhookMessageStatus, +} from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + rejectEndpointNotFound, + resolveEndpointOrRelay, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksMessagesOptions extends WebhooksGlobalOptions { + endpoint?: string; + status?: WebhookMessageStatus; + limit?: number; + iterator?: string; +} + +// Pad before coloring so ANSI codes don't skew the column width. +function paddedStatus(status: WebhookMessage["status"], width: number): string { + const padded = status.padEnd(width); + switch (status) { + case "success": + return green(padded); + case "fail": + return red(padded); + default: + return yellow(padded); + } +} + +function formatMessagesTable(messages: WebhookMessage[]): void { + const idWidth = Math.max("ID".length, ...messages.map((m) => m.id.length)) + 2; + const eventWidth = Math.max("EVENT TYPE".length, ...messages.map((m) => m.event_type.length)) + 2; + const statusWidth = Math.max("STATUS".length, ...messages.map((m) => m.status.length)) + 2; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("EVENT TYPE".padEnd(eventWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("CREATED")}`, + ); + for (const message of messages) { + log.info( + `${cyan(message.id.padEnd(idWidth))}${message.event_type.padEnd(eventWidth)}${paddedStatus(message.status, statusWidth)}${message.created_at}`, + ); + } +} + +export async function webhooksMessages(options: WebhooksMessagesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + const response = await rejectEndpointNotFound( + listWebhookMessages(ctx.appId, ctx.instanceId, endpointId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + status: options.status, + }), + endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn(`No deliveries found for \`${endpointId}\`.`); + return; + } + + formatMessagesTable(response.data); + const count = response.data.length; + log.info(`\n${count} deliver${count === 1 ? "y" : "ies"} returned for \`${endpointId}\``); + printIteratorHint(response.cursor); +} From 371bf455a898c34b1b7b16a6230b057193dd7bca Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:30:03 -0300 Subject: [PATCH 13/28] feat(webhooks): add 'webhooks replay' command --- packages/cli-core/src/cli-program.ts | 33 ++++ .../cli-core/src/commands/webhooks/README.md | 20 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/replay.test.ts | 169 ++++++++++++++++++ .../cli-core/src/commands/webhooks/replay.ts | 78 ++++++++ 5 files changed, 302 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/replay.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/replay.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 0698882d..d52ee311 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -707,6 +707,39 @@ export function createProgram() { ), ); + webhooks + .command("replay") + .description("Resend one delivery, or bulk-recover a time window of deliveries") + .argument("[msg_id]", "Message ID to resend (mutually exclusive with --since)") + .option( + "--endpoint ", + "Target endpoint (defaults to the relay endpoint for ; required with --since)", + ) + .option("--since ", "Bulk-recover deliveries from this RFC 3339 timestamp") + .option("--until ", "Optional end of the recovery window (requires --since)") + .option("--yes", "Skip the bulk-recovery confirmation prompt (required in agent mode)") + .setExamples([ + { + command: "clerk webhooks replay msg_2xyz", + description: "Resend one delivery to the relay endpoint", + }, + { + command: "clerk webhooks replay msg_2xyz --endpoint ep_2abc123", + description: "Resend one delivery to a specific endpoint", + }, + { + command: + "clerk webhooks replay --since 2026-05-01T00:00:00Z --until 2026-05-01T01:00:00Z --endpoint ep_2abc123", + description: "Recover all deliveries in a bounded window", + }, + ]) + .action((msgId, _opts, cmd) => + webhooksHandlers.replay({ + ...(cmd.optsWithGlobals() as Omit[0], "msgId">), + msgId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index ea32629e..edc0d389 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -154,3 +154,23 @@ Human mode prints an `ID / EVENT TYPE / STATUS / CREATED` table on stderr (paylo | Method | Endpoint | Description | | ------ | --------------------------------- | ------------------------------------------------------ | | `GET` | `/webhooks/{endpointID}/messages` | List attempted deliveries (one page, optional status). | + +## `clerk webhooks replay` + +Dual-mode: + +- `replay ` resends one delivery (same `svix-id`). `--endpoint` defaults to the relay endpoint. No prompt — a single targeted resend is not destructive. +- `replay --since [--until ]` bulk-recovers failed deliveries in a window. `--endpoint` is **required** (bulk recovery never guesses), and it prompts unless `--yes` (agent mode requires `--yes`). + +`` and `--since` are mutually exclusive; passing both or neither is a usage error, as is `--until` without `--since`. Both operations are async on the Svix side — success means queued (`200 {}`), stdout stays empty. + +```sh +clerk webhooks replay [] [--endpoint ] [--since [--until ]] [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ---------------------------------------------------- | ------------------------------------------------------------ | +| `POST` | `/webhooks/{endpointID}/messages/{messageID}/resend` | Resend one delivery (`` mode). | +| `POST` | `/webhooks/{endpointID}/recover` | Recover a window: body `{ since, until? }` (`--since` mode). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index bcde4137..28bb5d64 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -4,6 +4,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; +import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksUpdate } from "./update.ts"; @@ -16,4 +17,5 @@ export const webhooks = { update: webhooksUpdate, create: webhooksCreate, messages: webhooksMessages, + replay: webhooksReplay, }; diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts new file mode 100644 index 00000000..06bcf1f6 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -0,0 +1,169 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockResendWebhookMessage = mock(); +const mockRecoverWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + resendWebhookMessage: (...args: unknown[]) => mockResendWebhookMessage(...args), + recoverWebhookMessages: (...args: unknown[]) => mockRecoverWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksReplay } = await import("./replay.ts"); + +describe("webhooks replay", () => { + useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockResendWebhookMessage.mockResolvedValue(undefined); + mockRecoverWebhookMessages.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockResendWebhookMessage.mockReset(); + mockRecoverWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test.each([ + { + label: "both and --since", + options: { msgId: "msg_1", since: "2026-05-01T00:00:00Z" }, + }, + { label: "neither nor --since", options: {} }, + { + label: "--until without --since", + options: { msgId: "msg_1", until: "2026-05-01T00:00:00Z" }, + }, + { + label: "--since without --endpoint", + options: { since: "2026-05-01T00:00:00Z" }, + }, + { + label: "invalid --since timestamp", + options: { since: "not-a-date", endpoint: "ep_1" }, + }, + { + label: "invalid --until timestamp", + options: { since: "2026-05-01T00:00:00Z", until: "nope", endpoint: "ep_1" }, + }, + ])("$label is a usage error", async ({ options }) => { + await expect(webhooksReplay(options)).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResendWebhookMessage).not.toHaveBeenCalled(); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("resends one message to an explicit --endpoint without prompting", async () => { + await webhooksReplay({ msgId: "msg_1", endpoint: "ep_1" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "msg_1"); + }); + + test("resend defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksReplay({ msgId: "msg_1" }); + + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", "msg_1"); + }); + + test("resend without --endpoint or a relay endpoint is a usage error", async () => { + await expect(webhooksReplay({ msgId: "msg_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("resend maps a PLAPI 404 to webhook_message_not_found", async () => { + mockResendWebhookMessage.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksReplay({ msgId: "msg_gone", endpoint: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, + message: "No webhook message with ID msg_gone was found.", + }); + }); + + test("--since prompts, then recovers the window", async () => { + await webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: undefined, + }); + }); + + test("--since --until bounds the recovery window", async () => { + await webhooksReplay({ + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + endpoint: "ep_1", + yes: true, + }); + + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + }); + + test("--since aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toBeInstanceOf(UserAbortError); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--since in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--since maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockRecoverWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_missing", yes: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts new file mode 100644 index 00000000..a4538f6c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -0,0 +1,78 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { recoverWebhookMessages, resendWebhookMessage } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + rejectMessageNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksReplayOptions extends WebhooksGlobalOptions { + msgId?: string; + endpoint?: string; + since?: string; + until?: string; + yes?: boolean; +} + +function assertRfc3339(value: string, flag: string): void { + if (Number.isNaN(Date.parse(value))) { + throwUsageError(`Invalid ${flag} value "${value}". Must be an RFC 3339 timestamp.`); + } +} + +function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover" { + if (options.msgId && options.since) { + throwUsageError("Pass either a or --since, not both."); + } + if (!options.msgId && !options.since) { + throwUsageError("Pass a to resend one delivery, or --since to bulk-recover."); + } + if (options.until && !options.since) { + throwUsageError("--until requires --since."); + } + if (options.since) { + assertRfc3339(options.since, "--since"); + if (options.until) assertRfc3339(options.until, "--until"); + if (!options.endpoint) { + throwUsageError("--endpoint is required with --since. Bulk recovery never guesses a target."); + } + return "recover"; + } + return "resend"; +} + +export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promise { + const mode = validateReplayMode(options); + const ctx = await resolveAppContext(options); + + if (mode === "resend") { + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + await rejectMessageNotFound( + resendWebhookMessage(ctx.appId, ctx.instanceId, endpointId, options.msgId!), + options.msgId!, + ); + log.success(`Queued replay of \`${options.msgId}\` to \`${endpointId}\``); + return; + } + + const windowLabel = options.until + ? `between ${options.since} and ${options.until}` + : `since ${options.since}`; + await confirmDestructive( + `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, + options, + ); + + await rejectEndpointNotFound( + recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { + since: options.since!, + until: options.until, + }), + options.endpoint!, + ); + log.success(`Queued recovery of deliveries to \`${options.endpoint}\` ${windowLabel}`); +} From ce856695fd52ad1cdbf6566096995c4a6f81e729 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:31:21 -0300 Subject: [PATCH 14/28] feat(webhooks): add 'webhooks trigger' command --- packages/cli-core/src/cli-program.ts | 28 ++++ .../cli-core/src/commands/webhooks/README.md | 15 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/trigger.test.ts | 126 ++++++++++++++++++ .../cli-core/src/commands/webhooks/trigger.ts | 55 ++++++++ 5 files changed, 226 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/trigger.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/trigger.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index d52ee311..642544d5 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -740,6 +740,34 @@ export function createProgram() { }), ); + webhooks + .command("trigger") + .description("Send an example event to an endpoint (validates the type first)") + .argument("", "Event type to trigger (e.g. user.created)") + .option( + "--endpoint ", + "Target endpoint (defaults to this instance's relay endpoint from `webhooks listen`)", + ) + .setExamples([ + { + command: "clerk webhooks trigger user.created", + description: "Send an example user.created event to the relay endpoint", + }, + { + command: "clerk webhooks trigger user.created --endpoint ep_2abc123", + description: "Send an example event to a specific endpoint", + }, + ]) + .action((eventType, _opts, cmd) => + webhooksHandlers.trigger({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "eventType" + >), + eventType, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index edc0d389..933bec80 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -174,3 +174,18 @@ clerk webhooks replay [] [--endpoint ] [--since [--until ` mode). | | `POST` | `/webhooks/{endpointID}/recover` | Recover a window: body `{ since, until? }` (`--since` mode). | + +## `clerk webhooks trigger ` + +Sends an example event of the given type. Because `send_example` returns `200 {}` asynchronously, the CLI first validates the type against the event-type catalog (paging through it) and fails fast with error code `unknown_event_type` — otherwise an invalid type would exit 0 and deliver nothing. `--endpoint` defaults to the relay endpoint. + +```sh +clerk webhooks trigger user.created [--endpoint ] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------------- | ------------------------------------------------ | +| `GET` | `/webhooks/event_types` | Validate the event type against the catalog. | +| `POST` | `/webhooks/{endpointID}/send_example` | Send the example event: body `{ "event_type" }`. | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 28bb5d64..9cf14866 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -6,6 +6,7 @@ import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; +import { webhooksTrigger } from "./trigger.ts"; import { webhooksUpdate } from "./update.ts"; export const webhooks = { @@ -18,4 +19,5 @@ export const webhooks = { create: webhooksCreate, messages: webhooksMessages, replay: webhooksReplay, + trigger: webhooksTrigger, }; diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts new file mode 100644 index 00000000..72c074ac --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -0,0 +1,126 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +const mockSendWebhookExample = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), + sendWebhookExample: (...args: unknown[]) => mockSendWebhookExample(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksTrigger } = await import("./trigger.ts"); + +function catalogPage(names: string[], hasNextPage = false, startingAfter: string | null = null) { + return { + data: names.map((name) => ({ + name, + description: "", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + })), + cursor: { starting_after: startingAfter, ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks trigger", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(catalogPage(["user.created", "user.deleted"])); + mockSendWebhookExample.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockSendWebhookExample.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("validates the event type, then sends the example", async () => { + await webhooksTrigger({ eventType: "user.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 250, + iterator: undefined, + }); + expect(mockSendWebhookExample).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + "user.created", + ); + expect(captured.err).toContain("delivery is async"); + }); + + test("uses an explicit --endpoint over the relay default", async () => { + await webhooksTrigger({ eventType: "user.created", endpoint: "ep_1" }); + + expect(mockSendWebhookExample).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "user.created"); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.created" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("unknown event type fails fast with unknown_event_type", async () => { + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("pages through the catalog before declaring a type unknown", async () => { + mockListWebhookEventTypes + .mockResolvedValueOnce(catalogPage(["user.created"], true, "iter_2")) + .mockResolvedValueOnce(catalogPage(["organization.created"])); + + await webhooksTrigger({ eventType: "organization.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledTimes(2); + expect(mockListWebhookEventTypes).toHaveBeenLastCalledWith("app_1", "ins_1", { + limit: 250, + iterator: "iter_2", + }); + expect(mockSendWebhookExample).toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 on send to webhook_endpoint_not_found", async () => { + mockSendWebhookExample.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksTrigger({ eventType: "user.created", endpoint: "ep_missing" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts new file mode 100644 index 00000000..3359254c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -0,0 +1,55 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, sendWebhookExample } from "../../lib/plapi.ts"; +import { + rejectEndpointNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksTriggerOptions extends WebhooksGlobalOptions { + eventType: string; + endpoint?: string; +} + +const CATALOG_PAGE_LIMIT = 250; + +async function assertKnownEventType( + appId: string, + instanceId: string, + eventType: string, +): Promise { + let iterator: string | undefined; + do { + const page = await listWebhookEventTypes(appId, instanceId, { + limit: CATALOG_PAGE_LIMIT, + iterator, + }); + if (page.data.some((entry) => entry.name === eventType)) return; + iterator = page.cursor.has_next_page ? (page.cursor.starting_after ?? undefined) : undefined; + } while (iterator); + + throw new CliError( + `Unknown event type "${eventType}". Run \`clerk webhooks event-types\` to list available types.`, + { code: ERROR_CODE.UNKNOWN_EVENT_TYPE }, + ); +} + +export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise { + const ctx = await resolveAppContext(options); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + // send_example returns 200 {} asynchronously — an invalid event type would + // otherwise exit 0 and deliver nothing, the silent failure trigger exists to kill. + await assertKnownEventType(ctx.appId, ctx.instanceId, options.eventType); + + await rejectEndpointNotFound( + sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), + endpointId, + ); + + log.success( + `Sent example \`${options.eventType}\` event to \`${endpointId}\` (delivery is async)`, + ); +} From 70aaceb07e4a8166735f2f073371fdde16951ebf Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:32:15 -0300 Subject: [PATCH 15/28] feat(webhooks): add 'webhooks open' command --- packages/cli-core/src/cli-program.ts | 11 +++ .../cli-core/src/commands/webhooks/README.md | 14 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/open.test.ts | 87 +++++++++++++++++++ .../cli-core/src/commands/webhooks/open.ts | 32 +++++++ 5 files changed, 146 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/open.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/open.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 642544d5..6cf77322 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -768,6 +768,17 @@ export function createProgram() { }), ); + webhooks + .command("open") + .description("Open the instance's webhook portal in your browser") + .setExamples([ + { command: "clerk webhooks open", description: "Open the webhook portal" }, + { command: "clerk webhooks open --json", description: "Print the portal URL as JSON" }, + ]) + .action((_opts, cmd) => + webhooksHandlers.open(cmd.optsWithGlobals() as Parameters[0]), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 933bec80..33c30152 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -189,3 +189,17 @@ clerk webhooks trigger user.created [--endpoint ] | ------ | ------------------------------------- | ------------------------------------------------ | | `GET` | `/webhooks/event_types` | Validate the event type against the catalog. | | `POST` | `/webhooks/{endpointID}/send_example` | Send the example event: body `{ "event_type" }`. | + +## `clerk webhooks open` + +Fetches a single-use Svix portal URL and opens it in the browser via `openBrowser()` (which never throws — on failure the URL is printed as a fallback). JSON/agent mode prints `{ "url": "..." }` and does not launch a browser. Backed by the Svix `DashboardAccess` API in v0.64.1; switch to `AppPortalAccess` on SDK upgrade. + +```sh +clerk webhooks open +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------- | ----------------------------------------- | +| `POST` | `/webhooks/url` | Fetch the portal URL (request body `{}`). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 9cf14866..3da7671e 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -4,6 +4,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; +import { webhooksOpen } from "./open.ts"; import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksTrigger } from "./trigger.ts"; @@ -20,4 +21,5 @@ export const webhooks = { messages: webhooksMessages, replay: webhooksReplay, trigger: webhooksTrigger, + open: webhooksOpen, }; diff --git a/packages/cli-core/src/commands/webhooks/open.test.ts b/packages/cli-core/src/commands/webhooks/open.test.ts new file mode 100644 index 00000000..1432e315 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.test.ts @@ -0,0 +1,87 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookPortalUrl = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookPortalUrl: (...args: unknown[]) => mockGetWebhookPortalUrl(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const { webhooksOpen } = await import("./open.ts"); + +const PORTAL_URL = "https://app.svix.com/login#key=abc"; + +describe("webhooks open", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookPortalUrl.mockResolvedValue({ url: PORTAL_URL }); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + }); + + afterEach(() => { + mockGetWebhookPortalUrl.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockOpenBrowser.mockReset(); + }); + + test("fetches the portal URL and opens the browser in human mode", async () => { + await webhooksOpen(); + + expect(mockGetWebhookPortalUrl).toHaveBeenCalledWith("app_1", "ins_1"); + expect(mockOpenBrowser).toHaveBeenCalledWith(PORTAL_URL); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Opening the webhook portal"); + }); + + test("prints a fallback URL when the browser cannot be opened", async () => { + mockOpenBrowser.mockResolvedValue({ ok: false, reason: "no-launcher" }); + + await webhooksOpen(); + + expect(captured.err).toContain("Could not open your browser automatically"); + expect(captured.err).toContain(PORTAL_URL); + }); + + test("outputs { url } without launching a browser with --json", async () => { + await webhooksOpen({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("outputs { url } without launching a browser in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksOpen(); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/open.ts b/packages/cli-core/src/commands/webhooks/open.ts new file mode 100644 index 00000000..58518fd7 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.ts @@ -0,0 +1,32 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { getWebhookPortalUrl } from "../../lib/plapi.ts"; +import { printJson, shouldOutputJson, type WebhooksGlobalOptions } from "./shared.ts"; + +export type WebhooksOpenOptions = WebhooksGlobalOptions; + +export async function webhooksOpen(options: WebhooksOpenOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const { url } = await withApiContext( + getWebhookPortalUrl(ctx.appId, ctx.instanceId), + "Failed to fetch the webhook portal URL", + ); + + if (shouldOutputJson(options)) { + printJson({ url }); + return; + } + + log.info(`↗ Opening the webhook portal for \`${ctx.appLabel}\` (${ctx.instanceLabel})`); + log.info(` ${dim(url)}`); + + 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})`)}`, + ); + } +} From 9b736998017663261d5bf8b5b871d6239cdc737a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:35:30 -0300 Subject: [PATCH 16/28] feat(webhooks): add offline 'webhooks verify' command --- packages/cli-core/src/cli-program.ts | 29 ++ .../cli-core/src/commands/webhooks/README.md | 23 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/verify.test.ts | 248 ++++++++++++++++++ .../cli-core/src/commands/webhooks/verify.ts | 186 +++++++++++++ packages/cli-core/undefined/body.json | 1 + 6 files changed, 489 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/verify.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/verify.ts create mode 100644 packages/cli-core/undefined/body.json diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 6cf77322..27e5731b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -779,6 +779,35 @@ export function createProgram() { webhooksHandlers.open(cmd.optsWithGlobals() as Parameters[0]), ); + webhooks + .command("verify") + .description("Verify a webhook signature locally (offline, no auth required)") + .option("--secret ", "Signing secret (whsec_...), always required") + .option( + "--delivery ", + "One `listen` event NDJSON line as @file or - for stdin (alternative to the four explicit flags)", + ) + .option("--payload ", "Raw request body as @file or - for stdin") + .option("--id ", "The svix-id header value") + .option("--timestamp ", "The svix-timestamp header value (Unix epoch seconds)") + .option("--signature ", "The raw svix-signature header value (may hold multiple entries)") + .setExamples([ + { + command: + "clerk webhooks verify --secret whsec_... --payload @body.json --id msg_2xyz --timestamp 1717935000 --signature v1,abc...", + description: "Verify from the four header values", + }, + { + command: "clerk webhooks verify --secret whsec_... --delivery @event.json", + description: "Verify a saved `listen` event line", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.verify( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 33c30152..d4cb1850 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -203,3 +203,26 @@ clerk webhooks open | Method | Endpoint | Description | | ------ | --------------- | ----------------------------------------- | | `POST` | `/webhooks/url` | Fetch the portal URL (request body `{}`). | + +## `clerk webhooks verify` + +Verifies a Svix webhook signature **locally**: HMAC-SHA256 over `{id}.{timestamp}.{body}` with the base64-decoded `whsec_` suffix, constant-time compare, any-match across space-separated `v1,` header entries (rotation grace windows produce multiple entries). No network calls, no auth gate (`--app`/`--instance` are ignored). Exit 0 = signature matched; exit 1 = mismatch (with a humanized timestamp-skew hint when the timestamp is >5 minutes off); exit 2 = bad inputs. + +```sh +clerk webhooks verify --secret whsec_... (--delivery @event.json | --payload @body.json --id msg_... --timestamp --signature v1,...) +``` + +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------- | +| `--secret ` | Always required. A flag, never a positional — secrets must not land in argv positionals. | +| `--delivery ` | One `listen` event NDJSON line (`@file` or `-`); supplies `id`, `timestamp`, `signature`, and the body. | +| `--payload ` | Raw body as `@file` or `-` (bare inline JSON rejected; shells mangle it). | +| `--id ` | The `svix-id` header (first HMAC pre-image segment). | +| `--timestamp ` | The `svix-timestamp` header — Unix epoch seconds, integer. | +| `--signature ` | The raw `svix-signature` header value; may carry multiple space-separated `v1,` entries (any-match). | + +Explicit flags override fields parsed from `--delivery`. A `listen` event line saved to a file is directly consumable here. + +### API endpoints + +None — pure offline computation. diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 3da7671e..41b8206e 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -9,6 +9,7 @@ import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksTrigger } from "./trigger.ts"; import { webhooksUpdate } from "./update.ts"; +import { webhooksVerify } from "./verify.ts"; export const webhooks = { list: webhooksList, @@ -22,4 +23,5 @@ export const webhooks = { replay: webhooksReplay, trigger: webhooksTrigger, open: webhooksOpen, + verify: webhooksVerify, }; diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts new file mode 100644 index 00000000..f5c1dde9 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -0,0 +1,248 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createHmac, randomBytes } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { + decodeWebhookSecret, + parseDeliveryLine, + verifyWebhookSignature, + webhooksVerify, +} from "./verify.ts"; + +const KEY = randomBytes(24); +const SECRET = `whsec_${KEY.toString("base64")}`; +const ID = "msg_2xyz"; +const TIMESTAMP = String(Math.floor(Date.now() / 1000)); +const PAYLOAD = '{"object":"event","type":"user.created"}'; + +function sign(id: string, timestamp: string, payload: string, key: Buffer = KEY): string { + return createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`, "utf8").digest("base64"); +} + +const VALID_SIGNATURE = `v1,${sign(ID, TIMESTAMP, PAYLOAD)}`; + +describe("decodeWebhookSecret", () => { + test.each([ + { label: "valid whsec_ secret", secret: SECRET, expected: true }, + { label: "missing whsec_ prefix", secret: KEY.toString("base64"), expected: false }, + { label: "empty suffix", secret: "whsec_", expected: false }, + { label: "empty string", secret: "", expected: false }, + ])("$label", ({ secret, expected }) => { + const key = decodeWebhookSecret(secret); + expect(key !== null).toBe(expected); + if (key) expect(key.equals(KEY)).toBe(true); + }); +}); + +describe("verifyWebhookSignature", () => { + const base = { secret: SECRET, id: ID, timestamp: TIMESTAMP, payload: PAYLOAD }; + + test("accepts a valid single signature", () => { + expect(verifyWebhookSignature({ ...base, signature: VALID_SIGNATURE })).toBe(true); + }); + + test("accepts when any space-separated entry matches (rotation grace window)", () => { + const oldKey = randomBytes(24); + const staleEntry = `v1,${sign(ID, TIMESTAMP, PAYLOAD, oldKey)}`; + expect(verifyWebhookSignature({ ...base, signature: `${staleEntry} ${VALID_SIGNATURE}` })).toBe( + true, + ); + }); + + test.each([ + { label: "tampered body", input: { ...base, payload: PAYLOAD + " " } }, + { label: "wrong timestamp", input: { ...base, timestamp: String(Number(TIMESTAMP) + 1) } }, + { label: "wrong id", input: { ...base, id: "msg_other" } }, + { + label: "wrong secret", + input: { ...base, secret: `whsec_${randomBytes(24).toString("base64")}` }, + }, + ])("rejects $label", ({ input }) => { + expect(verifyWebhookSignature({ ...input, signature: VALID_SIGNATURE })).toBe(false); + }); + + test.each([ + { label: "non-v1 version entries", signature: `v1a,${sign(ID, TIMESTAMP, PAYLOAD)}` }, + { label: "entry without a comma", signature: "v1" }, + { label: "empty header", signature: "" }, + { label: "whitespace-only header", signature: " " }, + { label: "truncated base64 signature", signature: "v1,AAAA" }, + { label: "garbage entry", signature: "v1,!!!not-base64!!!" }, + ])("rejects $label without crashing", ({ signature }) => { + expect(verifyWebhookSignature({ ...base, signature })).toBe(false); + }); + + test("rejects everything when the secret is malformed", () => { + expect( + verifyWebhookSignature({ ...base, secret: "not-a-secret", signature: VALID_SIGNATURE }), + ).toBe(false); + }); +}); + +describe("parseDeliveryLine", () => { + test("extracts the four fields from a listen event line", () => { + const line = JSON.stringify({ + type: "event", + svix_id: ID, + event_type: "user.created", + headers: { + "svix-id": ID, + "svix-timestamp": TIMESTAMP, + "svix-signature": VALID_SIGNATURE, + }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + forward_status: 200, + latency_ms: 12, + }); + + expect(parseDeliveryLine(line)).toEqual({ + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, + payload: PAYLOAD, + }); + }); + + test.each([ + { label: "invalid JSON", raw: "{nope" }, + { label: "non-object JSON", raw: '"hello"' }, + ])("throws a usage error on $label", ({ raw }) => { + expect(() => parseDeliveryLine(raw)).toThrow(CliError); + }); + + test("returns undefined fields when headers are missing", () => { + expect(parseDeliveryLine("{}")).toEqual({ + id: undefined, + timestamp: undefined, + signature: undefined, + }); + }); +}); + +describe("webhooks verify command", () => { + const captured = useCaptureLog(); + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-verify-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeTempFile(name: string, content: string): Promise { + const path = join(tempDir, name); + await writeFile(path, content); + return path; + } + + const explicitFlags = () => ({ + secret: SECRET, + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, + }); + + test("verifies with explicit flags and a payload file", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }); + + expect(captured.err).toContain("Signature verified."); + expect(captured.out).toBe(""); + }); + + test("verifies from a --delivery event file alone", async () => { + const line = JSON.stringify({ + headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const deliveryPath = await writeTempFile("event.json", `${line}\n`); + + await webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}` }); + + expect(captured.err).toContain("Signature verified."); + }); + + test("explicit flags override --delivery fields", async () => { + const line = JSON.stringify({ + headers: { + "svix-id": "msg_other", + "svix-timestamp": TIMESTAMP, + "svix-signature": VALID_SIGNATURE, + }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const deliveryPath = await writeTempFile("event.json", line); + + // The file's svix-id would fail; the explicit --id matching the signature wins. + await webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}`, id: ID }); + + expect(captured.err).toContain("Signature verified."); + }); + + test("fails with exit 1 on a signature mismatch", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD + "tampered"); + + await expect( + webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }), + ).rejects.toThrow("Signature verification failed"); + }); + + test("mismatch on a stale timestamp includes a humanized skew hint", async () => { + const staleTimestamp = String(Number(TIMESTAMP) - 3600); + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await expect( + webhooksVerify({ ...explicitFlags(), timestamp: staleTimestamp, payload: `@${payloadPath}` }), + ).rejects.toThrow("in the past"); + }); + + test.each([ + { label: "missing --secret", options: {} }, + { label: "malformed --secret", options: { secret: "sk_nope" } }, + { + label: "missing inputs (no --delivery, incomplete flags)", + options: { secret: SECRET, id: ID }, + }, + { + label: "non-integer --timestamp", + options: { + secret: SECRET, + id: ID, + timestamp: "2026-06-09T12:00:00Z", + signature: VALID_SIGNATURE, + payload: "-", + }, + }, + { + label: "inline --payload (not @file or -)", + options: { + ...{ secret: SECRET, id: ID, timestamp: TIMESTAMP, signature: VALID_SIGNATURE }, + payload: "{}", + }, + }, + ])("$label is a usage error", async ({ options }) => { + await expect(webhooksVerify(options)).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("missing --payload file maps to file_not_found", async () => { + await expect( + webhooksVerify({ ...explicitFlags(), payload: "@/definitely/not/here.json" }), + ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); + }); + + test("empty --delivery input is a usage error", async () => { + const deliveryPath = await writeTempFile("empty.json", "\n\n"); + + await expect( + webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}` }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/verify.ts b/packages/cli-core/src/commands/webhooks/verify.ts new file mode 100644 index 00000000..ec5d127c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -0,0 +1,186 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; + +export interface WebhooksVerifyOptions { + secret?: string; + delivery?: string; + payload?: string; + id?: string; + timestamp?: string; + signature?: string; + // Group-level flags are accepted but ignored: verify is pure offline HMAC. + app?: string; + instance?: string; + json?: boolean; +} + +const SECRET_PREFIX = "whsec_"; +const SKEW_HINT_THRESHOLD_SECONDS = 5 * 60; + +/** Decode the base64 key material after the `whsec_` prefix. Null when malformed. */ +export function decodeWebhookSecret(secret: string): Buffer | null { + if (!secret.startsWith(SECRET_PREFIX)) return null; + const encoded = secret.slice(SECRET_PREFIX.length); + if (!encoded) return null; + const key = Buffer.from(encoded, "base64"); + if (key.length === 0) return null; + return key; +} + +/** + * Verify a Svix signature: HMAC-SHA256 over `{id}.{timestamp}.{payload}` with + * the decoded secret, compared constant-time against every space-separated + * `v1,` entry in the header (any match wins). During the 24h rotation + * grace window the header carries multiple entries — that's why any-match matters. + */ +export function verifyWebhookSignature(input: { + secret: string; + id: string; + timestamp: string; + payload: string; + signature: string; +}): boolean { + const key = decodeWebhookSecret(input.secret); + if (!key) return false; + + const expected = createHmac("sha256", key) + .update(`${input.id}.${input.timestamp}.${input.payload}`, "utf8") + .digest(); + + return input.signature + .split(/\s+/) + .filter(Boolean) + .some((entry) => { + const commaIndex = entry.indexOf(","); + if (commaIndex === -1) return false; + const version = entry.slice(0, commaIndex); + if (version !== "v1") return false; + const candidate = Buffer.from(entry.slice(commaIndex + 1), "base64"); + return candidate.length === expected.length && timingSafeEqual(candidate, expected); + }); +} + +export interface DeliveryFields { + id?: string; + timestamp?: string; + signature?: string; + payload?: string; +} + +/** + * Parse one `listen` event NDJSON line (`headers` + `body_b64`) into the four + * verification inputs. Explicit flags override these at the call site. + */ +export function parseDeliveryLine(raw: string): DeliveryFields { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throwUsageError("--delivery is not valid JSON. Expected one `listen` event NDJSON line."); + } + if (parsed === null || typeof parsed !== "object") { + throwUsageError("--delivery must be a JSON object (one `listen` event NDJSON line)."); + } + + const record = parsed as { headers?: Record; body_b64?: string }; + const headers = record.headers ?? {}; + const fields: DeliveryFields = { + id: headers["svix-id"], + timestamp: headers["svix-timestamp"], + signature: headers["svix-signature"], + }; + if (typeof record.body_b64 === "string") { + fields.payload = Buffer.from(record.body_b64, "base64").toString("utf8"); + } + return fields; +} + +async function readFileOrStdin(value: string, flag: string): Promise { + if (value === "-") { + return await Bun.stdin.text(); + } + if (value.startsWith("@")) { + const path = value.slice(1); + const file = Bun.file(path); + if (!(await file.exists())) { + throw new CliError(`File not found: ${path}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } + return await file.text(); + } + return throwUsageError( + `${flag} takes @file or - for stdin (inline values get mangled by shells).`, + ); +} + +function humanizeSkew(deltaSeconds: number): string { + const minutes = Math.round(Math.abs(deltaSeconds) / 60); + const span = minutes >= 1 ? `${minutes} minute${minutes === 1 ? "" : "s"}` : "less than a minute"; + return deltaSeconds > 0 ? `${span} in the past` : `${span} in the future`; +} + +export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promise { + if (!options.secret) { + throwUsageError("Missing required --secret whsec_..."); + } + if (!decodeWebhookSecret(options.secret)) { + throwUsageError("Invalid --secret. Expected a whsec_-prefixed base64 signing secret."); + } + + let fields: DeliveryFields = {}; + if (options.delivery) { + const raw = await readFileOrStdin(options.delivery, "--delivery"); + const firstLine = raw.split("\n").find((line) => line.trim()); + if (!firstLine) { + throwUsageError("--delivery input is empty. Expected one `listen` event NDJSON line."); + } + fields = parseDeliveryLine(firstLine); + } + + // Explicit flags override --delivery fields. + const id = options.id ?? fields.id; + const timestamp = options.timestamp ?? fields.timestamp; + const signature = options.signature ?? fields.signature; + const hasPayload = options.payload !== undefined || fields.payload !== undefined; + + const missing = [ + !id && "--id", + !timestamp && "--timestamp", + !signature && "--signature", + !hasPayload && "--payload", + ].filter(Boolean); + if (missing.length > 0) { + throwUsageError( + `Missing ${missing.join(", ")}. Pass --delivery @event.json or all four explicit flags.`, + ); + } + + if (!/^\d+$/.test(timestamp!)) { + throwUsageError( + `Invalid --timestamp "${timestamp}". Expected Unix epoch seconds (the raw svix-timestamp header value).`, + ); + } + + const payload = options.payload + ? await readFileOrStdin(options.payload, "--payload") + : fields.payload; + + const valid = verifyWebhookSignature({ + secret: options.secret, + id: id!, + timestamp: timestamp!, + payload: payload!, + signature: signature!, + }); + + if (!valid) { + let message = "Signature verification failed: no signature entry matched."; + const deltaSeconds = Math.floor(Date.now() / 1000) - Number(timestamp); + if (Math.abs(deltaSeconds) > SKEW_HINT_THRESHOLD_SECONDS) { + message += ` Note: the timestamp is ${humanizeSkew(deltaSeconds)} — make sure it is the raw svix-timestamp header from the same delivery as the signature.`; + } + throw new CliError(message); + } + + log.success("Signature verified."); +} diff --git a/packages/cli-core/undefined/body.json b/packages/cli-core/undefined/body.json new file mode 100644 index 00000000..098657a7 --- /dev/null +++ b/packages/cli-core/undefined/body.json @@ -0,0 +1 @@ +{ "type": "user.created" } From 67abc48d6a03c449ea70b38a6d3c6deb122abfa0 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:39:04 -0300 Subject: [PATCH 17/28] feat(webhooks): add pure relay protocol helpers --- .../commands/webhooks/relay-protocol.test.ts | 104 +++++++++++++++++ .../src/commands/webhooks/relay-protocol.ts | 108 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/relay-protocol.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/relay-protocol.ts diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts new file mode 100644 index 00000000..c818ce5e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts @@ -0,0 +1,104 @@ +import { test, expect, describe } from "bun:test"; +import { + decodeEventBody, + decodeFrame, + encodeEventResponseFrame, + encodeStartFrame, + generateRelayToken, + relayReceiveUrl, +} from "./relay-protocol.ts"; + +describe("generateRelayToken", () => { + test("produces 10 base62 chars with no prefix", () => { + const token = generateRelayToken(); + expect(token).toMatch(/^[0-9A-Za-z]{10}$/); + expect(token.startsWith("c_")).toBe(false); + }); + + test("produces distinct tokens across calls", () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateRelayToken())); + expect(tokens.size).toBe(50); + }); +}); + +describe("relayReceiveUrl", () => { + test("builds the play.svix.com URL with the raw token", () => { + expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://play.svix.com/in/Ab12Cd34Ef/"); + }); +}); + +describe("encodeStartFrame", () => { + test("matches the svix-cli handshake shape", () => { + expect(JSON.parse(encodeStartFrame("Ab12Cd34Ef"))).toEqual({ + type: "start", + version: 1, + data: { token: "Ab12Cd34Ef" }, + }); + }); +}); + +describe("decodeFrame", () => { + const eventFrame = JSON.stringify({ + type: "event", + version: 1, + data: { + id: "frame_1", + method: "POST", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body: Buffer.from('{"type":"user.created"}', "utf8").toString("base64"), + }, + }); + + test("decodes an event frame", () => { + const decoded = decodeFrame(eventFrame); + expect(decoded.type).toBe("event"); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.id).toBe("frame_1"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers["svix-id"]).toBe("msg_1"); + expect(decodeEventBody(decoded.event)).toBe('{"type":"user.created"}'); + }); + + test("round-trips: a decoded event re-encodes into a valid response frame", () => { + const decoded = decodeFrame(eventFrame); + if (decoded.type !== "event") throw new Error("unreachable"); + + const reply = encodeEventResponseFrame({ + id: decoded.event.id, + status: 200, + headers: { "content-type": "application/json" }, + bodyB64: Buffer.from("{}", "utf8").toString("base64"), + }); + + expect(JSON.parse(reply)).toEqual({ + type: "event", + version: 1, + data: { + id: "frame_1", + status: 200, + headers: { "content-type": "application/json" }, + body: "e30=", + }, + }); + }); + + test.each([ + { label: "invalid JSON", raw: "{nope" }, + { label: "non-object JSON", raw: '"hello"' }, + { label: "null", raw: "null" }, + { label: "unknown frame type", raw: '{"type":"server-error","version":1,"data":{}}' }, + { label: "event frame without data", raw: '{"type":"event","version":1}' }, + { label: "event frame without an id", raw: '{"type":"event","version":1,"data":{}}' }, + ])("returns unknown for $label", ({ raw }) => { + expect(decodeFrame(raw)).toEqual({ type: "unknown" }); + }); + + test("defaults method to POST and headers/body to empty", () => { + const decoded = decodeFrame('{"type":"event","version":1,"data":{"id":"frame_2"}}'); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers).toEqual({}); + expect(decoded.event.bodyB64).toBe(""); + expect(decodeEventBody(decoded.event)).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.ts new file mode 100644 index 00000000..075c235a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.ts @@ -0,0 +1,108 @@ +/** + * Pure Svix relay protocol helpers: token generation, URLs, and frame + * encoding/decoding. Frame field names verified against the svix-cli source. + * No I/O here — everything is unit-testable without a socket. + */ + +export const RELAY_WS_URL = "wss://api.relay.svix.com/api/v1/listen/"; + +/** Close code the relay sends when another listener holds the same token. */ +export const RELAY_CLOSE_TOKEN_COLLISION = 1008; + +/** + * The relay server pings ~every 21s, but Bun's client WebSocket auto-pongs + * below the JS API (no ping/pong events). After this much silence we actively + * probe with a client ping — writes to a dead link surface as error/close, + * which triggers the same-token redial. + */ +export const RELAY_SILENCE_TIMEOUT_MS = 30_000; + +export const RELAY_RECONNECT_DELAY_MS = 1_000; + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const TOKEN_LENGTH = 10; +// Largest multiple of 62 below 256; bytes at or above it would bias the modulo. +const UNBIASED_BYTE_LIMIT = 248; + +/** 10 random base62 chars, raw — no `c_` prefix on the wire or in the URL. */ +export function generateRelayToken(): string { + let token = ""; + while (token.length < TOKEN_LENGTH) { + const bytes = new Uint8Array(TOKEN_LENGTH * 2); + crypto.getRandomValues(bytes); + for (const byte of bytes) { + if (byte >= UNBIASED_BYTE_LIMIT) continue; + token += BASE62[byte % 62]; + if (token.length === TOKEN_LENGTH) break; + } + } + return token; +} + +export function relayReceiveUrl(token: string): string { + return `https://play.svix.com/in/${token}/`; +} + +export function encodeStartFrame(token: string): string { + return JSON.stringify({ type: "start", version: 1, data: { token } }); +} + +export interface RelayEventFrame { + /** Relay-internal frame ID, echoed back in the response frame. */ + id: string; + method: string; + headers: Record; + /** Base64-encoded request body, exactly as received. */ + bodyB64: string; +} + +export type DecodedFrame = { type: "event"; event: RelayEventFrame } | { type: "unknown" }; + +export function decodeFrame(raw: string): DecodedFrame { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { type: "unknown" }; + } + if (parsed === null || typeof parsed !== "object") return { type: "unknown" }; + + const frame = parsed as { + type?: string; + data?: { id?: string; method?: string; headers?: Record; body?: string }; + }; + if (frame.type !== "event" || !frame.data || typeof frame.data.id !== "string") { + return { type: "unknown" }; + } + + return { + type: "event", + event: { + id: frame.data.id, + method: frame.data.method ?? "POST", + headers: frame.data.headers ?? {}, + bodyB64: frame.data.body ?? "", + }, + }; +} + +export function decodeEventBody(event: RelayEventFrame): string { + return Buffer.from(event.bodyB64, "base64").toString("utf8"); +} + +/** + * Frame a forward response back to the relay so Svix-side delivery telemetry + * stays honest (status, headers, and body of the local handler's response). + */ +export function encodeEventResponseFrame(reply: { + id: string; + status: number; + headers: Record; + bodyB64: string; +}): string { + return JSON.stringify({ + type: "event", + version: 1, + data: { id: reply.id, status: reply.status, headers: reply.headers, body: reply.bodyB64 }, + }); +} From f927b38742c8274a9831d7f1781b35499a95bb09 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:43:27 -0300 Subject: [PATCH 18/28] feat(webhooks): add relay client, forwarder, and listen rendering --- .../src/commands/webhooks/forward.test.ts | 146 +++++++++++++++++ .../cli-core/src/commands/webhooks/forward.ts | 88 ++++++++++ .../src/commands/webhooks/relay-client.ts | 129 +++++++++++++++ .../src/commands/webhooks/render.test.ts | 155 ++++++++++++++++++ .../cli-core/src/commands/webhooks/render.ts | 136 +++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/forward.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/forward.ts create mode 100644 packages/cli-core/src/commands/webhooks/relay-client.ts create mode 100644 packages/cli-core/src/commands/webhooks/render.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/render.ts diff --git a/packages/cli-core/src/commands/webhooks/forward.test.ts b/packages/cli-core/src/commands/webhooks/forward.test.ts new file mode 100644 index 00000000..802a4d9f --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.test.ts @@ -0,0 +1,146 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { CliError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { buildForwardHeaders, forwardDelivery, parseHeaderPairs } from "./forward.ts"; + +const originalFetch = globalThis.fetch; + +describe("parseHeaderPairs", () => { + const parseCases: Array<{ + label: string; + value: string | undefined; + expected: Record; + }> = [ + { label: "undefined", value: undefined, expected: {} }, + { label: "empty string", value: "", expected: {} }, + { label: "single pair", value: "x-env:dev", expected: { "x-env": "dev" } }, + { + label: "multiple pairs with whitespace", + value: " x-env : dev , x-team:core ", + expected: { "x-env": "dev", "x-team": "core" }, + }, + { + label: "value containing colons (split on FIRST colon)", + value: "authorization:Bearer abc:def", + expected: { authorization: "Bearer abc:def" }, + }, + { label: "trailing comma", value: "x-env:dev,", expected: { "x-env": "dev" } }, + { label: "empty value", value: "x-empty:", expected: { "x-empty": "" } }, + ]; + + test.each(parseCases)("parses $label", ({ value, expected }) => { + expect(parseHeaderPairs(value)).toEqual(expected); + }); + + test.each([ + { label: "pair without a colon", value: "not-a-pair" }, + { label: "pair with an empty key", value: ":value" }, + ])("throws a usage error on $label", ({ value }) => { + expect(() => parseHeaderPairs(value)).toThrow(CliError); + }); +}); + +describe("buildForwardHeaders", () => { + const eventHeaders = { + "svix-id": "msg_1", + "svix-timestamp": "1717935000", + "svix-signature": "v1,abc", + "content-type": "application/json", + }; + + test("preserves delivery headers and adds extras", () => { + const headers = buildForwardHeaders(eventHeaders, { "x-env": "dev" }); + + expect(headers.get("svix-id")).toBe("msg_1"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-env")).toBe("dev"); + }); + + test("extras may override non-svix delivery headers", () => { + const headers = buildForwardHeaders(eventHeaders, { "Content-Type": "text/plain" }); + + expect(headers.get("content-type")).toBe("text/plain"); + }); + + test.each([ + { label: "lowercase", key: "svix-signature" }, + { label: "uppercase", key: "SVIX-SIGNATURE" }, + { label: "mixed case", key: "Svix-Signature" }, + ])("extras can never override svix-* headers ($label)", ({ key }) => { + const headers = buildForwardHeaders(eventHeaders, { [key]: "v1,forged" }); + + expect(headers.get("svix-signature")).toBe("v1,abc"); + }); +}); + +describe("forwardDelivery", () => { + useCaptureLog(); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs the body with headers and captures the response", async () => { + let captured: { url: string; method: string; body: string; headers: Headers } | undefined; + stubFetch(async (input, init) => { + captured = { + url: input.toString(), + method: init?.method ?? "GET", + body: String(init?.body), + headers: new Headers(init?.headers), + }; + return new Response("ok body", { status: 200, headers: { "x-served-by": "test" } }); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: buildForwardHeaders({ "svix-id": "msg_1" }, {}), + body: '{"type":"user.created"}', + }); + + expect(captured?.url).toBe("http://localhost:3000/api/webhooks"); + expect(captured?.method).toBe("POST"); + expect(captured?.body).toBe('{"type":"user.created"}'); + expect(captured?.headers.get("svix-id")).toBe("msg_1"); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(200); + expect(outcome.bodyText).toBe("ok body"); + expect(outcome.bodyB64).toBe(Buffer.from("ok body", "utf8").toString("base64")); + expect(outcome.headers["x-served-by"]).toBe("test"); + expect(outcome.latencyMs).toBeGreaterThanOrEqual(0); + }); + + test("returns a synthetic 502 when the local handler is unreachable", async () => { + stubFetch(async () => { + throw new Error("connection refused"); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:9/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(true); + expect(outcome.status).toBe(502); + expect(outcome.bodyText).toContain("connection refused"); + }); + + test("non-2xx handler responses are captured, not thrown", async () => { + stubFetch(async () => new Response("boom", { status: 500 })); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(500); + expect(outcome.bodyText).toBe("boom"); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/forward.ts b/packages/cli-core/src/commands/webhooks/forward.ts new file mode 100644 index 00000000..19cc9ece --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.ts @@ -0,0 +1,88 @@ +import { errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; + +export interface ForwardOutcome { + status: number; + headers: Record; + bodyText: string; + bodyB64: string; + latencyMs: number; + /** True when the local handler was unreachable (status is a synthetic 502). */ + failed: boolean; +} + +/** Comma-separated `k:v` pairs, split on the FIRST colon, whitespace trimmed. */ +export function parseHeaderPairs(value: string | undefined): Record { + if (!value) return {}; + const headers: Record = {}; + for (const pair of value.split(",")) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const colonIndex = trimmed.indexOf(":"); + const key = colonIndex === -1 ? "" : trimmed.slice(0, colonIndex).trim(); + if (!key) { + throwUsageError(`Invalid --headers pair "${trimmed}". Expected key:value.`); + } + headers[key] = trimmed.slice(colonIndex + 1).trim(); + } + return headers; +} + +/** + * Delivery headers plus `--headers` extras. Extras may override non-svix + * delivery headers, but the delivery's `svix-*` headers always win — they are + * what `verify` (and the user's handler) authenticate against. + */ +export function buildForwardHeaders( + eventHeaders: Record, + extraHeaders: Record, +): Headers { + const headers = new Headers(eventHeaders); + for (const [key, value] of Object.entries(extraHeaders)) { + if (key.toLowerCase().startsWith("svix-")) continue; + headers.set(key, value); + } + return headers; +} + +export async function forwardDelivery(args: { + forwardTo: string; + method: string; + headers: Headers; + body: string; +}): Promise { + const startedAt = performance.now(); + try { + const response = await loggedFetch(args.forwardTo, { + tag: "relay", + method: args.method, + headers: args.headers, + body: args.body, + }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { + status: response.status, + headers, + bodyText, + bodyB64: Buffer.from(bodyText, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: false, + }; + } catch (error) { + // Local handler unreachable. Frame a synthetic 502 back so Svix-side + // delivery telemetry records the failure instead of a hung attempt. + const message = errorMessage(error); + return { + status: 502, + headers: {}, + bodyText: message, + bodyB64: Buffer.from(message, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: true, + }; + } +} diff --git a/packages/cli-core/src/commands/webhooks/relay-client.ts b/packages/cli-core/src/commands/webhooks/relay-client.ts new file mode 100644 index 00000000..586edb66 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -0,0 +1,129 @@ +import { log } from "../../lib/log.ts"; +import { + RELAY_CLOSE_TOKEN_COLLISION, + RELAY_RECONNECT_DELAY_MS, + RELAY_SILENCE_TIMEOUT_MS, + RELAY_WS_URL, + decodeFrame, + encodeStartFrame, + generateRelayToken, + type RelayEventFrame, +} from "./relay-protocol.ts"; + +export interface RelayClientOptions { + token: string; + /** Called per inbound delivery; `reply` sends a response frame back. */ + onEvent: (event: RelayEventFrame, reply: (frame: string) => void) => void; + /** 1008 collision → a fresh token was generated; persist it (and re-point the endpoint). */ + onTokenRotated: (token: string) => Promise; + /** Connection dropped; redialing with the same token. */ + onReconnect: () => void; + /** Test/env override for the relay WebSocket URL. */ + url?: string; +} + +/** + * Long-lived relay WebSocket using Bun's built-in client. Reconnects with the + * same token (the relay URL — and therefore the registered endpoint — never + * changes across reconnects); rotates the token only on close code 1008. + */ +export class RelayClient { + token: string; + + private ws: WebSocket | undefined; + private stopped = false; + private probeTimer: ReturnType | undefined; + private lastActivityAt = Date.now(); + private resolveFirstOpen: (() => void) | undefined; + + constructor(private readonly options: RelayClientOptions) { + this.token = options.token; + } + + /** Dial and resolve once the first connection is open and handshaken. */ + start(): Promise { + const opened = new Promise((resolve) => { + this.resolveFirstOpen = resolve; + }); + this.connect(); + return opened; + } + + /** Close the socket and stop reconnecting. Never deletes the relay endpoint. */ + stop(): void { + this.stopped = true; + this.clearProbe(); + this.ws?.close(1000); + } + + private connect(): void { + if (this.stopped) return; + + const ws = new WebSocket(this.options.url ?? RELAY_WS_URL); + this.ws = ws; + + ws.onopen = () => { + log.debug(`relay: connected, sending start frame (token=${this.token})`); + ws.send(encodeStartFrame(this.token)); + this.lastActivityAt = Date.now(); + this.startProbe(ws); + this.resolveFirstOpen?.(); + this.resolveFirstOpen = undefined; + }; + + ws.onmessage = (message) => { + this.lastActivityAt = Date.now(); + const raw = typeof message.data === "string" ? message.data : String(message.data); + const decoded = decodeFrame(raw); + if (decoded.type !== "event") { + log.debug(`relay: ignoring non-event frame: ${raw.slice(0, 200)}`); + return; + } + this.options.onEvent(decoded.event, (frame) => { + if (ws.readyState === WebSocket.OPEN) ws.send(frame); + }); + }; + + ws.onerror = () => { + log.debug("relay: socket error"); + }; + + ws.onclose = (event) => { + this.clearProbe(); + if (this.stopped) return; + + if (event.code === RELAY_CLOSE_TOKEN_COLLISION) { + // Another listener holds this token: rotate, persist, redial. + this.token = generateRelayToken(); + log.debug("relay: token collision (1008), rotating token"); + void this.options.onTokenRotated(this.token).then(() => this.connect()); + return; + } + + log.debug(`relay: connection closed (code=${event.code}), reconnecting`); + this.options.onReconnect(); + setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); + }; + } + + private startProbe(ws: WebSocket): void { + this.clearProbe(); + // Bun's client WebSocket auto-pongs server pings below the JS API, so + // silence is unobservable directly. After RELAY_SILENCE_TIMEOUT_MS without + // any message we send a client ping: writes to a dead link fail and fire + // close/error, which triggers the same-token redial above. + this.probeTimer = setInterval(() => { + if (Date.now() - this.lastActivityAt < RELAY_SILENCE_TIMEOUT_MS) return; + try { + ws.ping(); + } catch { + ws.close(); + } + }, RELAY_SILENCE_TIMEOUT_MS / 2); + } + + private clearProbe(): void { + if (this.probeTimer) clearInterval(this.probeTimer); + this.probeTimer = undefined; + } +} diff --git a/packages/cli-core/src/commands/webhooks/render.test.ts b/packages/cli-core/src/commands/webhooks/render.test.ts new file mode 100644 index 00000000..a36ca168 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -0,0 +1,155 @@ +import { test, expect, describe } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import type { ForwardOutcome } from "./forward.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; + +function outcome(overrides: Partial = {}): ForwardOutcome { + return { + status: 200, + headers: {}, + bodyText: "", + bodyB64: "", + latencyMs: 12, + failed: false, + ...overrides, + }; +} + +describe("buildReadyLine", () => { + test("matches the agent-mode ready contract", () => { + const line = buildReadyLine({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: ["user.created"], + forwardTo: "http://localhost:3000/api/webhooks", + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + signing_secret: "whsec_abc", + endpoint_id: "ep_1", + events_filter: ["user.created"], + }); + }); +}); + +describe("buildEventLine", () => { + test("matches the agent-mode event contract", () => { + const line = buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + bodyB64: "e30=", + forwardStatus: 200, + latencyMs: 12, + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "event", + svix_id: "msg_1", + event_type: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body_b64: "e30=", + forward_status: 200, + latency_ms: 12, + }); + }); + + test("forward_status is null when not forwarding", () => { + const parsed = JSON.parse( + buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: {}, + bodyB64: "", + forwardStatus: null, + latencyMs: 0, + }), + ) as { forward_status: number | null }; + + expect(parsed.forward_status).toBeNull(); + }); +}); + +describe("human rendering", () => { + const captured = useCaptureLog(); + + test("ready banner shows the secret, relay URL, and endpoint", () => { + renderReadyBanner({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: null, + forwardTo: null, + }); + + expect(captured.err).toContain("whsec_abc"); + expect(captured.err).toContain("https://play.svix.com/in/Ab12Cd34Ef/"); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("NOT your Dashboard endpoint secret"); + expect(captured.err).toContain("not forwarding"); + expect(captured.out).toBe(""); + }); + + test("arrival and result lines follow the time --> / <-- format", () => { + renderArrival("user.created", "msg_1"); + renderForwardResult(outcome({ status: 200 }), "POST", "/api/webhooks"); + + const plain = captured.err.replace(/\x1b\[[0-9;]*m/g, ""); + expect(plain).toMatch(/\d{2}:\d{2}:\d{2} --> user\.created msg_1\n/); + expect(plain).toMatch(/\d{2}:\d{2}:\d{2} <-- 200 POST \/api\/webhooks 12ms\n/); + }); + + test("verification warning names the delivery", () => { + renderVerificationWarning("msg_1"); + + expect(captured.err).toContain("signature verification failed for msg_1"); + }); + + test.each([ + { + label: "401 → middleware hint", + forward: outcome({ status: 401 }), + expected: "createRouteMatcher(['/api/webhooks(.*)'])", + }, + { + label: "400 → raw-body hint", + forward: outcome({ status: 400 }), + expected: "RAW request body", + }, + { + label: "unreachable handler → dev-server hint", + forward: outcome({ status: 502, failed: true, bodyText: "connection refused" }), + expected: "Is your dev server running", + }, + ])("$label", ({ forward, expected }) => { + renderForwardDiagnostics(forward, "msg_1"); + + expect(captured.err).toContain(expected); + }); + + test("5xx diagnostics include the response body and the replay command", () => { + renderForwardDiagnostics(outcome({ status: 500, bodyText: "stack trace here" }), "msg_9"); + + expect(captured.err).toContain("stack trace here"); + expect(captured.err).toContain("clerk webhooks replay msg_9"); + }); + + test("2xx responses produce no diagnostics", () => { + renderForwardDiagnostics(outcome({ status: 204 }), "msg_1"); + + expect(captured.err).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/render.ts b/packages/cli-core/src/commands/webhooks/render.ts new file mode 100644 index 00000000..f53d3a8d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.ts @@ -0,0 +1,136 @@ +/** + * Rendering for `webhooks listen`. Per-delivery lines go through + * `log.ui(line + "\n")` — every other stderr channel shares a 5-then-suppress + * throttle per 1s window that would eat delivery bursts. + */ + +import { bold, cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import type { ForwardOutcome } from "./forward.ts"; + +export interface ReadyInfo { + relayUrl: string; + signingSecret: string; + endpointId: string; + eventsFilter: string[] | null; + forwardTo: string | null; +} + +/** NDJSON ready line (stdout in agent/--json mode). */ +export function buildReadyLine(info: ReadyInfo): string { + return JSON.stringify({ + type: "ready", + relay_url: info.relayUrl, + signing_secret: info.signingSecret, + endpoint_id: info.endpointId, + events_filter: info.eventsFilter, + }); +} + +/** NDJSON per-delivery line; saved to a file it feeds `verify --delivery`. */ +export function buildEventLine(args: { + svixId: string; + eventType: string; + headers: Record; + bodyB64: string; + forwardStatus: number | null; + latencyMs: number; +}): string { + return JSON.stringify({ + type: "event", + svix_id: args.svixId, + event_type: args.eventType, + headers: args.headers, + body_b64: args.bodyB64, + forward_status: args.forwardStatus, + latency_ms: args.latencyMs, + }); +} + +export function renderReadyBanner(info: ReadyInfo): void { + const forwarding = info.forwardTo ?? dim("(not forwarding — printing events only)"); + const events = info.eventsFilter?.length ? info.eventsFilter.join(", ") : "all"; + log.ui( + [ + "", + `${bold("Webhook relay ready")}`, + ` Endpoint: ${cyan(info.endpointId)}`, + ` Relay URL: ${info.relayUrl}`, + ` Signing secret: ${info.signingSecret}`, + ` ${dim("(local relay endpoint secret, NOT your Dashboard endpoint secret)")}`, + ` Forwarding to: ${forwarding}`, + ` Events: ${events}`, + "", + ` ${dim("Press Ctrl+C to stop. The relay endpoint and secret persist across restarts.")}`, + "", + "", + ].join("\n"), + ); +} + +function timeOfDay(): string { + return new Date().toTimeString().slice(0, 8); +} + +export function renderArrival(eventType: string, svixId: string): void { + log.ui(`${dim(timeOfDay())} ${cyan("-->")} ${eventType} ${dim(svixId)}\n`); +} + +export function renderForwardResult(outcome: ForwardOutcome, method: string, path: string): void { + const color = outcome.status >= 500 ? red : outcome.status >= 400 ? yellow : green; + log.ui( + `${dim(timeOfDay())} ${color(`<-- ${outcome.status}`)} ${method} ${path} ${dim(`${outcome.latencyMs}ms`)}\n`, + ); +} + +export function renderVerificationWarning(svixId: string): void { + log.ui( + yellow( + ` ! signature verification failed for ${svixId} — the relay secret does not match this delivery. Forwarding anyway; pass --skip-verify to silence.\n`, + ), + ); +} + +const BODY_PREVIEW_LIMIT = 500; + +export function renderForwardDiagnostics(outcome: ForwardOutcome, svixId: string): void { + if (outcome.failed) { + log.ui( + yellow(` ! could not reach the local handler: ${outcome.bodyText}\n`) + + dim(" Is your dev server running on the --forward-to URL?\n"), + ); + return; + } + + if (outcome.status === 401) { + log.ui( + yellow(" ! 401 from your handler — middleware is likely protecting the webhook route.\n") + + dim( + " In clerkMiddleware(), allow it with createRouteMatcher(['/api/webhooks(.*)']) as a public route.\n", + ), + ); + return; + } + + if (outcome.status === 400) { + log.ui( + yellow(" ! 400 from your handler — usually a signature check on a parsed body.\n") + + dim( + " Pass the RAW request body to verifyWebhook(); read it before any JSON body parsing.\n", + ), + ); + return; + } + + if (outcome.status >= 500) { + const preview = + outcome.bodyText.length > BODY_PREVIEW_LIMIT + ? `${outcome.bodyText.slice(0, BODY_PREVIEW_LIMIT)}...` + : outcome.bodyText; + log.ui( + yellow(` ! ${outcome.status} from your handler. Response body:\n`) + + (preview ? ` ${preview}\n` : dim(" (empty)\n")) + + dim(` Fix the handler, then resend this delivery: clerk webhooks replay ${svixId}\n`), + ); + } +} From 28e7d177a3022bea8851405abf92fd272cc1fad6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:46:48 -0300 Subject: [PATCH 19/28] feat(webhooks): add 'webhooks listen' command --- packages/cli-core/src/cli-program.ts | 33 ++ .../cli-core/src/commands/webhooks/README.md | 33 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/listen.test.ts | 338 ++++++++++++++++++ .../cli-core/src/commands/webhooks/listen.ts | 271 ++++++++++++++ 5 files changed, 677 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/listen.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/listen.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 27e5731b..3ab41541 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -808,6 +808,39 @@ export function createProgram() { ), ); + webhooks + .command("listen") + .description("Stream instance events to your terminal and forward them to a local handler") + .option("--forward-to ", "Local URL to POST deliveries to (omit to just print events)") + .option( + "--events ", + "Comma-separated event types to filter on (PATCHes the shared relay endpoint's filter)", + ) + .option("--skip-verify", "Skip HMAC verification of incoming deliveries") + .option( + "--headers ", + "Extra headers for the forwarded request, comma-separated k:v pairs (svix-* cannot be overridden)", + ) + .setExamples([ + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + { + command: "clerk webhooks listen --events user.created,user.deleted", + description: "Only receive specific event types", + }, + { + command: "clerk webhooks listen --json", + description: "Emit NDJSON event lines (pipe into a file for `webhooks verify --delivery`)", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.listen( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index d4cb1850..2ae70354 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -226,3 +226,36 @@ Explicit flags override fields parsed from `--delivery`. A `listen` event line s ### API endpoints None — pure offline computation. + +## `clerk webhooks listen` + +Dials the Svix relay (`wss://api.relay.svix.com/api/v1/listen/`), registers a **persistent** per-instance relay endpoint pointing at `https://play.svix.com/in//`, and forwards incoming deliveries to a local handler. + +```sh +clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [--headers k:v,...] +``` + +| Option | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--forward-to ` | Local URL to POST deliveries to. Omitted: events are received, verified, and printed with `forward_status: null`. | +| `--events ` | Sets `filter_types` on the relay endpoint. If the persisted endpoint has different filters it is PATCHed — with a warning, since other `listen` sessions share this instance's relay endpoint. | +| `--skip-verify` | Skip per-delivery HMAC verification. | +| `--headers ` | Comma-separated `k:v` extras on the forwarded POST (split on the FIRST colon). The delivery's `svix-*` headers always win. | + +Behavior notes: + +- **Relay token**: 10 random base62 chars, raw on the wire (no `c_` prefix), persisted per instance in the CLI config (`relay..token`). Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. +- **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). After 30s of silence the client sends an active `ws.ping()` probe — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. +- **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay ` line; unreachable handler → synthetic **502** framed back to the relay. +- **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events). +- **Agent/`--json` mode**: NDJSON on stdout — one `ready` line (`relay_url`, `signing_secret`, `endpoint_id`, `events_filter`), then one `event` line per delivery (`svix_id`, `event_type`, `headers`, `body_b64`, `forward_status`, `latency_ms`). An event line saved to a file is directly consumable by `verify --delivery @file`. +- **SIGINT**: `listen` replaces the global cleanup-free handler before opening the socket: close socket, drain in-flight forwards, exit 130. The relay endpoint is **never** deleted on exit — its URL and `whsec_` stay stable across restarts. `listen` never exits 0. + +### API endpoints + +| Method | Endpoint | Description | +| ------- | ------------------------------- | ---------------------------------------------------------- | +| `GET` | `/webhooks/{endpointID}` | Reuse check for the persisted relay endpoint. | +| `PATCH` | `/webhooks/{endpointID}` | Re-point URL after token rotation / update `filter_types`. | +| `POST` | `/webhooks` | Create the relay endpoint on first run (or after a 404). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the relay endpoint's signing secret at startup. | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 41b8206e..f71e2ec3 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksListen } from "./listen.ts"; import { webhooksMessages } from "./messages.ts"; import { webhooksOpen } from "./open.ts"; import { webhooksReplay } from "./replay.ts"; @@ -24,4 +25,5 @@ export const webhooks = { trigger: webhooksTrigger, open: webhooksOpen, verify: webhooksVerify, + listen: webhooksListen, }; diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts new file mode 100644 index 00000000..1091cbb9 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -0,0 +1,338 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { createHmac, randomBytes } from "node:crypto"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import type { RelayEventFrame } from "./relay-protocol.ts"; + +type EventHandler = (event: RelayEventFrame, reply: (frame: string) => void) => void; + +interface FakeClientOptions { + token: string; + onEvent: EventHandler; + onTokenRotated: (token: string) => Promise; + onReconnect: () => void; +} + +let lastClient: FakeRelayClient | undefined; + +class FakeRelayClient { + token: string; + started = false; + stopped = false; + + constructor(readonly options: FakeClientOptions) { + this.token = options.token; + lastClient = this; + } + + start(): Promise { + this.started = true; + return Promise.resolve(); + } + + stop(): void { + this.stopped = true; + } +} + +mock.module("./relay-client.ts", () => ({ RelayClient: FakeRelayClient })); + +const mockGetWebhookEndpoint = mock(); +const mockCreateWebhookEndpoint = mock(); +const mockUpdateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +const mockSetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), + setRelayEntry: (...args: unknown[]) => mockSetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksListen } = await import("./listen.ts"); + +const KEY = randomBytes(24); +const SECRET = `whsec_${KEY.toString("base64")}`; + +const relayEndpoint = (overrides: Record = {}) => ({ + id: "ep_relay", + url: "https://play.svix.com/in/Ab12Cd34Ef/", + version: 1, + disabled: false, + filter_types: null, + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", + ...overrides, +}); + +function signedEvent(body: string, overrides: Partial = {}): RelayEventFrame { + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = `v1,${createHmac("sha256", KEY).update(`msg_1.${timestamp}.${body}`, "utf8").digest("base64")}`; + return { + id: "frame_1", + method: "POST", + headers: { + "svix-id": "msg_1", + "svix-timestamp": timestamp, + "svix-signature": signature, + "content-type": "application/json", + }, + bodyB64: Buffer.from(body, "utf8").toString("base64"), + ...overrides, + }; +} + +/** listen never resolves; run it and wait until the ready output lands. */ +async function startListen( + options: Parameters[0], + captured: { out: string; err: string }, +): Promise { + const run = webhooksListen(options); + run.catch(() => {}); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (captured.out.includes('"ready"') || captured.err.includes("Webhook relay ready")) return; + } + throw new Error("listen never became ready"); +} + +describe("webhooks listen", () => { + const captured = useCaptureLog(); + const originalFetch = globalThis.fetch; + let savedSigintListeners: NodeJS.SignalsListener[] = []; + + beforeEach(() => { + savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; + mockIsAgent.mockReturnValue(false); + lastClient = undefined; + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockSetRelayEntry.mockResolvedValue(undefined); + mockGetWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockCreateWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockUpdateWebhookEndpoint.mockImplementation( + async (_app: string, _ins: string, _ep: string, patch: Record) => + relayEndpoint(patch), + ); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: SECRET }); + }); + + afterEach(() => { + process.removeAllListeners("SIGINT"); + for (const listener of savedSigintListeners) process.on("SIGINT", listener); + globalThis.fetch = originalFetch; + mockGetWebhookEndpoint.mockReset(); + mockCreateWebhookEndpoint.mockReset(); + mockUpdateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockSetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("invalid --headers is a usage error before any network call", async () => { + await expect(webhooksListen({ headers: "not-a-pair" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("first run generates and persists a 10-char base62 token, then creates the endpoint", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({}, captured); + + const persistedToken = (mockSetRelayEntry.mock.calls[0]?.[1] as { token: string }).token; + expect(mockSetRelayEntry.mock.calls[0]?.[0]).toBe("ins_1"); + expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: `https://play.svix.com/in/${persistedToken}/`, + version: 1, + }); + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: persistedToken, + endpoint_id: "ep_relay", + }); + }); + + test("reuses the persisted endpoint without patching when nothing changed", async () => { + await startListen({}, captured); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay"); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + expect(captured.err).toContain("Webhook relay ready"); + expect(captured.err).toContain(SECRET); + }); + + test("PATCHes filter_types (with a warning) when --events differs", async () => { + await startListen({ events: "user.created,user.deleted" }, captured); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + filter_types: ["user.created", "user.deleted"], + }); + expect(captured.err).toContain("affects any other"); + }); + + test("recreates the endpoint when the persisted one returns 404", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await startListen({}, captured); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalled(); + }); + + test("emits the NDJSON ready line in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ forwardTo: "http://localhost:3000/api/webhooks" }, captured); + + const ready = JSON.parse(captured.out) as Record; + expect(ready).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + signing_secret: SECRET, + endpoint_id: "ep_relay", + events_filter: null, + }); + }); + + test("registers its own SIGINT handler before the socket opens", async () => { + await startListen({}, captured); + + expect(process.listenerCount("SIGINT")).toBe(1); + expect(lastClient?.started).toBe(true); + }); + + test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const replies: string[] = []; + lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(JSON.parse(replies[0]!)).toEqual({ + type: "event", + version: 1, + data: { id: "frame_1", status: 200, headers: {}, body: "" }, + }); + + const line = JSON.parse(captured.out) as Record; + expect(line.type).toBe("event"); + expect(line.svix_id).toBe("msg_1"); + expect(line.event_type).toBe("user.created"); + expect(line.forward_status).toBeNull(); + expect(captured.err).toBe(""); // no verification warning for a valid signature + }); + + test("delivery with --forward-to POSTs to the handler and frames the response back", async () => { + mockIsAgent.mockReturnValue(true); + let forwarded: { url: string; headers: Headers; body: string } | undefined; + stubFetch(async (input, init) => { + forwarded = { + url: input.toString(), + headers: new Headers(init?.headers), + body: String(init?.body), + }; + return new Response("handled", { status: 201 }); + }); + + await startListen( + { forwardTo: "http://localhost:3000/api/webhooks", headers: "x-env:dev" }, + captured, + ); + captured.clear(); + + const replies: string[] = []; + lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + for (let i = 0; i < 20 && replies.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(forwarded?.url).toBe("http://localhost:3000/api/webhooks"); + expect(forwarded?.headers.get("svix-id")).toBe("msg_1"); + expect(forwarded?.headers.get("x-env")).toBe("dev"); + expect(forwarded?.body).toBe('{"type":"user.created"}'); + + const reply = JSON.parse(replies[0]!) as { data: { status: number; body: string } }; + expect(reply.data.status).toBe(201); + expect(Buffer.from(reply.data.body, "base64").toString("utf8")).toBe("handled"); + + const line = JSON.parse(captured.out) as { forward_status: number }; + expect(line.forward_status).toBe(201); + }); + + test("warns on an invalid signature but still forwards", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toContain("signature verification failed for msg_1"); + expect(captured.out).toContain('"type":"event"'); + }); + + test("--skip-verify suppresses the signature warning", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ skipVerify: true }, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toBe(""); + }); + + test("token rotation persists the new token and re-points the endpoint URL", async () => { + await startListen({}, captured); + + await lastClient!.options.onTokenRotated("Zz98Yy76Xx"); + + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: "Zz98Yy76Xx", + endpoint_id: "ep_relay", + }); + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + url: "https://play.svix.com/in/Zz98Yy76Xx/", + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts new file mode 100644 index 00000000..30279f3d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -0,0 +1,271 @@ +import { getRelayEntry, resolveAppContext, setRelayEntry } from "../../lib/config.ts"; +import { EXIT_CODE, PlapiError, errorMessage } from "../../lib/errors.ts"; +import { dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpoint, + getWebhookEndpointSecret, + updateWebhookEndpoint, + type UpdateWebhookEndpointParams, + type WebhookEndpoint, +} from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { + buildForwardHeaders, + forwardDelivery, + parseHeaderPairs, + type ForwardOutcome, +} from "./forward.ts"; +import { RelayClient } from "./relay-client.ts"; +import { + decodeEventBody, + encodeEventResponseFrame, + generateRelayToken, + relayReceiveUrl, + type RelayEventFrame, +} from "./relay-protocol.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; +import { splitCommaList, type WebhooksGlobalOptions } from "./shared.ts"; +import { verifyWebhookSignature } from "./verify.ts"; + +export interface WebhooksListenOptions extends WebhooksGlobalOptions { + forwardTo?: string; + events?: string; + skipVerify?: boolean; + headers?: string; +} + +interface ListenContext { + appId: string; + instanceId: string; +} + +function sameFilter(current: string[] | null | undefined, next: string[]): boolean { + const a = [...(current ?? [])].sort(); + const b = [...next].sort(); + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * Find-or-create is CLIENT-side: reuse the persisted endpoint ID, re-pointing + * its URL if the token rotated and PATCHing `filter_types` when `--events` + * differs. On 404 (or first run) create and persist. The backend does no + * URL-uniqueness matching. + */ +async function ensureRelayEndpoint( + ctx: ListenContext, + token: string, + eventsFilter: string[] | undefined, +): Promise { + const relayUrl = relayReceiveUrl(token); + const entry = await getRelayEntry(ctx.instanceId); + + if (entry?.endpoint_id) { + try { + let endpoint = await getWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id); + const patch: UpdateWebhookEndpointParams = {}; + if (endpoint.url !== relayUrl) patch.url = relayUrl; + if (eventsFilter && !sameFilter(endpoint.filter_types, eventsFilter)) { + log.warn( + "Updating the relay endpoint's event filter — this affects any other `listen` session sharing this instance's relay endpoint.", + ); + patch.filter_types = eventsFilter; + } + if (Object.keys(patch).length > 0) { + endpoint = await updateWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id, patch); + } + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; + } catch (error) { + if (!(error instanceof PlapiError && error.status === 404)) throw error; + // The persisted endpoint was deleted out from under us — recreate. + } + } + + const endpoint = await createWebhookEndpoint(ctx.appId, ctx.instanceId, { + url: relayUrl, + version: 1, + ...(eventsFilter ? { filter_types: eventsFilter } : {}), + }); + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; +} + +function extractEventType(body: string): string { + try { + const parsed = JSON.parse(body) as { type?: unknown }; + if (typeof parsed.type === "string" && parsed.type) return parsed.type; + } catch { + // Non-JSON bodies still render; the type is just unknown. + } + return "unknown"; +} + +function forwardPath(forwardTo: string): string { + try { + return new URL(forwardTo).pathname; + } catch { + return forwardTo; + } +} + +export async function webhooksListen(options: WebhooksListenOptions = {}): Promise { + const ndjson = Boolean(options.json) || isAgent(); + const extraHeaders = parseHeaderPairs(options.headers); + const rawFilter = splitCommaList(options.events); + const eventsFilter = rawFilter?.length ? rawFilter : undefined; + + const ctx = await resolveAppContext(options); + + const entry = await getRelayEntry(ctx.instanceId); + let token = entry?.token; + if (!token) { + token = generateRelayToken(); + await setRelayEntry(ctx.instanceId, { ...entry, token }); + } + + const inFlight = new Set>(); + let client: RelayClient | undefined; + let endpointSecret = ""; + + // Own SIGINT handling, registered BEFORE the socket opens. The global + // handler (cli.ts) is a cleanup-free exit(130) and would fire first, so it + // has to go: close the socket, drain in-flight forwards, then exit 130. + // The relay endpoint is never deleted — its URL and secret stay stable. + process.removeAllListeners("SIGINT"); + process.on("SIGINT", () => { + void (async () => { + client?.stop(); + await Promise.allSettled([...inFlight]); + process.exit(EXIT_CODE.SIGINT); + })(); + }); + + async function processDelivery( + event: RelayEventFrame, + reply: (frame: string) => void, + ): Promise { + const body = decodeEventBody(event); + const svixId = event.headers["svix-id"] ?? event.id; + const eventType = extractEventType(body); + + if (!options.skipVerify) { + const verified = verifyWebhookSignature({ + secret: endpointSecret, + id: svixId, + timestamp: event.headers["svix-timestamp"] ?? "", + payload: body, + signature: event.headers["svix-signature"] ?? "", + }); + if (!verified) renderVerificationWarning(svixId); + } + + if (!ndjson) renderArrival(eventType, svixId); + + let outcome: ForwardOutcome | null = null; + if (options.forwardTo) { + outcome = await forwardDelivery({ + forwardTo: options.forwardTo, + method: event.method, + headers: buildForwardHeaders(event.headers, extraHeaders), + body, + }); + reply( + encodeEventResponseFrame({ + id: event.id, + status: outcome.status, + headers: outcome.headers, + bodyB64: outcome.bodyB64, + }), + ); + } else { + // No local handler: frame a synthetic 200 so Svix-side delivery + // telemetry records a completed attempt instead of a hang. + reply(encodeEventResponseFrame({ id: event.id, status: 200, headers: {}, bodyB64: "" })); + } + + if (ndjson) { + log.data( + buildEventLine({ + svixId, + eventType, + headers: event.headers, + bodyB64: event.bodyB64, + forwardStatus: outcome ? outcome.status : null, + latencyMs: outcome?.latencyMs ?? 0, + }), + ); + return; + } + + if (outcome) { + renderForwardResult(outcome, event.method, forwardPath(options.forwardTo!)); + renderForwardDiagnostics(outcome, svixId); + } + } + + client = new RelayClient({ + token, + onEvent: (event, reply) => { + const task = processDelivery(event, reply).catch((error) => { + log.debug(`relay: delivery handling failed: ${errorMessage(error)}`); + }); + inFlight.add(task); + void task.finally(() => inFlight.delete(task)); + }, + onTokenRotated: async (newToken) => { + const current = await getRelayEntry(ctx.instanceId); + await setRelayEntry(ctx.instanceId, { ...current, token: newToken }); + // The registered endpoint must follow the new relay URL or deliveries + // land in the old (now foreign) inbox. + if (current?.endpoint_id) { + try { + await updateWebhookEndpoint(ctx.appId, ctx.instanceId, current.endpoint_id, { + url: relayReceiveUrl(newToken), + }); + } catch (error) { + log.warn( + `Could not re-point the relay endpoint after a token rotation: ${errorMessage(error)}`, + ); + } + } + }, + onReconnect: () => { + log.ui(dim("relay connection lost — reconnecting…\n")); + }, + }); + + await client.start(); + + const endpoint = await ensureRelayEndpoint(ctx, client.token, eventsFilter); + ({ secret: endpointSecret } = await getWebhookEndpointSecret( + ctx.appId, + ctx.instanceId, + endpoint.id, + )); + + const readyInfo = { + relayUrl: relayReceiveUrl(client.token), + signingSecret: endpointSecret, + endpointId: endpoint.id, + eventsFilter: eventsFilter ?? null, + forwardTo: options.forwardTo ?? null, + }; + if (ndjson) { + log.data(buildReadyLine(readyInfo)); + } else { + renderReadyBanner(readyInfo); + } + + // listen never exits 0: it ends via SIGINT (130) or an unrecoverable error (1). + await new Promise(() => {}); +} From 80e910415b7493865a148596996aa1c4fe9faa90 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:48:36 -0300 Subject: [PATCH 20/28] docs(webhooks): sync root README help output and add agent-mode output tests --- README.md | 1 + .../cli-core/src/commands/webhooks/create.test.ts | 11 +++++++++++ .../cli-core/src/commands/webhooks/update.test.ts | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index e372cef3..831f5791 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Commands: open Open Clerk resources in your browser apps Manage your Clerk applications users [options] Manage Clerk users + webhooks [options] Manage webhook endpoints and deliveries env Manage environment variables config Manage instance configuration enable Enable Clerk features on the linked instance diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts index a9392c9a..66f98182 100644 --- a/packages/cli-core/src/commands/webhooks/create.test.ts +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -108,6 +108,17 @@ describe("webhooks create", () => { expect(captured.err).toBe(""); }); + test("emits the same flat JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + }); + test("prints details and the unmasked secret in human mode", async () => { await webhooksCreate({ url: "https://example.com/webhooks" }); diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts index ef983d5d..c5799029 100644 --- a/packages/cli-core/src/commands/webhooks/update.test.ts +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -120,6 +120,14 @@ describe("webhooks update", () => { expect(captured.err).toBe(""); }); + test("outputs the updated endpoint resource in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + }); + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { mockUpdateWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); From 4db010680505b8fcda6d19adb49816497f5e0fe2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:48:37 -0300 Subject: [PATCH 21/28] docs(changeset): add the clerk webhooks command group --- .changeset/webhooks.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/webhooks.md diff --git a/.changeset/webhooks.md b/.changeset/webhooks.md new file mode 100644 index 00000000..9dd245cd --- /dev/null +++ b/.changeset/webhooks.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add the `clerk webhooks` command group for managing webhook endpoints and deliveries from the terminal: `list`, `get`, `create`, `update`, `delete`, `secret [--rotate]`, `event-types`, `messages`, `replay`, `listen`, `trigger`, `verify`, and `open`. From 39f7d96342734a9b8c3f09d85d15c542739a03f1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 17:29:09 -0300 Subject: [PATCH 22/28] style(webhooks): resolve all oxlint warnings in the webhooks group --- .../src/commands/webhooks/listen.test.ts | 27 +++++++++++-------- .../cli-core/src/commands/webhooks/listen.ts | 2 +- .../src/commands/webhooks/render.test.ts | 2 +- .../src/commands/webhooks/verify.test.ts | 5 +++- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index 1091cbb9..99f2c898 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -13,7 +13,8 @@ interface FakeClientOptions { onReconnect: () => void; } -let lastClient: FakeRelayClient | undefined; +const relayClients: FakeRelayClient[] = []; +const lastClient = () => relayClients.at(-1); class FakeRelayClient { token: string; @@ -22,7 +23,7 @@ class FakeRelayClient { constructor(readonly options: FakeClientOptions) { this.token = options.token; - lastClient = this; + relayClients.push(this); } start(): Promise { @@ -121,7 +122,7 @@ describe("webhooks listen", () => { beforeEach(() => { savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; mockIsAgent.mockReturnValue(false); - lastClient = undefined; + relayClients.length = 0; mockResolveAppContext.mockResolvedValue({ appId: "app_1", appLabel: "My App", @@ -165,8 +166,12 @@ describe("webhooks listen", () => { await startListen({}, captured); - const persistedToken = (mockSetRelayEntry.mock.calls[0]?.[1] as { token: string }).token; - expect(mockSetRelayEntry.mock.calls[0]?.[0]).toBe("ins_1"); + const [firstInstanceId, firstEntry] = mockSetRelayEntry.mock.calls[0] as [ + string, + { token: string }, + ]; + const persistedToken = firstEntry.token; + expect(firstInstanceId).toBe("ins_1"); expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { @@ -225,7 +230,7 @@ describe("webhooks listen", () => { await startListen({}, captured); expect(process.listenerCount("SIGINT")).toBe(1); - expect(lastClient?.started).toBe(true); + expect(lastClient()?.started).toBe(true); }); test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { @@ -235,7 +240,7 @@ describe("webhooks listen", () => { captured.clear(); const replies: string[] = []; - lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => replies.push(frame), ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -273,7 +278,7 @@ describe("webhooks listen", () => { captured.clear(); const replies: string[] = []; - lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => replies.push(frame), ); for (let i = 0; i < 20 && replies.length === 0; i++) { @@ -301,7 +306,7 @@ describe("webhooks listen", () => { const event = signedEvent('{"type":"user.created"}'); event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; - lastClient!.options.onEvent(event, () => {}); + lastClient()!.options.onEvent(event, () => {}); await new Promise((resolve) => setTimeout(resolve, 0)); expect(captured.err).toContain("signature verification failed for msg_1"); @@ -316,7 +321,7 @@ describe("webhooks listen", () => { const event = signedEvent('{"type":"user.created"}'); event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; - lastClient!.options.onEvent(event, () => {}); + lastClient()!.options.onEvent(event, () => {}); await new Promise((resolve) => setTimeout(resolve, 0)); expect(captured.err).toBe(""); @@ -325,7 +330,7 @@ describe("webhooks listen", () => { test("token rotation persists the new token and re-points the endpoint URL", async () => { await startListen({}, captured); - await lastClient!.options.onTokenRotated("Zz98Yy76Xx"); + await lastClient()!.options.onTokenRotated("Zz98Yy76Xx"); expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { token: "Zz98Yy76Xx", diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts index 30279f3d..76c5de6e 100644 --- a/packages/cli-core/src/commands/webhooks/listen.ts +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -145,7 +145,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi process.on("SIGINT", () => { void (async () => { client?.stop(); - await Promise.allSettled([...inFlight]); + await Promise.allSettled(inFlight); process.exit(EXIT_CODE.SIGINT); })(); }); diff --git a/packages/cli-core/src/commands/webhooks/render.test.ts b/packages/cli-core/src/commands/webhooks/render.test.ts index a36ca168..19e6a2ad 100644 --- a/packages/cli-core/src/commands/webhooks/render.test.ts +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -107,7 +107,7 @@ describe("human rendering", () => { renderArrival("user.created", "msg_1"); renderForwardResult(outcome({ status: 200 }), "POST", "/api/webhooks"); - const plain = captured.err.replace(/\x1b\[[0-9;]*m/g, ""); + const plain = Bun.stripANSI(captured.err); expect(plain).toMatch(/\d{2}:\d{2}:\d{2} --> user\.created msg_1\n/); expect(plain).toMatch(/\d{2}:\d{2}:\d{2} <-- 200 POST \/api\/webhooks 12ms\n/); }); diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index f5c1dde9..15d4c7a9 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -222,7 +222,10 @@ describe("webhooks verify command", () => { { label: "inline --payload (not @file or -)", options: { - ...{ secret: SECRET, id: ID, timestamp: TIMESTAMP, signature: VALID_SIGNATURE }, + secret: SECRET, + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, payload: "{}", }, }, From e4ebef69fb1480c7ba6ac25a1c0665d62da5c211 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 17:52:51 -0300 Subject: [PATCH 23/28] fix(webhooks): validate trigger event type before endpoint resolution; gate listen deliveries until setup completes --- .../src/commands/webhooks/create.test.ts | 2 +- .../src/commands/webhooks/listen.test.ts | 33 +++++++++++++++++++ .../cli-core/src/commands/webhooks/listen.ts | 16 +++++++++ .../src/commands/webhooks/secret.test.ts | 9 +++++ .../src/commands/webhooks/trigger.test.ts | 9 +++++ .../cli-core/src/commands/webhooks/trigger.ts | 7 ++-- .../src/commands/webhooks/verify.test.ts | 32 +++++++++++++++++- 7 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts index 66f98182..71884100 100644 --- a/packages/cli-core/src/commands/webhooks/create.test.ts +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -134,7 +134,7 @@ describe("webhooks create", () => { const promise = webhooksCreate({ url: "https://example.com/webhooks" }); await expect(promise).rejects.toBeInstanceOf(CliError); - await expect(webhooksCreate({ url: "https://example.com/webhooks" })).rejects.toThrow( + await expect(promise).rejects.toThrow( "Endpoint created (id: ep_new) but the signing secret could not be fetched. " + "Run 'clerk webhooks secret ep_new' to retrieve it.", ); diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index 99f2c898..ad12d256 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -233,6 +233,39 @@ describe("webhooks listen", () => { expect(lastClient()?.started).toBe(true); }); + test("deliveries arriving before the secret fetch wait for setup to finish", async () => { + mockIsAgent.mockReturnValue(true); + let releaseSecret!: (value: { secret: string }) => void; + mockGetWebhookEndpointSecret.mockReturnValue( + new Promise((resolve) => { + releaseSecret = resolve; + }), + ); + + const run = webhooksListen({}); + run.catch(() => {}); + for (let i = 0; i < 50 && !lastClient(); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.out).not.toContain('"type":"event"'); + expect(captured.err).not.toContain("signature verification failed"); + + releaseSecret({ secret: SECRET }); + for (let i = 0; i < 50 && !captured.out.includes('"type":"event"'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const readyIndex = captured.out.indexOf('"ready"'); + const eventIndex = captured.out.indexOf('"type":"event"'); + expect(readyIndex).toBeGreaterThanOrEqual(0); + expect(eventIndex).toBeGreaterThan(readyIndex); + expect(captured.err).not.toContain("signature verification failed"); + }); + test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts index 76c5de6e..83095360 100644 --- a/packages/cli-core/src/commands/webhooks/listen.ts +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -136,6 +136,16 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi const inFlight = new Set>(); let client: RelayClient | undefined; let endpointSecret = ""; + let shuttingDown = false; + + // Deliveries can arrive as soon as the relay handshake completes (flow step + // 2), but the signing secret only lands after the endpoint is resolved (step + // 5) — verifying against the empty secret would warn falsely, so processing + // waits on this gate until the ready line is out. + let releaseSetupGate!: () => void; + const setupGate = new Promise((resolve) => { + releaseSetupGate = resolve; + }); // Own SIGINT handling, registered BEFORE the socket opens. The global // handler (cli.ts) is a cleanup-free exit(130) and would fire first, so it @@ -144,6 +154,8 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi process.removeAllListeners("SIGINT"); process.on("SIGINT", () => { void (async () => { + shuttingDown = true; + releaseSetupGate(); // gated deliveries must settle or the drain hangs client?.stop(); await Promise.allSettled(inFlight); process.exit(EXIT_CODE.SIGINT); @@ -154,6 +166,9 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi event: RelayEventFrame, reply: (frame: string) => void, ): Promise { + await setupGate; + if (shuttingDown) return; + const body = decodeEventBody(event); const svixId = event.headers["svix-id"] ?? event.id; const eventType = extractEventType(body); @@ -265,6 +280,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi } else { renderReadyBanner(readyInfo); } + releaseSetupGate(); // listen never exits 0: it ends via SIGINT (130) or an unrecoverable error (1). await new Promise(() => {}); diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts index 2f7bd0fd..b1eb11b6 100644 --- a/packages/cli-core/src/commands/webhooks/secret.test.ts +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -104,6 +104,15 @@ describe("webhooks secret", () => { expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); }); + test("--rotate --yes in agent mode skips the prompt and rotates", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + test("--rotate in agent mode without --yes is a usage error", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts index 72c074ac..a33eb441 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.test.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -101,6 +101,15 @@ describe("webhooks trigger", () => { expect(mockSendWebhookExample).not.toHaveBeenCalled(); }); + test("unknown event type wins over a missing relay endpoint (fail fast)", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + test("pages through the catalog before declaring a type unknown", async () => { mockListWebhookEventTypes .mockResolvedValueOnce(catalogPage(["user.created"], true, "iter_2")) diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts index 3359254c..d62382a3 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -38,12 +38,15 @@ async function assertKnownEventType( export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise { const ctx = await resolveAppContext(options); - const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); // send_example returns 200 {} asynchronously — an invalid event type would - // otherwise exit 0 and deliver nothing, the silent failure trigger exists to kill. + // otherwise exit 0 and deliver nothing, the silent failure trigger exists to + // kill. Validated first so agents get unknown_event_type even when no relay + // endpoint is configured. await assertKnownEventType(ctx.appId, ctx.instanceId, options.eventType); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + await rejectEndpointNotFound( sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), endpointId, diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index 15d4c7a9..d48d5722 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { createHmac, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -241,6 +241,36 @@ describe("webhooks verify command", () => { ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); }); + test("missing --delivery file maps to file_not_found", async () => { + await expect( + webhooksVerify({ secret: SECRET, delivery: "@/definitely/not/here.json" }), + ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); + }); + + test("reads the --delivery event line from stdin with -", async () => { + const line = JSON.stringify({ + headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(`${line}\n`); + try { + await webhooksVerify({ secret: SECRET, delivery: "-" }); + expect(captured.err).toContain("Signature verified."); + } finally { + stdinSpy.mockRestore(); + } + }); + + test("reads the --payload body from stdin with -", async () => { + const stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(PAYLOAD); + try { + await webhooksVerify({ ...explicitFlags(), payload: "-" }); + expect(captured.err).toContain("Signature verified."); + } finally { + stdinSpy.mockRestore(); + } + }); + test("empty --delivery input is a usage error", async () => { const deliveryPath = await writeTempFile("empty.json", "\n\n"); From 54e980833fb4aaee98c2374249c183ed9308bf11 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:20:19 -0300 Subject: [PATCH 24/28] feat(webhooks): structured agent-mode output for 'webhooks verify' (spec change #26) --- .../cli-core/src/commands/webhooks/README.md | 2 ++ .../src/commands/webhooks/verify.test.ts | 34 ++++++++++++++++++- .../cli-core/src/commands/webhooks/verify.ts | 9 +++-- packages/cli-core/src/lib/errors.ts | 2 ++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 2ae70354..89d857c3 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -208,6 +208,8 @@ clerk webhooks open Verifies a Svix webhook signature **locally**: HMAC-SHA256 over `{id}.{timestamp}.{body}` with the base64-decoded `whsec_` suffix, constant-time compare, any-match across space-separated `v1,` header entries (rotation grace windows produce multiple entries). No network calls, no auth gate (`--app`/`--instance` are ignored). Exit 0 = signature matched; exit 1 = mismatch (with a humanized timestamp-skew hint when the timestamp is >5 minutes off); exit 2 = bad inputs. +Agent/`--json` mode: success prints `{ "valid": true }` on stdout; a mismatch exits 1 with error code `invalid_webhook_signature` in the structured stderr error. + ```sh clerk webhooks verify --secret whsec_... (--delivery @event.json | --payload @body.json --id msg_... --timestamp --signature v1,...) ``` diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index d48d5722..2ef0d4f8 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -1,8 +1,17 @@ -import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createHmac, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; import { @@ -127,10 +136,12 @@ describe("webhooks verify command", () => { let tempDir: string; beforeEach(async () => { + mockIsAgent.mockReturnValue(false); tempDir = await mkdtemp(join(tmpdir(), "clerk-verify-test-")); }); afterEach(async () => { + mockIsAgent.mockReset(); await rm(tempDir, { recursive: true, force: true }); }); @@ -193,6 +204,27 @@ describe("webhooks verify command", () => { ).rejects.toThrow("Signature verification failed"); }); + test("signature mismatch carries invalid_webhook_signature for agent discrimination", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD + "tampered"); + + await expect( + webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }), + ).rejects.toMatchObject({ code: ERROR_CODE.INVALID_WEBHOOK_SIGNATURE }); + }); + + test.each([ + { label: "agent mode", json: undefined, agent: true }, + { label: "--json in a human TTY", json: true, agent: false }, + ])("success in $label emits {valid: true} on stdout", async ({ json, agent }) => { + mockIsAgent.mockReturnValue(agent); + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await webhooksVerify({ ...explicitFlags(), json, payload: `@${payloadPath}` }); + + expect(JSON.parse(captured.out)).toEqual({ valid: true }); + expect(captured.err).toBe(""); + }); + test("mismatch on a stale timestamp includes a humanized skew hint", async () => { const staleTimestamp = String(Number(TIMESTAMP) - 3600); const payloadPath = await writeTempFile("body.json", PAYLOAD); diff --git a/packages/cli-core/src/commands/webhooks/verify.ts b/packages/cli-core/src/commands/webhooks/verify.ts index ec5d127c..aaa04dcd 100644 --- a/packages/cli-core/src/commands/webhooks/verify.ts +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { shouldOutputJson } from "./shared.ts"; export interface WebhooksVerifyOptions { secret?: string; @@ -179,8 +180,12 @@ export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promi if (Math.abs(deltaSeconds) > SKEW_HINT_THRESHOLD_SECONDS) { message += ` Note: the timestamp is ${humanizeSkew(deltaSeconds)} — make sure it is the raw svix-timestamp header from the same delivery as the signature.`; } - throw new CliError(message); + throw new CliError(message, { code: ERROR_CODE.INVALID_WEBHOOK_SIGNATURE }); } - log.success("Signature verified."); + if (shouldOutputJson(options)) { + log.data(JSON.stringify({ valid: true })); + } else { + log.success("Signature verified."); + } } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 025b2fbf..364ece66 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -61,6 +61,8 @@ export const ERROR_CODE = { WEBHOOK_MESSAGE_NOT_FOUND: "webhook_message_not_found", /** Event type is not in the instance's event-type catalog. */ UNKNOWN_EVENT_TYPE: "unknown_event_type", + /** Offline webhook signature verification found no matching entry. */ + INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 522437db04418b642e95f9575bbc60b2e0bad130 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:27:24 -0300 Subject: [PATCH 25/28] fix(webhooks): fail fast on confirmation gates and unblock 'verify' stdin pipes - delete / secret --rotate / replay --since now run the --yes/prompt gate before resolveAppContext, so agent mode gets the deterministic usage error without a network round-trip (and regardless of key validity) - the implicit piped-stdin --input-json expansion now stands down when a literal '-' is in argv, fixing 'verify --delivery -' / '--payload -' which previously had their stdin consumed and rejected as nested JSON --- .../src/commands/webhooks/delete.test.ts | 1 + .../cli-core/src/commands/webhooks/delete.ts | 6 +++-- .../src/commands/webhooks/replay.test.ts | 1 + .../cli-core/src/commands/webhooks/replay.ts | 22 ++++++++++++------- .../src/commands/webhooks/secret.test.ts | 1 + .../cli-core/src/commands/webhooks/secret.ts | 9 ++++++-- packages/cli-core/src/lib/input-json.test.ts | 9 ++++++++ packages/cli-core/src/lib/input-json.ts | 6 +++-- 8 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/delete.test.ts b/packages/cli-core/src/commands/webhooks/delete.test.ts index 928ab6ae..1d0c05de 100644 --- a/packages/cli-core/src/commands/webhooks/delete.test.ts +++ b/packages/cli-core/src/commands/webhooks/delete.test.ts @@ -80,6 +80,7 @@ describe("webhooks delete", () => { code: ERROR_CODE.USAGE_ERROR, }); expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("agent mode with --yes deletes without prompting", async () => { diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts index 61c0480e..b76bf8e5 100644 --- a/packages/cli-core/src/commands/webhooks/delete.ts +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -13,13 +13,15 @@ export interface WebhooksDeleteOptions extends WebhooksGlobalOptions { } export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { - const ctx = await resolveAppContext(options); - + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. await confirmDestructive( `Permanently delete webhook endpoint ${options.endpointId}? This cannot be undone.`, options, ); + const ctx = await resolveAppContext(options); + await rejectEndpointNotFound( deleteWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), options.endpointId, diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts index 06bcf1f6..77f05587 100644 --- a/packages/cli-core/src/commands/webhooks/replay.test.ts +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -157,6 +157,7 @@ describe("webhooks replay", () => { webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("--since maps a PLAPI 404 to webhook_endpoint_not_found", async () => { diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts index a4538f6c..e85bc7a9 100644 --- a/packages/cli-core/src/commands/webhooks/replay.ts +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -47,6 +47,20 @@ function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promise { const mode = validateReplayMode(options); + + const windowLabel = options.until + ? `between ${options.since} and ${options.until}` + : `since ${options.since}`; + + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. + if (mode === "recover") { + await confirmDestructive( + `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, + options, + ); + } + const ctx = await resolveAppContext(options); if (mode === "resend") { @@ -59,14 +73,6 @@ export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promi return; } - const windowLabel = options.until - ? `between ${options.since} and ${options.until}` - : `since ${options.since}`; - await confirmDestructive( - `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, - options, - ); - await rejectEndpointNotFound( recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { since: options.since!, diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts index b1eb11b6..e7c2473e 100644 --- a/packages/cli-core/src/commands/webhooks/secret.test.ts +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -120,6 +120,7 @@ describe("webhooks secret", () => { code: ERROR_CODE.USAGE_ERROR, }); expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { diff --git a/packages/cli-core/src/commands/webhooks/secret.ts b/packages/cli-core/src/commands/webhooks/secret.ts index de9d0978..63860f17 100644 --- a/packages/cli-core/src/commands/webhooks/secret.ts +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -16,13 +16,18 @@ export interface WebhooksSecretOptions extends WebhooksGlobalOptions { } export async function webhooksSecret(options: WebhooksSecretOptions): Promise { - const ctx = await resolveAppContext(options); - + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. if (options.rotate) { await confirmDestructive( `Rotate the signing secret for ${options.endpointId}? The old key keeps verifying for 24h (dual-signing grace).`, options, ); + } + + const ctx = await resolveAppContext(options); + + if (options.rotate) { await rejectEndpointNotFound( rotateWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), options.endpointId, diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 954ac2ee..503ffb41 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -370,5 +370,14 @@ describe("expandInputJson", () => { const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); }); + + test("auto-stdin stands down when a flag reads stdin itself (literal -)", async () => { + const argv = ["clerk", "webhooks", "verify", "--secret", "whsec_x", "--delivery", "-"]; + const result = await expandViaStdin( + argv, + '{"headers":{"svix-id":"msg_1"},"body_b64":"e30="}', + ); + expect(result.result).toEqual(argv); + }); }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index bcc37281..0938e750 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -151,8 +151,10 @@ export async function expandInputJson(argv: string[]): Promise { return argv; } - // No explicit --input-json flag — check for piped stdin - if (hasStdinPipe()) { + // No explicit --input-json flag — check for piped stdin. A literal `-` in + // argv means some flag reads stdin itself (e.g. `verify --delivery -`); the + // implicit JSON slurp must not consume the stream first. + if (hasStdinPipe() && !argv.includes(STDIN_MARKER)) { const jsonStr = await readOptionalStdin(); if (jsonStr === undefined) return argv; const parsed = parseJsonString(jsonStr); From 2c652bb5af120c76a7cce33162f341b878472daa Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:30:26 -0300 Subject: [PATCH 26/28] test(webhooks): spell the no---json agent case as an empty flags object --- packages/cli-core/src/commands/webhooks/verify.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index 2ef0d4f8..8cda7a1d 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -213,13 +213,13 @@ describe("webhooks verify command", () => { }); test.each([ - { label: "agent mode", json: undefined, agent: true }, - { label: "--json in a human TTY", json: true, agent: false }, - ])("success in $label emits {valid: true} on stdout", async ({ json, agent }) => { + { label: "agent mode without --json", flags: {}, agent: true }, + { label: "--json in a human TTY", flags: { json: true }, agent: false }, + ])("success in $label emits {valid: true} on stdout", async ({ flags, agent }) => { mockIsAgent.mockReturnValue(agent); const payloadPath = await writeTempFile("body.json", PAYLOAD); - await webhooksVerify({ ...explicitFlags(), json, payload: `@${payloadPath}` }); + await webhooksVerify({ ...explicitFlags(), ...flags, payload: `@${payloadPath}` }); expect(JSON.parse(captured.out)).toEqual({ valid: true }); expect(captured.err).toBe(""); From dc34c0786643ca2dbdc2975217eab5601c41e11f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:32:47 -0300 Subject: [PATCH 27/28] chore(webhooks): remove stray test fixture committed under packages/cli-core/undefined/ --- packages/cli-core/undefined/body.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/cli-core/undefined/body.json diff --git a/packages/cli-core/undefined/body.json b/packages/cli-core/undefined/body.json deleted file mode 100644 index 098657a7..00000000 --- a/packages/cli-core/undefined/body.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "user.created" } From 9f8329d7d2c0b8233e44257fa5e657b7cabc49ea Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 10 Jun 2026 16:45:05 -0300 Subject: [PATCH 28/28] fix(webhooks): relay token carries the c_ prefix on the wire and in the inbox URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-relay verified: play.svix.com returns 400 'Invalid token' for unprefixed tokens, and the relay only registers an inbox when the start frame carries the same c_ token. With c_ in both, a POST to the inbox round-trips through the WebSocket and the reply frame is accepted — proven end-to-end against the real relay with no PLAPI involvement. Reverses spec change #12 (recorded as spec change #27). --- packages/cli-core/src/commands/webhooks/README.md | 2 +- packages/cli-core/src/commands/webhooks/listen.test.ts | 4 ++-- .../cli-core/src/commands/webhooks/relay-protocol.test.ts | 7 +++---- packages/cli-core/src/commands/webhooks/relay-protocol.ts | 8 ++++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 89d857c3..da3bc50c 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -246,7 +246,7 @@ clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [-- Behavior notes: -- **Relay token**: 10 random base62 chars, raw on the wire (no `c_` prefix), persisted per instance in the CLI config (`relay..token`). Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. +- **Relay token**: `c_` + 10 random base62 chars — the same token goes in the start frame, the inbox URL, and the per-instance CLI config (`relay..token`). Live-relay verified: `play.svix.com` rejects unprefixed tokens, and the relay only registers an inbox when the start frame carries the `c_` token. Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. - **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). After 30s of silence the client sends an active `ws.ping()` probe — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. - **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay ` line; unreachable handler → synthetic **502** framed back to the relay. - **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events). diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index ad12d256..1d5ab0cb 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -161,7 +161,7 @@ describe("webhooks listen", () => { expect(mockResolveAppContext).not.toHaveBeenCalled(); }); - test("first run generates and persists a 10-char base62 token, then creates the endpoint", async () => { + test("first run generates and persists a c_-prefixed base62 token, then creates the endpoint", async () => { mockGetRelayEntry.mockResolvedValue(undefined); await startListen({}, captured); @@ -172,7 +172,7 @@ describe("webhooks listen", () => { ]; const persistedToken = firstEntry.token; expect(firstInstanceId).toBe("ins_1"); - expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); + expect(persistedToken).toMatch(/^c_[0-9A-Za-z]{10}$/); expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { url: `https://play.svix.com/in/${persistedToken}/`, diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts index c818ce5e..62419af2 100644 --- a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts @@ -9,10 +9,9 @@ import { } from "./relay-protocol.ts"; describe("generateRelayToken", () => { - test("produces 10 base62 chars with no prefix", () => { + test("produces c_ + 10 base62 chars (live-relay wire format)", () => { const token = generateRelayToken(); - expect(token).toMatch(/^[0-9A-Za-z]{10}$/); - expect(token.startsWith("c_")).toBe(false); + expect(token).toMatch(/^c_[0-9A-Za-z]{10}$/); }); test("produces distinct tokens across calls", () => { @@ -22,7 +21,7 @@ describe("generateRelayToken", () => { }); describe("relayReceiveUrl", () => { - test("builds the play.svix.com URL with the raw token", () => { + test("builds the play.svix.com URL with the token verbatim", () => { expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://play.svix.com/in/Ab12Cd34Ef/"); }); }); diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.ts index 075c235a..be6b559b 100644 --- a/packages/cli-core/src/commands/webhooks/relay-protocol.ts +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.ts @@ -23,8 +23,12 @@ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const TOKEN_LENGTH = 10; // Largest multiple of 62 below 256; bytes at or above it would bias the modulo. const UNBIASED_BYTE_LIMIT = 248; +// Live-relay verified (2026-06-10): play.svix.com rejects unprefixed tokens +// ("Invalid token"), and the relay only registers an inbox when the start +// frame carries the same c_ token. The prefix is wire format, not cosmetics. +const TOKEN_PREFIX = "c_"; -/** 10 random base62 chars, raw — no `c_` prefix on the wire or in the URL. */ +/** `c_` + 10 random base62 chars — the same token goes in the start frame, the inbox URL, and config. */ export function generateRelayToken(): string { let token = ""; while (token.length < TOKEN_LENGTH) { @@ -36,7 +40,7 @@ export function generateRelayToken(): string { if (token.length === TOKEN_LENGTH) break; } } - return token; + return TOKEN_PREFIX + token; } export function relayReceiveUrl(token: string): string {