diff --git a/.changeset/webhooks-listen-v1.md b/.changeset/webhooks-listen-v1.md new file mode 100644 index 00000000..39678b64 --- /dev/null +++ b/.changeset/webhooks-listen-v1.md @@ -0,0 +1,9 @@ +--- +"clerk": minor +--- + +Add the `clerk webhooks` command group (V1): a PLAPI-free local webhooks toolkit. + +- `clerk webhooks listen` — open a standalone Svix relay tunnel and forward deliveries to a local handler. No auth, no linked project, no backend. `--forward-to` is required. Without `--token`, the banner warns that the auto-generated relay token isn't a guaranteed-stable handle and prints the exact `--token` to pin next time; `--token ` pins an explicit, shareable URL. Flags: `--forward-to` (required), `--token`, `--headers`, `--json`. When `--forward-to` is missing, the usage error prints a runnable example beneath it. +- `clerk webhooks verify` — verify a webhook signature offline (HMAC-SHA256), from a saved `listen` event line (`--delivery`) or the four explicit header values. No network calls. +- `clerk webhooks token` — generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. Prints the bare token to stdout so it pipes: `clerk webhooks listen --token "$(clerk webhooks token)"`. diff --git a/.claude/rules/command-registration.md b/.claude/rules/command-registration.md new file mode 100644 index 00000000..a8dacb56 --- /dev/null +++ b/.claude/rules/command-registration.md @@ -0,0 +1,68 @@ +--- +description: Command registration conventions — every command group registers via register(program) from its index.ts, listed in the registrants array +paths: + - "packages/cli-core/src/cli-program.ts" + - "packages/cli-core/src/commands/*/index.ts" +alwaysApply: false +--- + +Every command group is wired into the root program through a **registrant function**, never inline in `createProgram()`. + +## The pattern + +1. Each command group exports `register(program: Program): void` from `packages/cli-core/src/commands//index.ts`. It builds the whole `program.command("")` subtree (options, arguments, `.setExamples()`, subcommands) and wires each `.action()` to the handler functions in sibling files. +2. `cli-program.ts` imports that function and adds it to the `registrants: CommandRegistrant[]` array. `createProgram()` only configures the root program + global hooks, then loops `for (const register of registrants) register(program)`. + +**Do not** build a command tree inline inside `createProgram()`. If you're adding a `program.command(...)` (or `webhooks`-style group) directly in `cli-program.ts`, stop — move it to a `register` in the group's `index.ts` and append the function to `registrants` instead. + +## `index.ts` shape + +```ts +import type { Program } from "../../cli-program.ts"; +import { list } from "./list.ts"; +import { create } from "./create.ts"; + +export function registerApps(program: Program): void { + const apps = program.command("apps").description("Manage your Clerk applications"); + + apps + .command("list") + .description("List your Clerk applications") + .option("--json", "Output as JSON") + .setExamples([{ command: "clerk apps list", description: "List all applications" }]) + .action(list); + + apps.command("create").argument("", "Application name").action(create); +} +``` + +- Import the `Program` type from `../../cli-program.ts` (type-only — no runtime cycle, this is the established pattern). +- Keep handler _logic_ in sibling files (`list.ts`, `create.ts`, …); `index.ts` is wiring only. A handler-map object (e.g. `const handlers = { list, create }`) is fine when actions need typed `Parameters[0]` casts. + +## `cli-program.ts` shape + +```ts +import { registerApps } from "./commands/apps/index.ts"; +// … +const registrants: CommandRegistrant[] = [ + registerInit, + registerApps, + // … one entry per command group, in display order … + registerExtras, +]; + +export function createProgram(): Program { + const program = new Command() /* … global options … */ as Program; + program.hook("preAction" /* … */); + for (const register of registrants) register(program); + return program; +} +``` + +Helpers used by only one group (e.g. `createOption`, `parseIntegerOption`, `getAuthToken`) belong in that group's `index.ts`, not imported into `cli-program.ts`. + +## Groups with global options, an optional group-level hook, and subcommands + +Build the group exactly as above and attach its concerns inside the same `register`: parent `.option(...)` flags inherited via `optsWithGlobals()`, an optional group `.hook("preAction", …)` for shared gating (e.g. auth), and one `.command(...)` per subcommand. See `commands/users/index.ts` (`registerUsers`) for a multi-subcommand group with a handler-map and options inherited via `optsWithGlobals()`. A group only needs a `preAction` gate when its subcommands require shared setup; auth-free groups like `commands/webhooks/index.ts` (`registerWebhooks`) — `token` (relay token generator), `listen` (relay tunnel), and `verify` (offline HMAC) — omit it entirely. + +Related: [commands.md](./commands.md) (per-command directory + README + agent-mode rules) and [completion.md](./completion.md) (keep `.choices()` / `__complete.ts` in sync when adding commands or options). diff --git a/.oxlintrc.json b/.oxlintrc.json index c5ad2710..2014e3a0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -10,6 +10,8 @@ "files": [ "packages/cli-core/src/cli.ts", "packages/cli-core/src/cli-program.ts", + "packages/cli-core/src/lib/signals.ts", + "packages/cli-core/src/commands/webhooks/listen.ts", "scripts/*" ], "rules": { diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index b353340b..b74608de 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test"; -import { createProgram, formatApiBody } from "./cli-program.ts"; +import { createProgram, formatApiBody, outputJsonError } from "./cli-program.ts"; import { ApiError } from "./lib/errors.ts"; +import { useCaptureLog } from "./test/lib/stubs.ts"; test("registers users as a top-level command", () => { const program = createProgram(); @@ -346,3 +347,23 @@ describe("formatApiBody", () => { expect(result).toBe("Plan limitation"); }); }); + +describe("outputJsonError", () => { + const captured = useCaptureLog(); + + const parse = () => JSON.parse(captured.err.trim()) as { error: Record }; + + test("includes raw {command, description} examples in the payload", () => { + outputJsonError("usage_error", "--forward-to is required.", undefined, undefined, [ + { command: "clerk webhooks listen --forward-to ", description: "Forward events" }, + ]); + expect(parse().error.examples).toEqual([ + { command: "clerk webhooks listen --forward-to ", description: "Forward events" }, + ]); + }); + + test("omits the examples key when there are none", () => { + outputJsonError("usage_error", "boom"); + expect(parse().error).not.toHaveProperty("examples"); + }); +}); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..2efcbde4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -19,6 +19,7 @@ import { registerSwitchEnv } from "./commands/switch-env/index.ts"; import { registerCompletion } from "./commands/completion/index.ts"; import { registerUpdate } from "./commands/update/index.ts"; import { registerDeploy } from "./commands/deploy/index.ts"; +import { registerWebhooks } from "./commands/webhooks/index.ts"; import { getEnvironment } from "./lib/config.ts"; import { setCurrentEnv, @@ -37,7 +38,7 @@ import { isPromptExitError, throwUsageError, } from "./lib/errors.ts"; -import { clerkHelpConfig } from "./lib/help.ts"; +import { clerkHelpConfig, formatExamplesBlock, type Example } from "./lib/help.ts"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; @@ -69,6 +70,7 @@ const registrants: CommandRegistrant[] = [ registerCompletion, registerUpdate, registerDeploy, + registerWebhooks, registerExtras, ]; @@ -234,11 +236,14 @@ export async function runProgram( if (error instanceof CliError) { if (isAgent() && error.code) { - outputJsonError(error.code, error.message, error.docsUrl); + outputJsonError(error.code, error.message, error.docsUrl, undefined, error.examples); } else { if (error.message) { log.error(error.message); } + if (error.examples?.length) { + log.info(`\n${formatExamplesBlock(error.examples)}`); + } if (error.docsUrl) { log.info(`\nFor more information, see: ${error.docsUrl}`); } @@ -303,11 +308,12 @@ interface ApiErrorEntry { } /** Output a structured JSON error to stderr for agent/CI consumption. */ -function outputJsonError( +export function outputJsonError( code: string, message: string, docsUrl?: string, errors?: ApiErrorEntry[], + examples?: Example[], ): void { const payload: { error: { @@ -315,11 +321,15 @@ function outputJsonError( message: string; docsUrl?: string; errors?: ApiErrorEntry[]; + // Raw {command, description} pairs (not the ANSI-formatted human block) so + // an agent can read the runnable fix and retry. + examples?: Example[]; }; } = { error: { code, message }, }; if (docsUrl) payload.error.docsUrl = docsUrl; if (errors?.length) payload.error.errors = errors; + if (examples?.length) payload.error.examples = examples; log.raw(JSON.stringify(payload)); } diff --git a/packages/cli-core/src/cli.ts b/packages/cli-core/src/cli.ts index ad1be3ba..35fddff8 100755 --- a/packages/cli-core/src/cli.ts +++ b/packages/cli-core/src/cli.ts @@ -1,7 +1,9 @@ #!/usr/bin/env bun import { createProgram, runProgram } from "./cli-program.ts"; -import { EXIT_CODE } from "./lib/errors.ts"; -process.on("SIGINT", () => process.exit(EXIT_CODE.SIGINT)); +import { CLI_SIGINT_HANDLER } from "./lib/signals.ts"; +// Named handler (not an inline arrow) so `webhooks listen` can removeListener it +// to install its own graceful-drain SIGINT handling. +process.on("SIGINT", CLI_SIGINT_HANDLER); // Fast path for shell completion — intercept before Commander parses // to avoid validation errors on partial input from Tab presses. 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..0342de1d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -0,0 +1,87 @@ +# `clerk webhooks` (V1) + +The PLAPI-free slice of the webhooks toolkit: a local relay tunnel plus offline +signature verification. Neither subcommand calls the Clerk API, requires auth, or +needs a linked project. + +> **No Clerk API calls.** `listen` talks only to the Svix relay +> (`wss://api.relay.svix.com`); `verify` is pure local HMAC. There is no +> PLAPI/BAPI dependency in this command group. + +## The flow + +```sh +clerk webhooks token # 1. mint a stable token +clerk webhooks listen --token "$(clerk webhooks token)" --forward-to … # 2. stream to your app +clerk webhooks verify --secret whsec_... --delivery @event.json # 3. verify a delivery +``` + +## `clerk webhooks token` + +Generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. The +bare token prints to **stdout** so it pipes cleanly; human mode adds a usage hint +on stderr (which never pollutes the pipe). + +```sh +clerk webhooks token # → c_AbCd123456 +clerk webhooks listen --token "$(clerk webhooks token)" # generate + pin in one step +clerk webhooks token --json # → {"token":"c_AbCd123456"} +``` + +Why it exists: the `--token` format is exact (`c_` + **10** base62 chars), so this +removes the guesswork of hand-writing one. + +## `clerk webhooks listen` + +Open a standalone Svix relay tunnel, print a stable inbox URL, and forward each +delivery to a local handler. + +```sh +clerk webhooks listen --forward-to http://localhost:3000/api/webhooks +``` + +| Option | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `--forward-to ` | **Required.** Local URL to POST deliveries to. | +| `--token ` | Pin the relay token so the inbox URL stays fixed across machines. `c_` + 10 base62 chars (gen with `webhooks token`). | +| `--header ` | Extra header for the forwarded request. Repeat the flag to send multiple headers. `svix-*` headers can't be overridden. | +| `--json` | Emit NDJSON: one `ready` line then one `event` line per delivery (pipe into a file for `webhooks verify --delivery`). | + +**Pin your URL.** Without `--token`, the relay token is generated for you and +persisted locally — but it isn't a guaranteed-stable handle (it can differ on a +new machine, a cleared config, or a rare token collision). When you run `listen` +**without** `--token`, the banner warns you and prints the exact `--token` to pass +next time. For a fixed, shareable URL, always pin it: + +```sh +clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks +``` + +**No verification.** Without the backend there is no per-endpoint signing secret, +so deliveries are forwarded as-is. The original `svix-*` headers are preserved on +the forwarded request, so your handler can still verify against the signing secret +of the dashboard endpoint you point at the inbox URL. + +**Ready line schema (`--json`):** +`{ "type": "ready", "relay_url": "https://webhooks.clerk.com/in/c_AbCd123456/", "forward_to": "http://localhost:3000/api/webhooks" }` — emitted once, then one +`event` line per delivery (and a `{ "type": "reconnecting" }` line if the relay +connection drops). + +## `clerk webhooks verify` + +Verify a Svix webhook signature locally — HMAC-SHA256 over +`{id}.{timestamp}.{payload}`, constant-time matched against every `v1,` +entry in the header (any match wins, covering the 24h rotation grace window). + +```sh +# From a saved `listen` event line: +clerk webhooks verify --secret whsec_... --delivery @event.json + +# From the four explicit header values: +clerk webhooks verify --secret whsec_... --payload @body.json \ + --id msg_2xyz --timestamp 1717935000 --signature v1,abc... +``` + +`--secret` is always required. `--payload`/`--delivery` take `@file` or `-` for +stdin (inline values get mangled by shells). Explicit flags override `--delivery` +fields. 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..05468a8b --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.test.ts @@ -0,0 +1,178 @@ +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, parseHeaderFlag } from "./forward.ts"; + +const originalFetch = globalThis.fetch; + +describe("parseHeaderFlag", () => { + test.each<{ label: string; value: string; expected: [string, string] }>([ + { label: "simple pair", value: "x-env:dev", expected: ["x-env", "dev"] }, + { + label: "value containing colons (split on FIRST colon)", + value: "authorization:Bearer abc:def", + expected: ["authorization", "Bearer abc:def"], + }, + { label: "empty value", value: "x-empty:", expected: ["x-empty", ""] }, + { label: "trims whitespace", value: " x-env : dev ", expected: ["x-env", "dev"] }, + ])("parses $label", ({ value, expected }) => { + expect(parseHeaderFlag(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(() => parseHeaderFlag(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 extra = new Headers({ "x-env": "dev" }); + const headers = buildForwardHeaders(eventHeaders, extra); + + 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 extra = new Headers({ "Content-Type": "text/plain" }); + const headers = buildForwardHeaders(eventHeaders, extra); + + 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 extra = new Headers({ [key]: "v1,forged" }); + const headers = buildForwardHeaders(eventHeaders, extra); + + expect(headers.get("svix-signature")).toBe("v1,abc"); + }); + + test("strips hop-by-hop headers from event headers", () => { + const withHopByHop = { + ...eventHeaders, + host: "svix-relay.example.com", + connection: "keep-alive", + "transfer-encoding": "chunked", + }; + const headers = buildForwardHeaders(withHopByHop, new Headers()); + + expect(headers.has("host")).toBe(false); + expect(headers.has("connection")).toBe(false); + expect(headers.has("transfer-encoding")).toBe(false); + expect(headers.get("svix-id")).toBe("msg_1"); + }); + + test("allows duplicate extra headers via append", () => { + const extra = new Headers(); + extra.append("x-custom", "first"); + extra.append("x-custom", "second"); + const headers = buildForwardHeaders({}, extra); + + expect(headers.get("x-custom")).toBe("first, second"); + }); +}); + +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" }, new Headers()), + 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"); + }); + + test("a fetch timeout/abort yields a synthetic 502", async () => { + stubFetch(async () => { + // Simulate what AbortSignal.timeout(30_000) throws when the deadline fires. + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(true); + expect(outcome.status).toBe(502); + expect(outcome.bodyText).toContain("timeout"); + }); +}); 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..da10864a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.ts @@ -0,0 +1,126 @@ +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; +} + +/** + * Hop-by-hop headers that must not be forwarded to the target. These are + * meaningful only for the single transport hop and would confuse the target + * server or cause protocol violations (e.g. chunked encoding mismatch). + */ +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", +]); + +// The body is decoded and re-framed before echoing, so content-length and +// content-encoding no longer match. Echoing a chunked local response (e.g. +// Next.js dev) made the relay return 502 to the sender despite a successful +// delivery. +const RESPONSE_HEADER_DENYLIST = new Set([ + ...HOP_BY_HOP_HEADERS, + "content-length", + "content-encoding", +]); + +/** + * Parse one `--header` value (`key:value`, split on the FIRST colon, whitespace + * trimmed) into a [key, value] pair. Throws a usage error on malformed input. + */ +export function parseHeaderFlag(value: string): [string, string] { + const colonIndex = value.indexOf(":"); + const key = colonIndex === -1 ? "" : value.slice(0, colonIndex).trim(); + if (!key) { + throwUsageError(`Invalid --header "${value}". Expected key:value (e.g. --header x-env:dev).`); + } + return [key, value.slice(colonIndex + 1).trim()]; +} + +/** + * Delivery headers plus `--header` extras, with hop-by-hop headers stripped. + * 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: Headers, +): Headers { + const headers = new Headers(); + for (const [key, value] of Object.entries(eventHeaders)) { + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + headers.set(key, value); + } + const seenExtra = new Set(); + for (const [key, value] of extraHeaders) { + const lower = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(lower)) continue; + if (lower.startsWith("svix-")) continue; + if (seenExtra.has(lower)) { + headers.append(key, value); + } else { + headers.set(key, value); + seenExtra.add(lower); + } + } + 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, + signal: AbortSignal.timeout(30_000), + }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (RESPONSE_HEADER_DENYLIST.has(key.toLowerCase())) return; + 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/index.ts b/packages/cli-core/src/commands/webhooks/index.ts new file mode 100644 index 00000000..1d0f97f8 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -0,0 +1,104 @@ +import type { Program } from "../../cli-program.ts"; +import { LISTEN_FORWARD_EXAMPLE, webhooksListen } from "./listen.ts"; +import { webhooksToken } from "./token.ts"; +import { webhooksVerify } from "./verify.ts"; + +/** + * V1 webhooks group: the PLAPI-free slice. `listen` is a standalone Svix relay + * tunnel and `verify` is offline HMAC — both run with no auth, no instance + * context, and no backend, so the group has no `preAction` auth gate. + */ +export function registerWebhooks(program: Program): void { + const webhooks = program + .command("webhooks") + .description("Stream webhook events to a local handler and verify their signatures") + .setExamples([ + { + command: "clerk webhooks token", + description: "1. Generate a stable relay token", + }, + { + command: + 'clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks', + description: "2. Stream events to a local handler on a pinned URL", + }, + { + command: "clerk webhooks verify --secret whsec_... --delivery @event.json", + description: "3. Verify a delivery's signature offline", + }, + ]); + + webhooks + .command("token") + .description("Generate a relay token (c_ + 10 base62 chars) for `listen --token`") + .option("--json", 'Output as JSON ({ "token": "c_..." })') + .setExamples([ + { command: "clerk webhooks token", description: "Print a fresh relay token" }, + { + command: + 'clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks', + description: "Generate and pin a token in one step", + }, + ]) + .action((_opts, cmd) => + webhooksToken(cmd.optsWithGlobals() as Parameters[0]), + ); + + webhooks + .command("listen") + .description("Stream webhook events to your terminal and forward them to a local handler") + .option("--forward-to ", "Local URL to POST deliveries to (required)") + .option( + "--token ", + "Pin the relay token so the inbox URL stays fixed across machines. Format: c_ + 10 base62 chars (generate with `clerk webhooks token`)", + ) + .option( + "-H, --header ", + "Extra header for the forwarded request (key:value); repeat for multiple headers (svix-* can't be overridden)", + (val: string, prev: string[] = []) => [...prev, val], + ) + .option("--json", "Output as NDJSON (agent/pipe mode)") + .setExamples([ + LISTEN_FORWARD_EXAMPLE, + { + command: + 'clerk webhooks listen --token "$(clerk webhooks token)" --forward-to http://localhost:3000/api/webhooks', + description: "Pin a stable, shareable relay URL", + }, + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks --json", + description: "Emit NDJSON event lines (pipe into a file for `webhooks verify --delivery`)", + }, + ]) + .action((_opts, cmd) => + webhooksListen(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)") + .option("--json", "Output as JSON") + .setExamples([ + { + command: "clerk webhooks verify --secret whsec_... --delivery @event.json", + description: "Verify a saved `listen` event line", + }, + { + command: + "clerk webhooks verify --secret whsec_... --payload @body.json --id msg_2xyz --timestamp 1717935000 --signature v1,abc...", + description: "Verify from the four header values", + }, + ]) + .action((_opts, cmd) => + webhooksVerify(cmd.optsWithGlobals() as Parameters[0]), + ); +} 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..fc3ec55e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -0,0 +1,313 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { ERROR_CODE } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import type { IRelayClient, RelayClientOptions } from "./relay-client.ts"; +import type { RelayEventFrame } from "./relay-protocol.ts"; + +const relayClients: FakeRelayClient[] = []; +const lastClient = () => relayClients.at(-1); + +class FakeRelayClient implements IRelayClient { + token: string; + started = false; + stopped = false; + + constructor(readonly options: RelayClientOptions) { + this.token = options.token; + relayClients.push(this); + } + + start(): Promise { + this.started = true; + return Promise.resolve(); + } + + stop(): void { + this.stopped = true; + } +} + +mock.module("./relay-client.ts", () => ({ RelayClient: FakeRelayClient })); + +const mockGetRelayEntry = mock(); +const mockSetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + 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 RELAY_KEY = "__relay_only__"; + +function event(body: string, overrides: Partial = {}): RelayEventFrame { + return { + id: "frame_1", + method: "POST", + headers: { + "svix-id": "msg_1", + "svix-timestamp": "1717935000", + "svix-signature": "v1,Zm9yZ2VkIHNpZ25hdHVyZQ==", + "content-type": "application/json", + }, + bodyB64: Buffer.from(body, "utf8").toString("base64"), + ...overrides, + }; +} + +/** listen never resolves; run it and wait until the ready output lands. */ +const FWD = "http://localhost:3000/api/webhooks"; + +async function startListen( + options: Parameters[0], + captured: { out: string; err: string }, +): Promise { + // --forward-to is required; default it so individual tests opt in to overrides. + const run = webhooksListen({ forwardTo: FWD, ...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 (V1, relay-only)", () => { + const captured = useCaptureLog(); + const originalFetch = globalThis.fetch; + let savedSigintListeners: NodeJS.SignalsListener[] = []; + + beforeEach(() => { + savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; + mockIsAgent.mockReturnValue(false); + relayClients.length = 0; + mockGetRelayEntry.mockResolvedValue(undefined); + mockSetRelayEntry.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.removeAllListeners("SIGINT"); + for (const listener of savedSigintListeners) process.on("SIGINT", listener); + globalThis.fetch = originalFetch; + mockGetRelayEntry.mockReset(); + mockSetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("invalid --header is a usage error before any persistence", async () => { + await expect(webhooksListen({ header: ["not-a-pair"] })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockGetRelayEntry).not.toHaveBeenCalled(); + }); + + test("first run generates and persists a c_-prefixed token under the reserved key", async () => { + await startListen({}, captured); + + expect(mockGetRelayEntry).toHaveBeenCalledWith(RELAY_KEY); + const [key, entry] = mockSetRelayEntry.mock.calls[0] as [string, { token: string }]; + expect(key).toBe(RELAY_KEY); + expect(entry.token).toMatch(/^c_[0-9A-Za-z]{10}$/); + expect(lastClient()?.started).toBe(true); + expect(lastClient()?.token).toBe(entry.token); + // No backend: the banner never carries a signing secret. + expect(captured.err).toContain("Webhook relay ready"); + }); + + test("reuses the persisted token across runs (stable URL), no rewrite when unchanged", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "c_Persisted1" }); + + await startListen({}, captured); + + expect(lastClient()?.token).toBe("c_Persisted1"); + expect(mockSetRelayEntry).not.toHaveBeenCalled(); + }); + + test("--token pins the token", async () => { + await startListen({ token: "c_Pinned1234" }, captured); + + expect(lastClient()?.token).toBe("c_Pinned1234"); + expect(mockSetRelayEntry).toHaveBeenCalledWith(RELAY_KEY, { token: "c_Pinned1234" }); + }); + + test("--token with a malformed value is a usage error", async () => { + await expect(webhooksListen({ token: "nope" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("without --token, the banner warns the token is auto-generated and how to pin it", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({ forwardTo: "http://localhost:3000/api/webhooks" }, captured); + + expect(captured.err).toContain("auto-generated relay token"); + expect(captured.err).toContain("--token"); + expect(captured.err).toContain("clerk webhooks token"); + }); + + test("with --token, no auto-generated-token warning is shown", async () => { + await startListen( + { token: "c_Pinned1234", forwardTo: "http://localhost:3000/api/webhooks" }, + captured, + ); + + expect(captured.err).not.toContain("auto-generated relay token"); + }); + + test("emits a lean NDJSON ready line in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + mockGetRelayEntry.mockResolvedValue({ token: "c_Stable9999" }); + + 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://webhooks.clerk.com/in/c_Stable9999/", + forward_to: "http://localhost:3000/api/webhooks", + }); + expect(ready).not.toHaveProperty("signing_secret"); + expect(ready).not.toHaveProperty("endpoint_id"); + }); + + 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("missing --forward-to is a usage error before any persistence", async () => { + await expect(webhooksListen({})).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockGetRelayEntry).not.toHaveBeenCalled(); + }); + + test("missing --forward-to carries a -placeholder example for the handler to show", async () => { + await expect(webhooksListen({})).rejects.toMatchObject({ + examples: [ + { + command: "clerk webhooks listen --forward-to ", + description: expect.any(String), + }, + ], + }); + }); + + test.each(["not-a-url", "", "ftp://example.com"])( + "invalid --forward-to %p is a usage error", + async (bad) => { + await expect(webhooksListen({ forwardTo: bad })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }, + ); + + test("svix-* in --header warns at startup that it can't be overridden", async () => { + await startListen({ header: ["svix-id:forged"] }, captured); + expect(captured.err).toContain("can't be overridden"); + }); + + 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", header: ["x-env:dev"] }, + captured, + ); + captured.clear(); + + const replies: string[] = []; + lastClient()!.options.onEvent(event('{"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("forwards without verifying — a forged signature produces no warning", async () => { + mockIsAgent.mockReturnValue(true); + stubFetch(async () => new Response("ok", { status: 200 })); + + await startListen({}, captured); + captured.clear(); + + // Signature is intentionally bogus; V1 has no signing secret, so no verify. + lastClient()!.options.onEvent(event('{"type":"user.created"}'), () => {}); + for (let i = 0; i < 20 && !captured.out.includes('"type":"event"'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(captured.err).not.toContain("verification"); + expect(captured.out).toContain('"type":"event"'); + }); + + test("token rotation persists the new token under the reserved key", async () => { + await startListen({}, captured); + + await lastClient()!.options.onTokenRotated("c_Zz98Yy76Xx"); + + expect(mockSetRelayEntry).toHaveBeenLastCalledWith(RELAY_KEY, { token: "c_Zz98Yy76Xx" }); + }); + + test("SIGINT stops the relay client and exits 130", async () => { + await startListen({}, captured); + + const exitSpy = spyOn(process, "exit").mockImplementation((() => {}) as () => never); + try { + process.emit("SIGINT"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(lastClient()!.stopped).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(130); + } finally { + exitSpy.mockRestore(); + } + }); + + test("onReconnect logs a reconnecting message to stderr (human mode)", async () => { + await startListen({}, captured); + captured.clear(); + + lastClient()!.options.onReconnect(); + + expect(captured.err).toContain("reconnect"); + }); + + test("onReconnect emits a structured NDJSON line in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + await startListen({}, captured); + captured.clear(); + + lastClient()!.options.onReconnect(); + + expect(JSON.parse(captured.out.trim())).toEqual({ type: "reconnecting" }); + }); +}); 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..2d7bd2b7 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -0,0 +1,244 @@ +import { getRelayEntry, setRelayEntry } from "../../lib/config.ts"; +import { EXIT_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { CLI_SIGINT_HANDLER } from "../../lib/signals.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { dim } from "../../lib/color.ts"; +import type { Example } from "../../lib/help.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; +import { buildForwardHeaders, forwardDelivery, parseHeaderFlag } 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, + renderUnpinnedTokenHint, +} from "./render.ts"; +import type { WebhooksGlobalOptions } from "./shared.ts"; + +export interface WebhooksListenOptions extends WebhooksGlobalOptions { + forwardTo?: string; + header?: string[]; + token?: string; +} + +// Reserved config key for the standalone relay token. V1 ships a single tunnel, +// so there is no per-instance keying — one persisted token keeps the URL stable. +const RELAY_KEY = "__relay_only__"; + +// The canonical "right way" to run `listen`, used in the command's help +// (index.ts) where a concrete, runnable URL is the most useful. +export const LISTEN_FORWARD_EXAMPLE: Example = { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward webhook events to a local handler", +}; + +// Shown beneath the missing-`--forward-to` usage error. Mirrors the error's own +// `` placeholder rather than a concrete URL, so the shape — not a specific +// value — is what stands out. +const MISSING_FORWARD_EXAMPLE: Example = { + command: "clerk webhooks listen --forward-to ", + description: "Forward webhook events to a local handler", +}; + +/** Relay tokens are `c_` + 10 base62 chars; the relay rejects other shapes. */ +function assertRelayToken(token: string): void { + if (!/^c_[0-9A-Za-z]{10}$/.test(token)) { + throwUsageError( + `Invalid --token "${token}". A relay token is \`c_\` followed by 10 base62 chars (e.g. c_AbCd123456).`, + ); + } +} + +// Validated manually (not Commander's .requiredOption) so a missing/invalid +// value is OUR usage error — JSON in agent mode, not Commander's plain text. +function assertForwardTo(forwardTo: string | undefined): string { + if (!forwardTo) + throwUsageError("--forward-to is required.", undefined, undefined, [ + MISSING_FORWARD_EXAMPLE, + ]); + let url: URL; + try { + url = new URL(forwardTo); + } catch { + return throwUsageError( + `Invalid --forward-to URL "${forwardTo}". Expected http:// or https:// (e.g. http://localhost:3000).`, + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throwUsageError( + `--forward-to must use http:// or https://; got "${url.protocol.replace(":", "")}://".`, + ); + } + return forwardTo; +} + +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 forwardTo = assertForwardTo(options.forwardTo); + + if (options.token) assertRelayToken(options.token); + + const extraHeaders = new Headers(); + for (const raw of options.header ?? []) { + const [key, value] = parseHeaderFlag(raw); + if (key.toLowerCase().startsWith("svix-")) { + log.warn(`--header: "${key}" can't be overridden — delivery svix-* headers always win.`); + continue; + } + extraHeaders.append(key, value); + } + + // Persist the token so the inbox URL stays stable across runs; --token pins + // an explicit one. No Clerk backend is involved. + const existing = await getRelayEntry(RELAY_KEY); + const token = options.token ?? existing?.token ?? generateRelayToken(); + if (token !== existing?.token) await setRelayEntry(RELAY_KEY, { token }); + + const inFlight = new Set>(); + let tokenRotationTask: Promise | undefined; + let client: RelayClient | undefined; + let shuttingDown = false; + + // Deliveries can arrive the moment the relay handshake completes, but the + // ready banner/line must print first. Gate processing until setup is done; + // the SIGINT path also resolves it so the drain can never hang. + const { promise: setupGate, resolve: resolveSetupGate } = Promise.withResolvers(); + + // Own SIGINT handling, registered BEFORE the socket opens. The global handler + // (cli.ts) is a cleanup-free exit(130) and would fire first, so remove it: + // close the socket, drain in-flight forwards, then exit 130. + process.removeListener("SIGINT", CLI_SIGINT_HANDLER); + process.on("SIGINT", () => { + if (shuttingDown) { + // Double Ctrl+C: force-quit immediately. + process.exit(EXIT_CODE.SIGINT); + } + void (async () => { + shuttingDown = true; // MUST precede resolveSetupGate so processDelivery short-circuits + resolveSetupGate(); // gated deliveries must settle or the drain hangs + client?.stop(); + const pending = [...inFlight, ...(tokenRotationTask ? [tokenRotationTask] : [])]; + await Promise.race([ + Promise.allSettled(pending), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + process.exit(EXIT_CODE.SIGINT); + })(); + }); + + async function processDelivery( + 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); + + if (!ndjson) renderArrival(eventType, svixId); + + const outcome = await forwardDelivery({ + forwardTo, + method: event.method, + headers: buildForwardHeaders(event.headers, extraHeaders), + body, + }); + reply( + encodeEventResponseFrame({ + id: event.id, + status: outcome.status, + headers: outcome.headers, + bodyB64: outcome.bodyB64, + }), + ); + + if (ndjson) { + log.data( + buildEventLine({ + svixId, + eventType, + headers: event.headers, + bodyB64: event.bodyB64, + forwardStatus: outcome.status, + latencyMs: outcome.latencyMs, + }), + ); + return; + } + + renderForwardResult(outcome, event.method, forwardPath(forwardTo)); + renderForwardDiagnostics(outcome, svixId); + } + + client = new RelayClient({ + token, + onEvent: (event, reply) => { + const task = processDelivery(event, reply).catch((error) => { + log.warn(`relay: delivery dropped: ${errorMessage(error)}`); + }); + inFlight.add(task); + void task.finally(() => inFlight.delete(task)); + }, + onTokenRotated: (newToken) => { + // Persist the new token so the next run reuses it. There's no registered + // endpoint to re-point (a dashboard endpoint needs a manual URL update + // after a collision, which is rare). + tokenRotationTask = setRelayEntry(RELAY_KEY, { token: newToken }); + return tokenRotationTask; + }, + onReconnect: () => { + if (ndjson) { + log.data(JSON.stringify({ type: "reconnecting" })); + } else { + log.ui(dim("relay connection lost — reconnecting…\n")); + } + }, + }); + + // Spinner is a no-op in agent/--json mode (isHuman() guard in lib/spinner.ts), + // so NDJSON stdout stays clean; on a failed handshake it stops with "Failed". + await withSpinner("Connecting to the webhook relay…", () => client.start()); + + const readyInfo = { relayUrl: relayReceiveUrl(client.token), forwardTo }; + if (ndjson) { + log.data(buildReadyLine(readyInfo)); + } else { + renderReadyBanner(readyInfo); + if (!options.token) renderUnpinnedTokenHint(client.token); + } + resolveSetupGate(); + + // 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/relay-client.test.ts b/packages/cli-core/src/commands/webhooks/relay-client.test.ts new file mode 100644 index 00000000..f498bcad --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-client.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { RelayClient } from "./relay-client.ts"; +import { + RELAY_CLOSE_TOKEN_COLLISION, + RELAY_RECONNECT_DELAY_MS, + RELAY_SILENCE_TIMEOUT_MS, + encodeStartFrame, +} from "./relay-protocol.ts"; + +/** + * Stand-in for Bun's client WebSocket. Records what the client sends/pings, + * and exposes manual `open()`/`message()`/`fireClose()` triggers so tests drive + * the connection lifecycle without a real socket. + */ +class FakeWebSocket { + static OPEN = 1; + static instances: FakeWebSocket[] = []; + + readyState = FakeWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + sent: string[] = []; + pingCount = 0; + closedWith: number | undefined; + pingThrows = false; + + constructor(readonly url: string) { + FakeWebSocket.instances.push(this); + } + + send(frame: string): void { + this.sent.push(frame); + } + + ping(): void { + if (this.pingThrows) throw new Error("dead link"); + this.pingCount++; + } + + close(code?: number): void { + this.closedWith = code; + } + + open(): void { + this.onopen?.(); + } + + message(data: string): void { + this.onmessage?.({ data }); + } + + fireClose(code: number): void { + this.onclose?.({ code }); + } +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** Fetch a constructed socket, asserting it exists (satisfies noUncheckedIndexedAccess). */ +function wsAt(index: number): FakeWebSocket { + const ws = FakeWebSocket.instances[index]; + if (!ws) throw new Error(`expected a relay socket at index ${index}`); + return ws; +} + +// Captured timer callbacks so tests can invoke them on demand instead of waiting. +let intervalCallback: (() => void) | undefined; +let intervalDelay: number | undefined; +let timeoutCallback: (() => void) | undefined; +let timeoutDelay: number | undefined; +let now = 0; + +const realWebSocket = globalThis.WebSocket; +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; +const realSetTimeout = globalThis.setTimeout; +const realNow = Date.now; + +describe("RelayClient", () => { + useCaptureLog(); + + beforeEach(() => { + FakeWebSocket.instances = []; + intervalCallback = undefined; + intervalDelay = undefined; + timeoutCallback = undefined; + timeoutDelay = undefined; + now = 1_000_000; + + (globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeWebSocket; + Date.now = () => now; + globalThis.setInterval = ((fn: () => void, delay?: number) => { + intervalCallback = fn; + intervalDelay = delay; + return 1 as unknown as ReturnType; + }) as typeof setInterval; + globalThis.clearInterval = (() => {}) as typeof clearInterval; + globalThis.setTimeout = ((fn: () => void, delay?: number) => { + timeoutCallback = fn; + timeoutDelay = delay; + return 2 as unknown as ReturnType; + }) as unknown as typeof setTimeout; + }); + + afterEach(() => { + (globalThis as unknown as { WebSocket: unknown }).WebSocket = realWebSocket; + globalThis.setInterval = realSetInterval; + globalThis.clearInterval = realClearInterval; + globalThis.setTimeout = realSetTimeout; + Date.now = realNow; + }); + + function makeClient(overrides: Partial[0]> = {}) { + const events: Array<{ id: string }> = []; + const rotated: string[] = []; + let reconnects = 0; + const client = new RelayClient({ + token: "c_original00", + url: "ws://relay.test", + onEvent: (event) => events.push(event), + onTokenRotated: async (token) => { + rotated.push(token); + }, + onReconnect: () => { + reconnects++; + }, + ...overrides, + }); + return { + client, + events, + rotated, + get reconnects() { + return reconnects; + }, + }; + } + + async function openClient(overrides?: Partial[0]>) { + const harness = makeClient(overrides); + const started = harness.client.start(); + wsAt(0).open(); + await started; + return harness; + } + + test("start() dials the override URL and sends the c_-prefixed start frame", async () => { + const { client } = await openClient(); + const ws = wsAt(0); + + expect(ws.url).toBe("ws://relay.test"); + expect(ws.sent[0]).toBe(encodeStartFrame("c_original00")); + expect(client.token).toBe("c_original00"); + }); + + test("schedules the keepalive probe at RELAY_SILENCE_TIMEOUT_MS / 2", async () => { + await openClient(); + expect(intervalDelay).toBe(RELAY_SILENCE_TIMEOUT_MS / 2); + }); + + test("keepalive pings only after RELAY_SILENCE_TIMEOUT_MS of silence", async () => { + await openClient(); + const ws = wsAt(0); + + now += RELAY_SILENCE_TIMEOUT_MS - 1; // still within the window + intervalCallback?.(); + expect(ws.pingCount).toBe(0); + + now += 2; // now past the silence threshold + intervalCallback?.(); + expect(ws.pingCount).toBe(1); + }); + + test("an inbound message resets the silence clock, deferring the next ping", async () => { + const { events } = await openClient(); + const ws = wsAt(0); + + now += RELAY_SILENCE_TIMEOUT_MS - 5; + ws.message( + JSON.stringify({ + type: "event", + data: { id: "frm_1", method: "POST", headers: {}, body: "" }, + }), + ); + expect(events).toHaveLength(1); + + // Only 5ms have elapsed since the message reset lastActivityAt. + now += 5; + intervalCallback?.(); + expect(ws.pingCount).toBe(0); + }); + + test("a dead-link ping closes the socket so the redial path fires", async () => { + await openClient(); + const ws = wsAt(0); + ws.pingThrows = true; + + now += RELAY_SILENCE_TIMEOUT_MS + 1; + intervalCallback?.(); + expect(ws.closedWith).toBeUndefined(); // close() called with no code on ping failure + expect(ws.pingCount).toBe(0); + }); + + test("a non-1008 close reconnects with the SAME token after the reconnect delay", async () => { + const harness = await openClient(); + wsAt(0).fireClose(1006); + + expect(harness.reconnects).toBe(1); + expect(timeoutDelay).toBe(RELAY_RECONNECT_DELAY_MS); + expect(FakeWebSocket.instances).toHaveLength(1); // no redial until the timer fires + + timeoutCallback?.(); + expect(FakeWebSocket.instances).toHaveLength(2); + wsAt(1).open(); + expect(wsAt(1).sent[0]).toBe(encodeStartFrame("c_original00")); + expect(harness.rotated).toHaveLength(0); + }); + + test("a 1008 collision rotates to a fresh c_ token, persists it, and redials after the reconnect delay", async () => { + const harness = await openClient(); + wsAt(0).fireClose(RELAY_CLOSE_TOKEN_COLLISION); + await flush(); + + expect(harness.client.token).not.toBe("c_original00"); + expect(harness.client.token).toMatch(/^c_[0-9A-Za-z]{10}$/); + expect(harness.rotated).toEqual([harness.client.token]); + + // Redial is deferred through the reconnect backoff so a relay that rejects + // every fresh token can't drive a zero-delay reconnect storm. + expect(FakeWebSocket.instances).toHaveLength(1); + expect(timeoutDelay).toBe(RELAY_RECONNECT_DELAY_MS); + + timeoutCallback?.(); + expect(FakeWebSocket.instances).toHaveLength(2); + wsAt(1).open(); + expect(wsAt(1).sent[0]).toBe(encodeStartFrame(harness.client.token)); + expect(harness.reconnects).toBe(0); // collision is not a generic reconnect + }); + + test("stop() before the socket opens suppresses the start frame and the keepalive probe", async () => { + const harness = makeClient(); + void harness.client.start(); // never resolves; the socket never finishes opening + const ws = wsAt(0); + + harness.client.stop(); + ws.open(); + + expect(ws.sent).toHaveLength(0); // no start frame on a stopped client + expect(ws.closedWith).toBe(1000); + expect(intervalCallback).toBeUndefined(); // probe timer never armed + }); + + test("stop() closes with 1000 and suppresses any further reconnect", async () => { + const harness = await openClient(); + const ws = wsAt(0); + + harness.client.stop(); + expect(ws.closedWith).toBe(1000); + + ws.fireClose(1000); + expect(harness.reconnects).toBe(0); + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + test("ignores non-event frames without invoking onEvent", async () => { + const { events } = await openClient(); + const ws = wsAt(0); + + ws.message(JSON.stringify({ type: "pong" })); + ws.message("not json"); + expect(events).toHaveLength(0); + }); + + test("start() rejects when the socket never opens within the first-connect timeout", async () => { + const harness = makeClient(); + const started = harness.client.start(); + // The fake setTimeout captures the start-timeout callback; fire it manually + // to simulate the deadline expiring before the socket ever opens. + expect(timeoutDelay).toBe(30_000); // default first-connect deadline + timeoutCallback?.(); + await expect(started).rejects.toThrow("Cannot reach the Svix relay"); + // The client must be stopped so no reconnect loop runs. + wsAt(0).fireClose(1006); + expect(harness.reconnects).toBe(0); + expect(FakeWebSocket.instances).toHaveLength(1); + }); +}); 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..428c1073 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -0,0 +1,168 @@ +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; + /** Override the first-connect deadline (ms). Default 30 000. Tests pass a small value. */ + firstConnectTimeoutMs?: number; +} + +export interface IRelayClient { + token: string; + start(): Promise; + stop(): void; +} + +/** + * 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 implements IRelayClient { + token: string; + + private ws: WebSocket | undefined; + private stopped = false; + private probeTimer: ReturnType | undefined; + private lastActivityAt = Date.now(); + private resolveFirstOpen: (() => void) | undefined; + private rejectFirstOpen: ((err: Error) => void) | undefined; + private startTimeoutId: ReturnType | undefined; + + constructor(private readonly options: RelayClientOptions) { + this.token = options.token; + } + + /** Dial and resolve once the first connection is open and handshaken. */ + start(): Promise { + const FIRST_CONNECT_TIMEOUT_MS = this.options.firstConnectTimeoutMs ?? 30_000; + const { promise: opened, resolve, reject } = Promise.withResolvers(); + this.rejectFirstOpen = reject; + this.resolveFirstOpen = () => { + clearTimeout(this.startTimeoutId); + this.startTimeoutId = undefined; + resolve(); + }; + this.startTimeoutId = setTimeout(() => { + this.stopped = true; + this.clearProbe(); + this.ws?.close(); + this.rejectFirstOpen?.( + new Error("Cannot reach the Svix relay — check your network and try again."), + ); + }, FIRST_CONNECT_TIMEOUT_MS); + 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 = () => { + // stop() may have raced in between `new WebSocket` and this open; if so, + // don't send the start frame, arm the probe timer, or resolve start(). + if (this.stopped) { + ws.close(1000); + return; + } + log.debug(`relay: connected, sending start frame (token=${this.token.slice(0, 2)}***)`); + ws.send(encodeStartFrame(this.token)); + this.lastActivityAt = Date.now(); + this.startProbe(ws); + this.resolveFirstOpen?.(); + this.resolveFirstOpen = undefined; + this.rejectFirstOpen = 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. Reconnect + // through the same backoff as a normal drop so a relay that rejects + // every fresh token can't spin a zero-delay reconnect storm. + this.token = generateRelayToken(); + log.debug("relay: token collision (1008), rotating token"); + void this.options + .onTokenRotated(this.token) + .catch(() => log.debug("relay: failed to persist rotated token")) + .finally(() => { + if (this.stopped) return; + setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); + }); + 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(); + this.lastActivityAt = Date.now(); + } 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/relay-protocol.test.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts new file mode 100644 index 00000000..043092c7 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts @@ -0,0 +1,103 @@ +import { test, expect, describe } from "bun:test"; +import { + decodeEventBody, + decodeFrame, + encodeEventResponseFrame, + encodeStartFrame, + generateRelayToken, + relayReceiveUrl, +} from "./relay-protocol.ts"; + +describe("generateRelayToken", () => { + test("produces c_ + 10 base62 chars (live-relay wire format)", () => { + const token = generateRelayToken(); + expect(token).toMatch(/^c_[0-9A-Za-z]{10}$/); + }); + + 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 webhooks.clerk.com URL with the token verbatim", () => { + expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://webhooks.clerk.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..a7e0e6ed --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.ts @@ -0,0 +1,112 @@ +/** + * 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; +// 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_"; + +/** `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) { + 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_PREFIX + token; +} + +export function relayReceiveUrl(token: string): string { + return `https://webhooks.clerk.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 }, + }); +} 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..5c17abed --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -0,0 +1,149 @@ +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, + renderUnpinnedTokenHint, +} 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://webhooks.clerk.com/in/Ab12Cd34Ef/", + forwardTo: "http://localhost:3000/api/webhooks", + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "ready", + relay_url: "https://webhooks.clerk.com/in/Ab12Cd34Ef/", + forward_to: "http://localhost:3000/api/webhooks", + }); + }); +}); + +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 relay URL, forwarding target, and dashboard link", () => { + renderReadyBanner({ + relayUrl: "https://webhooks.clerk.com/in/Ab12Cd34Ef/", + forwardTo: "http://localhost:3000/api/webhooks", + }); + + expect(captured.err).toContain("https://webhooks.clerk.com/in/Ab12Cd34Ef/"); + expect(captured.err).toContain("http://localhost:3000/api/webhooks"); + expect(captured.err).toContain("dashboard.clerk.com/last-active?path=webhooks"); + expect(captured.err).toContain("Verification:"); + 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 = 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/); + }); + + test("unpinned-token hint shows the current token and how to pin it", () => { + renderUnpinnedTokenHint("c_Ab12Cd34Ef"); + + expect(captured.err).toContain("auto-generated relay token"); + expect(captured.err).toContain("--token c_Ab12Cd34Ef"); + expect(captured.err).toContain("clerk webhooks token"); + }); + + 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 a re-trigger hint", () => { + renderForwardDiagnostics(outcome({ status: 500, bodyText: "stack trace here" }), "msg_9"); + + expect(captured.err).toContain("stack trace here"); + expect(captured.err).toContain("re-trigger the event"); + expect(captured.err).toContain("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..22c4fd29 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.ts @@ -0,0 +1,139 @@ +/** + * 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; + forwardTo: string; +} + +/** NDJSON ready line (stdout in agent/--json mode). */ +export function buildReadyLine(info: ReadyInfo): string { + return JSON.stringify({ + type: "ready", + relay_url: info.relayUrl, + forward_to: info.forwardTo, + }); +} + +/** 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 { + log.ui( + [ + "", + `${bold("Webhook relay ready")}`, + ` URL: ${info.relayUrl}`, + ` Forwarding to: ${info.forwardTo}`, + ` Verification: ${dim("off (no signing secret; verify with your Dashboard endpoint secret)")}`, + "", + ` ${dim("Add this Relay URL as an endpoint in the Clerk Dashboard to receive real events:")}`, + ` ${cyan(info.relayUrl)}`, + ` ${dim("Open the Dashboard webhooks page to add it:")}`, + ` ${cyan("https://dashboard.clerk.com/last-active?path=webhooks")}`, + ` ${dim("Or POST any JSON to the Relay URL above to inject a test delivery.")}`, + ` ${dim("Press Ctrl+C to stop.")}`, + "", + "", + ].join("\n"), + ); +} + +/** + * Shown after the ready banner when `listen` ran WITHOUT `--token`: the relay + * token was auto-generated and isn't guaranteed stable (it can differ across + * machines, a cleared config, or a rare token collision). Nudge toward pinning + * a fixed, shareable URL — ideally the current one, so it never moves. + */ +export function renderUnpinnedTokenHint(token: string): void { + log.ui( + yellow(" ! Using an auto-generated relay token — it can change across machines,\n") + + yellow(" a cleared config, or a rare token collision.\n") + + dim(" To lock this exact URL, always pass --token:\n") + + dim(` clerk webhooks listen --token ${token} --forward-to \n`) + + dim(" Generate a fresh token anytime with: clerk webhooks token\n\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`, + ); +} + +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 re-trigger the event (delivery ${svixId}).\n`), + ); + } +} 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..ed29f66a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -0,0 +1,11 @@ +import { isAgent } from "../../mode.ts"; + +/** Flags inherited from the `webhooks` group. V1 only carries `--json`. */ +export interface WebhooksGlobalOptions { + json?: boolean; +} + +/** JSON on stdout when `--json` is set or we're in agent mode. */ +export function shouldOutputJson(options: { json?: boolean }): boolean { + return Boolean(options.json) || isAgent(); +} diff --git a/packages/cli-core/src/commands/webhooks/token.test.ts b/packages/cli-core/src/commands/webhooks/token.test.ts new file mode 100644 index 00000000..aef9c22a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/token.test.ts @@ -0,0 +1,62 @@ +import { test, expect, describe, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksToken } = await import("./token.ts"); + +const TOKEN_RE = /^c_[0-9A-Za-z]{10}$/; + +describe("webhooks token", () => { + const captured = useCaptureLog(); + + test("prints the bare token on stdout and a Next steps block on stderr (human)", async () => { + mockIsAgent.mockReturnValue(false); + await webhooksToken({}); + + const token = captured.out.trim(); + expect(token).toMatch(TOKEN_RE); + expect(captured.err).toContain("Next steps"); + expect(captured.err).toContain("That's your relay token (above)"); // labels the bare stdout token + expect(captured.err).toContain(`--token ${token}`); // step references the same token + expect(captured.err).toContain("dashboard.clerk.com/last-active?path=webhooks"); // links to register the endpoint + }); + + test("--json prints a { token } object and no Next steps", async () => { + mockIsAgent.mockReturnValue(false); + await webhooksToken({ json: true }); + + const parsed = JSON.parse(captured.out) as { token: string }; + expect(parsed.token).toMatch(TOKEN_RE); + expect(captured.err).toBe(""); + }); + + test("agent mode still prints the BARE token (pipeable) with no Next steps", async () => { + // Command substitution `$(clerk webhooks token)` runs non-interactively, so + // the bare token — not JSON — must be the default stdout output. + mockIsAgent.mockReturnValue(true); + await webhooksToken({}); + + expect(captured.out.trim()).toMatch(TOKEN_RE); + expect(captured.err).toBe(""); + }); + + test("successive calls produce different tokens", async () => { + mockIsAgent.mockReturnValue(false); + await webhooksToken({}); + const first = captured.out.trim().split("\n")[0]; + captured.clear(); + await webhooksToken({}); + const second = captured.out.trim().split("\n")[0]; + + expect(first).toMatch(TOKEN_RE); + expect(second).toMatch(TOKEN_RE); + expect(first).not.toBe(second); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/token.ts b/packages/cli-core/src/commands/webhooks/token.ts new file mode 100644 index 00000000..807ad724 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/token.ts @@ -0,0 +1,31 @@ +import { cyan } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { outro } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { generateRelayToken } from "./relay-protocol.ts"; +import type { WebhooksGlobalOptions } from "./shared.ts"; + +export type WebhooksTokenOptions = WebhooksGlobalOptions; + +/** + * Generate a valid relay token (`c_` + 10 base62 chars) for `listen --token`. + * + * The bare token is ALWAYS the stdout output (unless `--json`), so it pipes + * cleanly — including under command substitution, which runs non-interactively: + * clerk webhooks listen --token "$(clerk webhooks token)" + * In interactive (human) mode we also print an animated "Next steps" block on + * stderr so the pinning command is explicit; it never pollutes the stdout pipe. + */ +export async function webhooksToken(options: WebhooksTokenOptions = {}): Promise { + const token = generateRelayToken(); + if (options.json) { + log.data(JSON.stringify({ token })); + return; + } + log.data(token); + if (isAgent()) return; + await outro([ + `That's your relay token (above). Use it:\n ${cyan(`clerk webhooks listen --token ${token} --forward-to `)}`, + `Register the Relay URL it prints as an endpoint in your Clerk Dashboard:\n ${cyan("https://dashboard.clerk.com/last-active?path=webhooks")}`, + ]); +} 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..42cf4ea4 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -0,0 +1,358 @@ +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 { + 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 () => { + mockIsAgent.mockReturnValue(false); + tempDir = await mkdtemp(join(tmpdir(), "clerk-verify-test-")); + }); + + afterEach(async () => { + mockIsAgent.mockReset(); + 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("a multi-line --delivery file verifies against the first non-empty line", async () => { + const firstLine = JSON.stringify({ + headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const secondLine = JSON.stringify({ + headers: { + "svix-id": "msg_later", + "svix-timestamp": TIMESTAMP, + "svix-signature": "v1,deadbeef", + }, + body_b64: Buffer.from('{"type":"user.deleted"}', "utf8").toString("base64"), + }); + const deliveryPath = await writeTempFile("events.ndjson", `${firstLine}\n${secondLine}\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("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 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(), ...flags, 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); + + await expect( + webhooksVerify({ ...explicitFlags(), timestamp: staleTimestamp, payload: `@${payloadPath}` }), + ).rejects.toThrow("in the past"); + }); + + test("mismatch on a future timestamp includes a humanized skew hint", async () => { + const futureTimestamp = String(Number(TIMESTAMP) + 3600); + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await expect( + webhooksVerify({ + ...explicitFlags(), + timestamp: futureTimestamp, + payload: `@${payloadPath}`, + }), + ).rejects.toThrow("in the future"); + }); + + 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: "{}", + }, + }, + { + // An explicit empty --payload must surface as a usage error, not fall + // through to the --delivery body or hash an `undefined` pre-image. + label: "empty --payload", + 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("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"); + + 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..67b5f01e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -0,0 +1,239 @@ +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; + 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; + // Buffer.from silently strips non-base64 chars and truncates unpadded input, + // so a garbled secret would decode to wrong key material. Round-trip to reject + // anything that isn't clean base64 (ignoring `=` padding differences). + const stripPad = (s: string) => s.replace(/=+$/, ""); + if (stripPad(key.toString("base64")) !== stripPad(encoded)) 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); + // Read directly rather than pre-checking exists(): Bun's stat-based exists() + // reports false for readable character devices like /dev/null. + try { + return await Bun.file(path).text(); + } catch (err) { + const reason = err instanceof Error ? `: ${err.message}` : ""; + throw new CliError(`Could not read ${path}${reason}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } + } + 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."); + } + + // Both read stdin, which can only be consumed once. + if (options.delivery === "-" && options.payload === "-") { + throwUsageError( + "Cannot use --delivery - and --payload - together: both read stdin, which can only be consumed once. Use --delivery - alone (its body_b64 provides the payload).", + ); + } + + let fields: DeliveryFields = {}; + if (options.delivery) { + const raw = await readFileOrStdin(options.delivery, "--delivery"); + // Accept the full `listen --json` stream: skip the `ready` line (and any + // other non-event JSON) and use the first event line. Non-NDJSON input + // falls through to the first non-empty line so error paths still fire. + const lines = raw.split("\n").filter((line) => line.trim()); + const firstLine = + lines.find((line) => { + try { + const parsed = JSON.parse(line.trim()) as { type?: unknown }; + return parsed !== null && typeof parsed === "object" && parsed.type !== "ready"; + } catch { + return true; + } + }) ?? lines[0]; + 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).`, + ); + } + + // Nullish-coalesce, not truthiness: an explicit empty `--payload` must reach + // readFileOrStdin (which rejects it as neither @file nor -) instead of + // silently falling through to the --delivery body or an `undefined` HMAC. + const payload = + options.payload !== undefined + ? 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."; + // Only hint at clock skew when at least one entry was a structurally + // plausible v1 HMAC-SHA256 (32 bytes) — otherwise the failure is a malformed + // signature, not a timestamp problem, and a skew note would mislead. + const HMAC_SHA256_BYTES = 32; + const hasStructuralCandidate = signature! + .split(/\s+/) + .filter(Boolean) + .some((entry) => { + const comma = entry.indexOf(","); + if (comma === -1 || entry.slice(0, comma) !== "v1") return false; + return Buffer.from(entry.slice(comma + 1), "base64").length === HMAC_SHA256_BYTES; + }); + const deltaSeconds = Math.floor(Date.now() / 1000) - Number(timestamp); + if (hasStructuralCandidate && 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.`; + } + // Trailing-newline footgun: `echo` adds one, but the HMAC is byte-exact. + if (options.payload !== undefined && typeof payload === "string" && payload.endsWith("\n")) { + message += + " Note: the --payload file ends with a trailing newline; the HMAC is byte-exact. Write the raw body with no trailing newline (use printf, not echo), or use --delivery from a captured listen event."; + } + throw new CliError(message, { code: ERROR_CODE.INVALID_WEBHOOK_SIGNATURE }); + } + + if (shouldOutputJson(options)) { + log.data(JSON.stringify({ valid: true })); + } else { + log.success("Signature verified."); + } +} diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..e781f8d0 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -45,10 +45,16 @@ export function profileLabel(profile: Profile): string { return profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId; } +/** Persisted Svix relay state for `clerk webhooks listen`. */ +interface RelayEntry { + token: string; +} + interface ClerkConfig { environment?: string; auth?: Record; profiles: Record; + relay?: Record; } function defaultConfig(): ClerkConfig { @@ -65,6 +71,21 @@ function migrateRawConfig(raw: Record): ClerkConfig { profiles: (raw.profiles as Record) ?? {}, }; + if (raw.relay && typeof raw.relay === "object" && !Array.isArray(raw.relay)) { + const relay: Record = {}; + for (const [key, val] of Object.entries(raw.relay as Record)) { + if ( + val && + typeof val === "object" && + !Array.isArray(val) && + typeof (val as Record).token === "string" + ) { + relay[key] = val as RelayEntry; + } + } + config.relay = relay; + } + if (raw.auth && typeof raw.auth === "object") { const auth = raw.auth as Record; if (typeof auth.userId === "string") { @@ -174,6 +195,18 @@ export async function listProfiles(): Promise> { return config.profiles; } +export async function getRelayEntry(key: string): Promise { + const config = await readConfig(); + return config.relay?.[key]; +} + +export async function setRelayEntry(key: string, entry: RelayEntry): Promise { + const config = await readConfig(); + if (!config.relay) config.relay = {}; + config.relay[key] = entry; + await writeConfig(config); +} + type ResolvedVia = "remote" | "git-common-dir" | "directory"; export async function resolveProfile(cwd: string): Promise< diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index bff5a8cf..b47a1a68 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -1,3 +1,4 @@ +import type { Example } from "./help.ts"; import { isAgent } from "../mode.ts"; /** Standard process exit codes used by the CLI. */ @@ -37,6 +38,8 @@ export const ERROR_CODE = { NO_SECRET_KEY: "no_secret_key", /** File not found on disk. */ FILE_NOT_FOUND: "file_not_found", + /** A webhook signature failed local HMAC verification. */ + INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature", /** Input is not valid JSON or not an object. */ INVALID_JSON: "invalid_json", /** Failed to fetch or parse the OpenAPI catalog. */ @@ -72,6 +75,11 @@ interface CliErrorOptions { exitCode?: ExitCode; /** URL to relevant documentation, printed after the error message. */ docsUrl?: string; + /** + * Usage examples to print beneath the error, showing "the right way" to run + * the command. Rendered only in human mode (suppressed for agent/JSON output). + */ + examples?: Example[]; } interface AuthErrorOptions extends Omit { @@ -102,12 +110,14 @@ export class CliError extends Error { public code?: ErrorCode; public exitCode: ExitCode; public docsUrl?: string; + public examples?: Example[]; constructor(message: string, options?: CliErrorOptions) { super(message); this.name = "CliError"; this.code = options?.code; this.exitCode = options?.exitCode ?? EXIT_CODE.GENERAL; + this.examples = options?.examples; if (options?.docsUrl) { this.docsUrl = options.docsUrl; @@ -376,6 +386,8 @@ export function isAuthError(error: unknown): error is AuthError | ApiError { * * @param message - Error message describing the usage problem * @param docsUrl - Optional URL to relevant documentation + * @param code - Optional machine-readable code (defaults to `USAGE_ERROR`) + * @param examples - Optional usage examples printed beneath the error in human mode * * @example * ```ts @@ -384,11 +396,17 @@ export function isAuthError(error: unknown): error is AuthError | ApiError { * } * ``` */ -export function throwUsageError(message: string, docsUrl?: string, code?: ErrorCode): never { +export function throwUsageError( + message: string, + docsUrl?: string, + code?: ErrorCode, + examples?: Example[], +): never { throw new CliError(message, { code: code ?? ERROR_CODE.USAGE_ERROR, exitCode: EXIT_CODE.USAGE, docsUrl, + examples, }); } diff --git a/packages/cli-core/src/lib/gradient.test.ts b/packages/cli-core/src/lib/gradient.test.ts index 338666e7..7ed31899 100644 --- a/packages/cli-core/src/lib/gradient.test.ts +++ b/packages/cli-core/src/lib/gradient.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { bold } from "./color.ts"; -import { animateHeader, hslToRgb, rgbTo256, shineText } from "./gradient.ts"; +import { animateHeader, cursorRowsBelowHeader, hslToRgb, rgbTo256, shineText } from "./gradient.ts"; import { useCaptureLog } from "../test/lib/stubs.ts"; const count = (haystack: string, needle: string) => haystack.split(needle).length - 1; @@ -175,6 +175,28 @@ describe("animateHeader (interactive gating)", () => { expect(out).toContain("\x1b[3B"); }); + test("steps up by the WRAPPED row count so a long line never strands the header", async () => { + const savedCols = process.stderr.columns; + Object.defineProperty(process.stderr, "columns", { value: 20, configurable: true }); + try { + const body = ` → ${"x".repeat(40)}\n → short\n`; // first line wraps at 20 cols + const expected = cursorRowsBelowHeader(body, 20); // 1 + 2 newlines + 2 wrap rows = 5 + expect(expected).toBeGreaterThan(3); // would have been a too-small 3 before the fix + await animateHeader({ + prefix: "", + label: "Hi", + fallback: bold, + body, + frames: 2, + intervalMs: 1, + }); + expect(captured.err).toContain(`\x1b[${expected}A`); + expect(captured.err).toContain(`\x1b[${expected}B`); + } finally { + Object.defineProperty(process.stderr, "columns", { value: savedCols, configurable: true }); + } + }); + test("NO_COLOR disables the animation (plain fallback, no redraw)", async () => { process.env.NO_COLOR = "1"; await run(); @@ -221,3 +243,25 @@ describe("animateHeader (interactive gating)", () => { } }); }); + +describe("cursorRowsBelowHeader", () => { + test("counts one row per newline when nothing wraps", () => { + // header newline (1) + two body newlines = 3 + expect(cursorRowsBelowHeader("step1\nstep2\n", 80)).toBe(3); + }); + + test("adds a row for each wrapped line", () => { + // a 25-wide line at columns=10 spans 3 visual rows → +2 over its newline + const line = "x".repeat(25); + expect(cursorRowsBelowHeader(`${line}\n`, 10)).toBe(1 + 1 + 2); + }); + + test("ignores ANSI when measuring width", () => { + // 8 visible chars wrapped in color codes must not count as wrapped at cols=20 + expect(cursorRowsBelowHeader("\x1b[36mabcdefgh\x1b[0m\n", 20)).toBe(2); + }); + + test("falls back to newline count when columns is unknown", () => { + expect(cursorRowsBelowHeader("x".repeat(200) + "\n", undefined)).toBe(2); + }); +}); diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index 439302a2..c94d0068 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -127,6 +127,25 @@ interface AnimateHeaderOptions { write?: (s: string) => void; } +/** + * Rows the cursor lands below the header after writing `header\n${body}`: one + * for the header's newline, one per newline in the body, PLUS the extra rows any + * line spans when it wraps past the terminal width. Counting only newlines (the + * old behaviour) under-counts wrapped lines, so the cursor-up step overshoots + * the header and strands a duplicate "Next steps" on screen. Assumes the header + * line itself does not wrap (labels are short). + */ +export function cursorRowsBelowHeader(body: string, columns: number | undefined): number { + let rows = 1 + (body.match(/\n/g)?.length ?? 0); + if (columns && columns > 0) { + for (const line of body.split("\n")) { + const width = Bun.stringWidth(line); + if (width > columns) rows += Math.ceil(width / columns) - 1; + } + } + return rows; +} + export async function animateHeader(options: AnimateHeaderOptions): Promise { const { prefix, @@ -145,9 +164,7 @@ export async function animateHeader(options: AnimateHeaderOptions): Promise value.replace(ANSI_ESCAPE_PATTERN, ""); + +describe("formatExamplesBlock", () => { + test("returns an empty string for no examples", () => { + expect(formatExamplesBlock([])).toBe(""); + }); + + test("titles the block and prefixes each command with `$ `", () => { + const block = stripAnsi( + formatExamplesBlock([ + { command: "clerk webhooks listen --forward-to url", description: "Forward" }, + ]), + ); + expect(block).toBe("Examples:\n $ clerk webhooks listen --forward-to url Forward"); + }); + + test("aligns descriptions to the longest command", () => { + const examples: Example[] = [ + { command: "short", description: "A" }, + { command: "a much longer command", description: "B" }, + ]; + const lines = stripAnsi(formatExamplesBlock(examples)).split("\n"); + // Both descriptions start at the same column. + expect(lines[1]!.indexOf("A")).toBe(lines[2]!.indexOf("B")); + }); +}); diff --git a/packages/cli-core/src/lib/help.ts b/packages/cli-core/src/lib/help.ts index d2780efc..ed40b234 100644 --- a/packages/cli-core/src/lib/help.ts +++ b/packages/cli-core/src/lib/help.ts @@ -1,10 +1,28 @@ import { Command, type Help } from "@commander-js/extra-typings"; +import { bold, dim } from "./color.ts"; export interface Example { command: string; description: string; } +/** + * Render an Examples block as a standalone string, for use OUTSIDE the help + * screen — e.g. beneath a usage error to show "the right way" to run a command. + * + * Mirrors the help formatter's Examples section (title, `$ `-prefixed commands, + * descriptions aligned in a column) but takes no Commander `Help` instance, so + * error paths without a command context can render it. Returns `""` for an + * empty list so callers can concatenate unconditionally. + */ +export function formatExamplesBlock(examples: Example[]): string { + if (examples.length === 0) return ""; + const terms = examples.map((e) => `$ ${e.command}`); + const termWidth = Math.max(...terms.map((t) => t.length)); + const lines = examples.map((e, i) => ` ${terms[i]!.padEnd(termWidth)} ${dim(e.description)}`); + return [bold("Examples:"), ...lines].join("\n"); +} + const examplesMap = new WeakMap(); // Augment Commander's Command type with .setExamples() diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index a7e89d11..6d3a2d2f 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -326,6 +326,14 @@ describe("expandInputJson", () => { expect(result.result).toEqual(["clerk", "whoami"]); }); + test("leaves stdin for a subcommand that claims it with a bare `-`", async () => { + // e.g. `webhooks verify --payload -`: stdin is the payload, not input-json. + // Without --input-json, argv is returned unchanged (no `--type` spliced in). + const argv = ["clerk", "webhooks", "verify", "--payload", "-"]; + const result = await expandViaStdin(argv, '{"type":"user.created"}'); + expect(result.result).toEqual(argv); + }); + test("--input-json - errors on invalid JSON from stdin", async () => { const result = await expandViaStdin(["clerk", "init", "--input-json", "-"], "not-json"); expect(result.error).toContain("Invalid JSON"); diff --git a/packages/cli-core/src/lib/signals.ts b/packages/cli-core/src/lib/signals.ts new file mode 100644 index 00000000..128f3803 --- /dev/null +++ b/packages/cli-core/src/lib/signals.ts @@ -0,0 +1,10 @@ +import { EXIT_CODE } from "./errors.ts"; + +/** + * The CLI's default SIGINT handler: exit with the conventional 130 code. + * Exported as a named function so commands that install their own graceful + * SIGINT handling (e.g. `webhooks listen`) can remove *only* this one via + * `process.removeListener("SIGINT", CLI_SIGINT_HANDLER)` instead of nuking all + * SIGINT listeners. + */ +export const CLI_SIGINT_HANDLER = (): never => process.exit(EXIT_CODE.SIGINT);