diff --git a/.changeset/fix-network-request-timeout.md b/.changeset/fix-network-request-timeout.md new file mode 100644 index 00000000..e5993ec7 --- /dev/null +++ b/.changeset/fix-network-request-timeout.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add a default 60s timeout to all outbound CLI network requests. Previously a stalled connection to a Clerk API could hang a command indefinitely (with no error and no way to recover other than Ctrl-C); requests now abort with a clear, tagged error after 60s. A caller-supplied `AbortSignal` still composes with this default, so tighter per-call budgets continue to win. diff --git a/.changeset/webhooks.md b/.changeset/webhooks.md new file mode 100644 index 00000000..be5c4348 --- /dev/null +++ b/.changeset/webhooks.md @@ -0,0 +1,7 @@ +--- +"clerk": minor +--- + +Add the `clerk webhooks` command group for managing webhook endpoints and deliveries from the terminal: `list`, `get`, `create`, `update`, `delete`, `secret [--rotate]`, `event-types`, `messages`, `replay`, `listen`, `trigger`, `verify`, and `open`. + +`webhooks listen` supports `--relay-only` to run the local relay tunnel with no Clerk backend (no PLAPI, no auth), and `--token ` to pin a stable, shareable relay URL. The relay token is persisted per instance, so the relay URL stays the same across restarts. diff --git a/.claude/rules/command-registration.md b/.claude/rules/command-registration.md new file mode 100644 index 00000000..7aac095e --- /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, a 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()`, a group `webhooks.hook("preAction", …)` for shared gating (e.g. auth), and one `.command(...)` per subcommand. See `commands/webhooks/index.ts` (`registerWebhooks`) for a full multi-subcommand group with inherited `--app`/`--instance`/`--json` options and an auth `preAction`. + +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..18dff077 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -10,6 +10,7 @@ "files": [ "packages/cli-core/src/cli.ts", "packages/cli-core/src/cli-program.ts", + "packages/cli-core/src/lib/signals.ts", "scripts/*" ], "rules": { diff --git a/README.md b/README.md index e372cef3..831f5791 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Commands: open Open Clerk resources in your browser apps Manage your Clerk applications users [options] Manage Clerk users + webhooks [options] Manage webhook endpoints and deliveries env Manage environment variables config Manage instance configuration enable Enable Clerk features on the linked instance diff --git a/package.json b/package.json index e9f77b9f..2656a0a2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "bun run --filter @clerk/cli-core build", "dev": "bun run --cwd packages/cli-core dev", "test": "bun test 'packages/cli-core/src/' 'packages/extras/src/' 'scripts/' --parallel --only-failures", - "test:e2e": "bun test 'test/e2e/' --retry 1 --parallel --only-failures", + "test:e2e": "bun test 'test/e2e/' --retry 1 --parallel=4 --only-failures", "test:e2e:op": "bun run scripts/run-e2e-op.ts", "e2e:refresh-fixtures": "bun run scripts/refresh-e2e-fixtures.ts", "typecheck": "bun run --filter './packages/*' typecheck && tsc --noEmit -p scripts/tsconfig.json && tsc --noEmit -p test/e2e/tsconfig.json", diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..edcdaf84 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -41,6 +41,7 @@ import { clerkHelpConfig } from "./lib/help.ts"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; +import { registerWebhooks } from "./commands/webhooks/index.ts"; import { registerExtras } from "@clerk/cli-extras"; /** @@ -69,6 +70,7 @@ const registrants: CommandRegistrant[] = [ registerCompletion, registerUpdate, registerDeploy, + registerWebhooks, registerExtras, ]; diff --git a/packages/cli-core/src/cli.ts b/packages/cli-core/src/cli.ts index ad1be3ba..adc6c2f4 100755 --- a/packages/cli-core/src/cli.ts +++ b/packages/cli-core/src/cli.ts @@ -1,7 +1,7 @@ #!/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 { cliSigintHandler } from "./lib/signals.ts"; +process.on("SIGINT", cliSigintHandler); // 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..6c7ef04a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -0,0 +1,281 @@ +# Webhooks Commands + +> The 13 PLAPI webhook routes these commands call are being built in parallel in `clerk_go` and may not exist yet in every environment. The CLI is built against the final spec's request/response shapes; unit tests mock the PLAPI layer. + +Manage webhook endpoints and deliveries for the linked instance: CRUD, delivery inspection, local forwarding (`listen`), replay, and offline signature verification. + +## Group-level options + +Inherited by every subcommand via `optsWithGlobals()`: + +| Option | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `--app ` | Application ID to target (works from any directory). | +| `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). | +| `--json` | Force machine output in a human TTY. Agent mode (`isAgent()`) always behaves as if `--json` were set. | + +Auth: every subcommand except `verify` is gated by a `preAction` hook calling `getAuthToken()` (accepts `ak_` keys or an OAuth session; never `sk_`). `verify` is pure offline HMAC — no auth, and it ignores `--app`/`--instance`. + +Output contract: stdout carries bare domain JSON via `log.data()` (pipeable); stderr carries human UI and, in agent mode, structured error JSON `{"error":{code,message,docsUrl?}}`. No `{ok,data,error}` envelope. Exit codes: 0 success, 1 failure, 2 usage error, 130 SIGINT. + +Pagination: list-shaped commands fetch ONE page (`--limit` 1-250, default 100). The `--limit` value is validated client-side — passing a value outside 1–250 is a usage error (exit 2). When `cursor.has_next_page` is true, the next `--iterator` value is printed as a stderr hint. The `--iterator` flag value is sent on the wire as the `starting_after` query param. + +All routes below are relative to `/v1/platform/applications/{applicationID}/instances/{envOrInsID}`. + +## `clerk webhooks list` + +Lists webhook endpoints for the instance. + +```sh +clerk webhooks list [--limit N] [--iterator C] +``` + +| Option | Description | +| ---------------- | ------------------------------------------------- | +| `--limit ` | Maximum endpoints to return (1-250, default 100). | +| `--iterator ` | Pagination cursor from the previous response. | + +Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode prints the full `{ data, cursor }` response on stdout. + +On a fresh instance where no webhooks have been configured yet, the command returns an empty list (`{ data: [], cursor: { ... } }`) rather than erroring. The backend's `svix_app_missing` 400 is caught and treated as an empty page. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------- | ---------------------------------- | +| `GET` | `/webhooks` | List webhook endpoints (one page). | + +## `clerk webhooks get ` + +Prints one endpoint's configuration. A PLAPI 404 maps to error code `webhook_endpoint_not_found`. On a fresh instance where no webhooks have been configured yet, the command exits 1 with the message: `No webhooks have been configured for this instance yet. Run \`clerk webhooks create\` to create one.` + +```sh +clerk webhooks get ep_2abc123 +``` + +Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------ | ------------------- | +| `GET` | `/webhooks/{endpointID}` | Fetch one endpoint. | + +## `clerk webhooks event-types` + +Lists the Svix event-type catalog for the instance (`--limit`/`--iterator` as in `list`). Archived types are marked in human output. On a fresh instance, returns an empty list rather than erroring (same `svix_app_missing` handling as `list`). + +```sh +clerk webhooks event-types [--limit N] [--iterator C] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------- | --------------------------------------- | +| `GET` | `/webhooks/event_types` | List the event-type catalog (one page). | + +## `clerk webhooks secret ` + +Prints the endpoint's current signing secret. With `--rotate`, rotates first (prompts in human mode; requires `--yes` in agent mode), then prints the new secret. After rotation Svix dual-signs with old+new keys for 24h — the `svix-signature` header carries multiple space-separated entries during the grace window. On a fresh instance, exits 1 with a friendly `No webhooks have been configured for this instance yet. Run \`clerk webhooks create\`…` error. + +```sh +clerk webhooks secret ep_2abc123 [--rotate [--yes]] +``` + +Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `export CLERK_WEBHOOK_SIGNING_SECRET=$(clerk webhooks secret ep_...)`), with all banners on stderr. JSON/agent mode prints `{ "secret": "whsec_..." }`. Plain `secret ` never prompts; `--yes` is only meaningful with `--rotate`. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | -------------------------------------- | -------------------------------------------- | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the signing secret. | +| `POST` | `/webhooks/{endpointID}/secret/rotate` | Rotate the signing secret (`--rotate` only). | + +## `clerk webhooks delete ` + +Hard-deletes an endpoint (Svix delete is hard; no shadow table). Prompts in human mode; agent mode requires `--yes` or fails with a usage error (exit 2). Declining the prompt exits cleanly. Success prints a stderr confirmation; stdout stays empty (the route returns `200 {}`). On a fresh instance, exits 1 with a friendly `No webhooks have been configured for this instance yet. Run \`clerk webhooks create\`…` error. + +```sh +clerk webhooks delete ep_2abc123 [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| -------- | ------------------------ | --------------------------------------- | +| `DELETE` | `/webhooks/{endpointID}` | Delete the endpoint (returns `200 {}`). | + +## `clerk webhooks update ` + +Patches endpoint fields. Only the flags you pass are sent; everything else is omitted from the PATCH body. `--enable` maps to `{disabled: false}`, `--disable` to `{disabled: true}` (mutually exclusive; `--disabled` exists only on `create`). Passing no update flags is a usage error. On a fresh instance, exits 1 with a friendly `No webhooks have been configured for this instance yet. Run \`clerk webhooks create\`…` error. + +**Filter clearing**: passing an empty value (`--events ""` or `--channels ""`) sends an empty array to the API, clearing all filters. Omitting the flag leaves the existing value unchanged. + +```sh +clerk webhooks update ep_2abc123 [--url ...] [--events a,b] [--description ] [--channels a,b] [--enable | --disable] + +# Clear all event-type filters: +clerk webhooks update ep_2abc123 --events "" + +# Clear channels: +clerk webhooks update ep_2abc123 --channels "" +``` + +Human mode prints the updated endpoint's details on stderr. JSON mode prints the updated endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------- | ------------------------ | ---------------------- | +| `PATCH` | `/webhooks/{endpointID}` | Patch endpoint fields. | + +## `clerk webhooks create` + +Creates an endpoint (always `version: 1`), then fetches and prints its signing secret. The backend lazily provisions the Svix app on the first create. Two network calls, client-orchestrated. + +```sh +clerk webhooks create --url [--events user.created,...] [--description ] [--channels a,b] [--disabled] +``` + +JSON mode emits the endpoint resource FLAT with one extra field: `signing_secret`. Human mode prints the details plus the unmasked secret on stderr. + +Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the command exits 1 with `Endpoint created (id: ep_...) but the signing secret could not be fetched. Run 'clerk webhooks secret ep_...' to retrieve it.` — no silent orphan. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------------------------------------- | +| `POST` | `/webhooks` | Create the endpoint (lazily provisions Svix app). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the new endpoint's signing secret. | + +## `clerk webhooks messages` + +Lists recent deliveries (msg IDs, event type, status, full payload) for an endpoint — the discovery feed for `replay `. `--endpoint` defaults to the instance's persisted relay endpoint; without either, it's a usage error. + +On a fresh instance (no webhooks configured yet): without `--endpoint`, returns an empty list rather than erroring (same `svix_app_missing` handling as `list`). With an explicit `--endpoint`, exits 1 with a friendly `No webhooks have been configured for this instance yet. Run \`clerk webhooks create\`…` error. + +```sh +clerk webhooks messages [--endpoint ] [--status success|pending|fail|sending] [--limit N] [--iterator C] +``` + +Human mode prints an `ID / EVENT TYPE / STATUS / CREATED` table on stderr (payloads only in JSON mode). JSON mode prints the full `{ data, cursor }` response, payloads included. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------------------------- | ------------------------------------------------------ | +| `GET` | `/webhooks/{endpointID}/messages` | List attempted deliveries (one page, optional status). | + +## `clerk webhooks replay` + +Dual-mode: + +- `replay ` resends one delivery (same `svix-id`). `--endpoint` defaults to the relay endpoint. No prompt — a single targeted resend is not destructive. +- `replay --since [--until ]` bulk-recovers failed deliveries in a window. `--endpoint` is **required** (bulk recovery never guesses), and it prompts unless `--yes` (agent mode requires `--yes`). + +`` and `--since` are mutually exclusive; passing both or neither is a usage error, as is `--until` without `--since`. Both operations are async on the Svix side — success means queued (`200 {}`), stdout stays empty. + +```sh +clerk webhooks replay [] [--endpoint ] [--since [--until ]] [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ---------------------------------------------------- | ------------------------------------------------------------ | +| `POST` | `/webhooks/{endpointID}/messages/{messageID}/resend` | Resend one delivery (`` mode). | +| `POST` | `/webhooks/{endpointID}/recover` | Recover a window: body `{ since, until? }` (`--since` mode). | + +## `clerk webhooks trigger ` + +Sends an example event of the given type. Because `send_example` returns `200 {}` asynchronously, the CLI first validates the type against the event-type catalog (paging through it) and fails fast with error code `unknown_event_type` — otherwise an invalid type would exit 0 and deliver nothing. `--endpoint` defaults to the relay endpoint. + +```sh +clerk webhooks trigger user.created [--endpoint ] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------------- | ------------------------------------------------ | +| `GET` | `/webhooks/event_types` | Validate the event type against the catalog. | +| `POST` | `/webhooks/{endpointID}/send_example` | Send the example event: body `{ "event_type" }`. | + +## `clerk webhooks open` + +Fetches a single-use Svix portal URL and opens it in the browser via `openBrowser()` (which never throws — on failure the URL is printed as a fallback). JSON/agent mode prints `{ "url": "..." }` and does not launch a browser. Backed by the Svix `DashboardAccess` API in v0.64.1; switch to `AppPortalAccess` on SDK upgrade. + +```sh +clerk webhooks open +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------- | ----------------------------------------- | +| `POST` | `/webhooks/url` | Fetch the portal URL (request body `{}`). | + +## `clerk webhooks verify` + +Verifies a Svix webhook signature **locally**: HMAC-SHA256 over `{id}.{timestamp}.{body}` with the base64-decoded `whsec_` suffix, constant-time compare, any-match across space-separated `v1,` header entries (rotation grace windows produce multiple entries). No network calls, no auth gate (`--app`/`--instance` are ignored). Exit 0 = signature matched; exit 1 = mismatch (with a humanized timestamp-skew hint when the timestamp is >5 minutes off); exit 2 = bad inputs. + +Agent/`--json` mode: success prints `{ "valid": true }` on stdout; a mismatch exits 1 with error code `invalid_webhook_signature` in the structured stderr error. + +```sh +clerk webhooks verify --secret whsec_... (--delivery @event.json | --payload @body.json --id msg_... --timestamp --signature v1,...) +``` + +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------- | +| `--secret ` | Always required. A flag, never a positional — secrets must not land in argv positionals. | +| `--delivery ` | One `listen` event NDJSON line (`@file` or `-`); supplies `id`, `timestamp`, `signature`, and the body. | +| `--payload ` | Raw body as `@file` or `-` (bare inline JSON rejected; shells mangle it). | +| `--id ` | The `svix-id` header (first HMAC pre-image segment). | +| `--timestamp ` | The `svix-timestamp` header — Unix epoch seconds, integer. | +| `--signature ` | The raw `svix-signature` header value; may carry multiple space-separated `v1,` entries (any-match). | + +Explicit flags override fields parsed from `--delivery`. A `listen` event line saved to a file is directly consumable here. + +### API endpoints + +None — pure offline computation. + +## `clerk webhooks listen` + +Dials the Svix relay (`wss://api.relay.svix.com/api/v1/listen/`), registers a **persistent** per-instance relay endpoint pointing at `https://play.svix.com/in//`, and forwards incoming deliveries to a local handler. + +```sh +clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [--relay-only] [--token ] [--headers k:v,...] +``` + +| Option | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--forward-to ` | Local URL to POST deliveries to. Omitted: events are received, verified, and printed with `forward_status: null`. | +| `--events ` | Sets `filter_types` on the relay endpoint. If the persisted endpoint has different filters it is PATCHed — with a warning, since other `listen` sessions share this instance's relay endpoint. | +| `--skip-verify` | Skip per-delivery HMAC verification. | +| `--relay-only` | Standalone tunnel: open the relay and forward **without** any Clerk backend, auth, or instance context. Skips endpoint registration and the signing-secret fetch (so verification is off). See note below. | +| `--token ` | Pin the relay token (only with `--relay-only`) so the inbox URL is fixed. Format: `c_` + 10 base62 chars. Without it, a token is generated once and persisted, so the URL is already stable across runs. | +| `--headers ` | Comma-separated `k:v` extras on the forwarded POST (split on the FIRST colon). The delivery's `svix-*` headers always win. | + +Behavior notes: + +- **Relay token**: `c_` + 10 random base62 chars — the same token goes in the start frame, the inbox URL, and the per-instance CLI config (`relay..token`). Live-relay verified: `play.svix.com` rejects unprefixed tokens, and the relay only registers an inbox when the start frame carries the `c_` token. Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. +- **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). A probe timer fires every `RELAY_SILENCE_TIMEOUT_MS / 2` (~15s) and, once at least `RELAY_SILENCE_TIMEOUT_MS` (30s) has elapsed with no inbound message, sends an active `ws.ping()` (so a probe lands 30–45s into a silence, depending on timer phase) — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. +- **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay ` line; unreachable handler → synthetic **502** framed back to the relay. +- **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events). +- **At-least-once**: forwarding is at-least-once, like any webhook stream. If the relay socket drops while a delivery is mid-forward, its response frame is sent on the closed socket and dropped, so Svix may redeliver it (and the new inbox URL after a 1008 rotation only appears in the next `ready` line on restart). Local handlers must key on `svix-id` and be idempotent. +- **Agent/`--json` mode**: NDJSON on stdout. Every line carries a `type` discriminator: one `{ "type": "ready", ... }` line (`relay_url`, `endpoint_id`, `events_filter`, `forward_to`), then one `{ "type": "event", ... }` line per delivery (`svix_id`, `event_type`, `headers`, `body_b64`, `forward_status`, `latency_ms`). An event line saved to a file is directly consumable by `verify --delivery @file`. The signing secret is **not** included in the ready line — agents that need it should fetch it on demand: `clerk webhooks secret ` (the `endpoint_id` field is still present in the ready line for this purpose). +- **SIGINT**: `listen` replaces the global cleanup-free handler before opening the socket: close socket, drain in-flight forwards, exit 130. The relay endpoint is **never** deleted on exit — its URL and `whsec_` stay stable across restarts. `listen` never exits 0. +- **`--relay-only`**: a fully standalone tunnel. It dials the relay and forwards, but makes **zero** calls to the platform API: no `resolveAppContext`, no endpoint registration, no secret fetch. The group-level auth `preAction` is also skipped for this mode, so it needs no login and no linked project. Because there's no signing secret, per-delivery HMAC verification is forced off. The token **is** persisted (under the reserved config key `relay.__relay_only__.token`), so the inbox URL stays stable across runs — register it once in the dashboard and keep reusing it. `--token ` pins an explicit token (shareable / memorable); a 1008 collision rotates and re-persists. Deliveries arrive only if something points at the printed relay URL — either POST to it directly to inject a test delivery, or register that URL as an endpoint in your Svix app to receive real instance events. `--app`/`--instance` are ignored. The ready banner reads `Webhook relay ready (relay-only — no Clerk endpoint registered)` and the NDJSON `ready` line carries `endpoint_id: null`. + +### API endpoints + +In the default mode `listen` calls the routes below. **`--relay-only` calls none of them** — it touches only the Svix relay WebSocket. + +| Method | Endpoint | Description | +| ------- | ------------------------------- | ---------------------------------------------------------- | +| `GET` | `/webhooks/{endpointID}` | Reuse check for the persisted relay endpoint. | +| `PATCH` | `/webhooks/{endpointID}` | Re-point URL after token rotation / update `filter_types`. | +| `POST` | `/webhooks` | Create the relay endpoint on first run (or after a 404). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the relay endpoint's signing secret at startup. | diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts new file mode 100644 index 00000000..7ce8255f --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -0,0 +1,159 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { AuthError, CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockCreateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksCreate } = await import("./create.ts"); + +const createdEndpoint = { + id: "ep_new", + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks create", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockCreateWebhookEndpoint.mockResolvedValue(createdEndpoint); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_new123" }); + }); + + afterEach(() => { + mockCreateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("missing --url is a usage error", async () => { + await expect(webhooksCreate({})).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("sends url and version 1 by default", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }); + }); + + test("maps optional flags to the create body", async () => { + await webhooksCreate({ + url: "https://example.com/webhooks", + events: "user.created, user.deleted", + description: "My endpoint", + channels: "a,b", + disabled: true, + }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created", "user.deleted"], + channels: ["a", "b"], + }); + }); + + test("fetches the signing secret after creating", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_new"); + }); + + test("emits the endpoint flat with signing_secret in JSON mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + expect(captured.err).toBe(""); + }); + + test("emits the same flat JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + }); + + test("prints details and the unmasked secret in human mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Created webhook endpoint"); + expect(captured.err).toContain("ep_new"); + expect(captured.err).toContain("whsec_new123"); + }); + + test("partial failure: secret fetch error exits 1 with the recovery command", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(500, "{}")); + + const promise = webhooksCreate({ url: "https://example.com/webhooks" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(promise).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_SECRET_FETCH_FAILED, + }); + await expect(promise).rejects.toThrow( + "Endpoint created (id: ep_new) but the signing secret could not be fetched. " + + "Run 'clerk webhooks secret ep_new' to retrieve it.", + ); + }); + + test("partial failure: an auth error on the secret fetch is not masked as a secret-fetch failure", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue( + new AuthError({ + reason: "session_expired", + message: "Session expired. Run `clerk auth login`.", + }), + ); + + const promise = webhooksCreate({ url: "https://example.com/webhooks" }); + + await expect(promise).rejects.toBeInstanceOf(AuthError); + await expect(promise).rejects.toThrow("Session expired"); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/create.ts b/packages/cli-core/src/commands/webhooks/create.ts new file mode 100644 index 00000000..fbe699e3 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.ts @@ -0,0 +1,80 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { + AuthError, + CliError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpointSecret, + type CreateWebhookEndpointParams, +} from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksCreateOptions extends WebhooksGlobalOptions { + url?: string; + events?: string; + description?: string; + channels?: string; + disabled?: boolean; +} + +function buildCreateParams(options: WebhooksCreateOptions): CreateWebhookEndpointParams { + if (!options.url) { + throwUsageError("Missing required --url ."); + } + + const params: CreateWebhookEndpointParams = { url: options.url, version: 1 }; + if (options.description !== undefined) params.description = options.description; + if (options.disabled) params.disabled = true; + const filterTypes = splitCommaList(options.events); + if (filterTypes?.length) params.filter_types = filterTypes; + const channels = splitCommaList(options.channels); + if (channels?.length) params.channels = channels; + return params; +} + +export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promise { + const params = buildCreateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await withApiContext( + createWebhookEndpoint(ctx.appId, ctx.instanceId, params), + "Failed to create webhook endpoint", + ); + + let secret: string; + try { + ({ secret } = await getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id)); + } catch (error) { + // An auth failure on the second call is the real problem — let it surface + // with its own reason and docs URL instead of being masked as a transient + // secret-fetch hiccup. + if (error instanceof AuthError) throw error; + // Create is atomic; the secret fetch is a second call. Never leave a + // silent orphan — surface the new ID and the exact recovery command. + throw new CliError( + `Endpoint created (id: ${endpoint.id}) but the signing secret could not be fetched. ` + + `Run 'clerk webhooks secret ${endpoint.id}' to retrieve it.`, + { code: ERROR_CODE.WEBHOOK_SECRET_FETCH_FAILED }, + ); + } + + if (shouldOutputJson(options)) { + printJson({ ...endpoint, signing_secret: secret }); + return; + } + + log.success(`Created webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); + log.info(`Signing secret: ${secret}`); +} diff --git a/packages/cli-core/src/commands/webhooks/delete.test.ts b/packages/cli-core/src/commands/webhooks/delete.test.ts new file mode 100644 index 00000000..1d0c05de --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.test.ts @@ -0,0 +1,102 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockDeleteWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + deleteWebhookEndpoint: (...args: unknown[]) => mockDeleteWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksDelete } = await import("./delete.ts"); + +describe("webhooks delete", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockDeleteWebhookEndpoint.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockDeleteWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prompts before deleting in human mode", async () => { + await webhooksDelete({ endpointId: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Deleted webhook endpoint"); + }); + + test("--yes skips the prompt", async () => { + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toBeInstanceOf(UserAbortError); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("agent mode with --yes deletes without prompting", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockDeleteWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksDelete({ endpointId: "ep_missing", yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts new file mode 100644 index 00000000..1e02219d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -0,0 +1,35 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { deleteWebhookEndpoint } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksDeleteOptions extends WebhooksGlobalOptions { + endpointId: string; + yes?: boolean; +} + +export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. + await confirmDestructive( + `Permanently delete webhook endpoint ${options.endpointId}? This cannot be undone.`, + options, + ); + + const ctx = await resolveAppContext(options); + + await rejectEndpointNotFound( + withApiContext( + deleteWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + "Failed to delete webhook endpoint", + ), + options.endpointId, + ); + + log.success(`Deleted webhook endpoint \`${options.endpointId}\``); +} diff --git a/packages/cli-core/src/commands/webhooks/event-types.test.ts b/packages/cli-core/src/commands/webhooks/event-types.test.ts new file mode 100644 index 00000000..5062ba00 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.test.ts @@ -0,0 +1,143 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksEventTypes } = await import("./event-types.ts"); + +const mockEventTypes = [ + { + name: "user.created", + description: "A user was created", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + name: "session.removed", + description: "A session was removed", + archived: true, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, +]; + +function eventTypesResponse(hasNextPage = false) { + return { + data: mockEventTypes, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks event-types", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse()); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksEventTypes(); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksEventTypes({ limit: 5, iterator: "iter_prev" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 5, + iterator: "iter_prev", + }); + }); + + test("prints names and descriptions, marking archived types", async () => { + await webhooksEventTypes(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("A user was created"); + expect(captured.err).toContain("session.removed"); + expect(captured.err).toContain("(archived)"); + expect(captured.err).toContain("2 event types returned"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse(true)); + + await webhooksEventTypes(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("warns when the catalog is empty", async () => { + mockListWebhookEventTypes.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksEventTypes(); + + expect(captured.err).toContain("No event types found."); + }); + + test("prints iterator hint on empty-data page when more results exist", async () => { + mockListWebhookEventTypes.mockResolvedValue({ + data: [], + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: true }, + }); + + await webhooksEventTypes(); + + expect(captured.err).toContain("No event types found."); + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("outputs the full response as JSON with --json", async () => { + await webhooksEventTypes({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksEventTypes(); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/event-types.ts b/packages/cli-core/src/commands/webhooks/event-types.ts new file mode 100644 index 00000000..6b320640 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.ts @@ -0,0 +1,54 @@ +import { cyan, dim, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, type WebhookEventType } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksEventTypesOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +function formatEventTypesTable(eventTypes: WebhookEventType[]): void { + const nameWidth = Math.max("NAME".length, ...eventTypes.map((t) => t.name.length)) + 2; + + log.info(`${dim("NAME".padEnd(nameWidth))}${dim("DESCRIPTION")}`); + for (const eventType of eventTypes) { + const archived = eventType.archived ? ` ${yellow("(archived)")}` : ""; + log.info(`${cyan(eventType.name.padEnd(nameWidth))}${eventType.description ?? ""}${archived}`); + } +} + +export async function webhooksEventTypes(options: WebhooksEventTypesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEventTypes(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook event types", + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No event types found."); + printIteratorHint(response.cursor); + return; + } + + formatEventTypesTable(response.data); + const count = response.data.length; + log.info(`\n${count} event type${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/forward.test.ts b/packages/cli-core/src/commands/webhooks/forward.test.ts new file mode 100644 index 00000000..62e59ea4 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.test.ts @@ -0,0 +1,164 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { CliError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { buildForwardHeaders, forwardDelivery, parseHeaderPairs } from "./forward.ts"; + +const originalFetch = globalThis.fetch; + +describe("parseHeaderPairs", () => { + const parseCases: Array<{ + label: string; + value: string | undefined; + expected: Record; + }> = [ + { label: "undefined", value: undefined, expected: {} }, + { label: "empty string", value: "", expected: {} }, + { label: "single pair", value: "x-env:dev", expected: { "x-env": "dev" } }, + { + label: "multiple pairs with whitespace", + value: " x-env : dev , x-team:core ", + expected: { "x-env": "dev", "x-team": "core" }, + }, + { + label: "value containing colons (split on FIRST colon)", + value: "authorization:Bearer abc:def", + expected: { authorization: "Bearer abc:def" }, + }, + { label: "trailing comma", value: "x-env:dev,", expected: { "x-env": "dev" } }, + { label: "empty value", value: "x-empty:", expected: { "x-empty": "" } }, + ]; + + test.each(parseCases)("parses $label", ({ value, expected }) => { + expect(parseHeaderPairs(value)).toEqual(expected); + }); + + test.each([ + { label: "pair without a colon", value: "not-a-pair" }, + { label: "pair with an empty key", value: ":value" }, + ])("throws a usage error on $label", ({ value }) => { + expect(() => parseHeaderPairs(value)).toThrow(CliError); + }); +}); + +describe("buildForwardHeaders", () => { + const eventHeaders = { + "svix-id": "msg_1", + "svix-timestamp": "1717935000", + "svix-signature": "v1,abc", + "content-type": "application/json", + }; + + test("preserves delivery headers and adds extras", () => { + const headers = buildForwardHeaders(eventHeaders, { "x-env": "dev" }); + + expect(headers.get("svix-id")).toBe("msg_1"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-env")).toBe("dev"); + }); + + test("extras may override non-svix delivery headers", () => { + const headers = buildForwardHeaders(eventHeaders, { "Content-Type": "text/plain" }); + + expect(headers.get("content-type")).toBe("text/plain"); + }); + + test.each([ + { label: "lowercase", key: "svix-signature" }, + { label: "uppercase", key: "SVIX-SIGNATURE" }, + { label: "mixed case", key: "Svix-Signature" }, + ])("extras can never override svix-* headers ($label)", ({ key }) => { + const headers = buildForwardHeaders(eventHeaders, { [key]: "v1,forged" }); + + expect(headers.get("svix-signature")).toBe("v1,abc"); + }); +}); + +describe("forwardDelivery", () => { + useCaptureLog(); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs the body with headers and captures the response", async () => { + let captured: { url: string; method: string; body: string; headers: Headers } | undefined; + stubFetch(async (input, init) => { + captured = { + url: input.toString(), + method: init?.method ?? "GET", + body: String(init?.body), + headers: new Headers(init?.headers), + }; + return new Response("ok body", { status: 200, headers: { "x-served-by": "test" } }); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: buildForwardHeaders({ "svix-id": "msg_1" }, {}), + body: '{"type":"user.created"}', + }); + + expect(captured?.url).toBe("http://localhost:3000/api/webhooks"); + expect(captured?.method).toBe("POST"); + expect(captured?.body).toBe('{"type":"user.created"}'); + expect(captured?.headers.get("svix-id")).toBe("msg_1"); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(200); + expect(outcome.bodyText).toBe("ok body"); + expect(outcome.bodyB64).toBe(Buffer.from("ok body", "utf8").toString("base64")); + expect(outcome.headers["x-served-by"]).toBe("test"); + expect(outcome.latencyMs).toBeGreaterThanOrEqual(0); + }); + + test("returns a synthetic 502 when the local handler is unreachable", async () => { + stubFetch(async () => { + throw new Error("connection refused"); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:9/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(true); + expect(outcome.status).toBe(502); + expect(outcome.bodyText).toContain("connection refused"); + }); + + test("non-2xx handler responses are captured, not thrown", async () => { + stubFetch(async () => new Response("boom", { status: 500 })); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(500); + expect(outcome.bodyText).toBe("boom"); + }); + + 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..b1fa365a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.ts @@ -0,0 +1,89 @@ +import { errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; + +export interface ForwardOutcome { + status: number; + headers: Record; + bodyText: string; + bodyB64: string; + latencyMs: number; + /** True when the local handler was unreachable (status is a synthetic 502). */ + failed: boolean; +} + +/** Comma-separated `k:v` pairs, split on the FIRST colon, whitespace trimmed. */ +export function parseHeaderPairs(value: string | undefined): Record { + if (!value) return {}; + const headers: Record = {}; + for (const pair of value.split(",")) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const colonIndex = trimmed.indexOf(":"); + const key = colonIndex === -1 ? "" : trimmed.slice(0, colonIndex).trim(); + if (!key) { + throwUsageError(`Invalid --headers pair "${trimmed}". Expected key:value.`); + } + headers[key] = trimmed.slice(colonIndex + 1).trim(); + } + return headers; +} + +/** + * Delivery headers plus `--headers` extras. Extras may override non-svix + * delivery headers, but the delivery's `svix-*` headers always win — they are + * what `verify` (and the user's handler) authenticate against. + */ +export function buildForwardHeaders( + eventHeaders: Record, + extraHeaders: Record, +): Headers { + const headers = new Headers(eventHeaders); + for (const [key, value] of Object.entries(extraHeaders)) { + if (key.toLowerCase().startsWith("svix-")) continue; + headers.set(key, value); + } + return headers; +} + +export async function forwardDelivery(args: { + forwardTo: string; + method: string; + headers: Headers; + body: string; +}): Promise { + const startedAt = performance.now(); + try { + const response = await loggedFetch(args.forwardTo, { + tag: "relay", + method: args.method, + headers: args.headers, + body: args.body, + signal: AbortSignal.timeout(30_000), + }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { + status: response.status, + headers, + bodyText, + bodyB64: Buffer.from(bodyText, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: false, + }; + } catch (error) { + // Local handler unreachable. Frame a synthetic 502 back so Svix-side + // delivery telemetry records the failure instead of a hung attempt. + const message = errorMessage(error); + return { + status: 502, + headers: {}, + bodyText: message, + bodyB64: Buffer.from(message, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: true, + }; + } +} diff --git a/packages/cli-core/src/commands/webhooks/get.test.ts b/packages/cli-core/src/commands/webhooks/get.test.ts new file mode 100644 index 00000000..e565fc43 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.test.ts @@ -0,0 +1,107 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksGet } = await import("./get.ts"); + +const mockEndpoint = { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created", "user.deleted"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", +}; + +describe("webhooks get", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpoint.mockResolvedValue(mockEndpoint); + }); + + afterEach(() => { + mockGetWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches the endpoint by ID", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + }); + + test("prints endpoint details on stderr in human mode", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("enabled"); + expect(captured.err).toContain("user.created, user.deleted"); + }); + + test("outputs the bare endpoint resource as JSON with --json", async () => { + await webhooksGet({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksGet({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + const promise = webhooksGet({ endpointId: "ep_missing" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(promise).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + message: "No webhook endpoint with ID ep_missing was found.", + }); + }); + + test("re-throws non-404 PLAPI errors untouched", async () => { + const original = new PlapiError(500, "{}"); + mockGetWebhookEndpoint.mockRejectedValue(original); + + await expect(webhooksGet({ endpointId: "ep_1" })).rejects.toBe(original); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts new file mode 100644 index 00000000..4662ab01 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -0,0 +1,32 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { getWebhookEndpoint } from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksGetOptions extends WebhooksGlobalOptions { + endpointId: string; +} + +export async function webhooksGet(options: WebhooksGetOptions): Promise { + const ctx = await resolveAppContext(options); + const endpoint = await rejectEndpointNotFound( + withApiContext( + getWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + "Failed to fetch webhook endpoint", + ), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + formatEndpointDetails(endpoint); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts new file mode 100644 index 00000000..17d6762c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -0,0 +1,427 @@ +import { createOption } from "@commander-js/extra-typings"; +import type { Program } from "../../cli-program.ts"; +import { getAuthToken } from "../../lib/plapi.ts"; +import { parseIntegerOption } from "../../lib/option-parsers.ts"; +import { webhooksCreate } from "./create.ts"; +import { webhooksDelete } from "./delete.ts"; +import { webhooksEventTypes } from "./event-types.ts"; +import { webhooksGet } from "./get.ts"; +import { webhooksList } from "./list.ts"; +import { webhooksListen } from "./listen.ts"; +import { webhooksMessages } from "./messages.ts"; +import { webhooksOpen } from "./open.ts"; +import { webhooksReplay } from "./replay.ts"; +import { webhooksSecret } from "./secret.ts"; +import { webhooksTrigger } from "./trigger.ts"; +import { webhooksUpdate } from "./update.ts"; +import { webhooksVerify } from "./verify.ts"; + +const webhooksHandlers = { + list: webhooksList, + get: webhooksGet, + eventTypes: webhooksEventTypes, + secret: webhooksSecret, + delete: webhooksDelete, + update: webhooksUpdate, + create: webhooksCreate, + messages: webhooksMessages, + replay: webhooksReplay, + trigger: webhooksTrigger, + open: webhooksOpen, + verify: webhooksVerify, + listen: webhooksListen, +}; + +export function registerWebhooks(program: Program): void { + const webhooks = program + .command("webhooks") + .description("Manage webhook endpoints and deliveries") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk webhooks list", description: "List webhook endpoints" }, + { + command: "clerk webhooks create --url https://example.com/api/webhooks", + description: "Create an endpoint and print its signing secret", + }, + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + ]); + + webhooks.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.name() === "verify") return; // pure offline HMAC, no auth gate + // `listen --relay-only` is a standalone Svix Play tunnel — no instance + // context, no PLAPI, no auth. + if ( + actionCommand.name() === "listen" && + (actionCommand.opts() as { relayOnly?: boolean }).relayOnly + ) { + return; + } + await getAuthToken(); + }); + + webhooks + .command("list") + .description("List webhook endpoints for the instance") + .option("--limit ", "Maximum endpoints to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { command: "clerk webhooks list", description: "List webhook endpoints" }, + { command: "clerk webhooks list --limit 10", description: "List the first 10 endpoints" }, + { + command: "clerk webhooks list --iterator iter_abc", + description: "Fetch the next page using a previous response's cursor", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.list(cmd.optsWithGlobals() as Parameters[0]), + ); + + webhooks + .command("get") + .description("Show one webhook endpoint's configuration") + .argument("", "Webhook endpoint ID (ep_...)") + .setExamples([ + { command: "clerk webhooks get ep_2abc123", description: "Show an endpoint's config" }, + { + command: "clerk webhooks get ep_2abc123 --json", + description: "Emit the endpoint resource as JSON", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.get({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + + webhooks + .command("event-types") + .description("List the instance's webhook event-type catalog") + .option("--limit ", "Maximum event types to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { command: "clerk webhooks event-types", description: "List available event types" }, + { + command: "clerk webhooks event-types --json", + description: "Emit the catalog as JSON", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.eventTypes( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + + webhooks + .command("secret") + .description("Print a webhook endpoint's signing secret") + .argument("", "Webhook endpoint ID (ep_...)") + .option( + "--rotate", + "Rotate the signing secret first. The old key keeps verifying for 24h (Svix dual-signing grace).", + ) + .option("--yes", "Skip the rotation confirmation prompt (required with --rotate in agent mode)") + .setExamples([ + { command: "clerk webhooks secret ep_2abc123", description: "Print the signing secret" }, + { + command: "export CLERK_WEBHOOK_SIGNING_SECRET=$(clerk webhooks secret ep_2abc123)", + description: "Export the secret into the environment", + }, + { + command: "clerk webhooks secret ep_2abc123 --rotate", + description: "Rotate, then print the new secret", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.secret({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + + webhooks + .command("delete") + .description("Delete a webhook endpoint") + .argument("", "Webhook endpoint ID (ep_...)") + .option("--yes", "Skip the confirmation prompt (required in agent mode)") + .setExamples([ + { command: "clerk webhooks delete ep_2abc123", description: "Delete with confirmation" }, + { + command: "clerk webhooks delete ep_2abc123 --yes", + description: "Delete without prompting", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.delete({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + + webhooks + .command("update") + .description("Update a webhook endpoint's configuration") + .argument("", "Webhook endpoint ID (ep_...)") + .option("--url ", "New destination URL") + .option( + "--events ", + 'Comma-separated event types to filter on (e.g. user.created,user.deleted). Pass an empty value (--events "") to clear all filters', + ) + .option("--description ", "New description") + .option( + "--channels ", + 'Comma-separated channels. Pass an empty value (--channels "") to clear all channels', + ) + .option("--enable", "Re-enable a disabled endpoint") + .option("--disable", "Disable the endpoint") + .setExamples([ + { + command: "clerk webhooks update ep_2abc123 --url https://example.com/api/webhooks", + description: "Point the endpoint at a new URL", + }, + { + command: "clerk webhooks update ep_2abc123 --events user.created,user.deleted", + description: "Replace the event-type filter", + }, + { + command: "clerk webhooks update ep_2abc123 --enable", + description: "Re-enable an endpoint", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.update({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); + + webhooks + .command("create") + .description("Create a webhook endpoint and print its signing secret") + .option("--url ", "Destination URL (required)") + .option( + "--events ", + "Comma-separated event types to filter on (e.g. user.created,user.deleted)", + ) + .option("--description ", "Endpoint description") + .option("--channels ", "Comma-separated channels") + .option("--disabled", "Create the endpoint in a disabled state") + .setExamples([ + { + command: "clerk webhooks create --url https://example.com/api/webhooks", + description: "Create an endpoint receiving all events", + }, + { + command: + "clerk webhooks create --url https://example.com/api/webhooks --events user.created,user.deleted", + description: "Create an endpoint filtered to specific events", + }, + { + command: "clerk webhooks create --url https://example.com/api/webhooks --disabled", + description: "Create the endpoint disabled", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.create( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + + webhooks + .command("messages") + .description("List recent deliveries for an endpoint (the feed for `webhooks replay`)") + .option( + "--endpoint ", + "Endpoint to inspect (defaults to this instance's relay endpoint from `webhooks listen`)", + ) + .addOption( + createOption("--status ", "Filter by delivery status").choices([ + "success", + "pending", + "fail", + "sending", + ]), + ) + .option("--limit ", "Maximum deliveries to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--iterator ", "Pagination cursor from the previous response") + .setExamples([ + { + command: "clerk webhooks messages --endpoint ep_2abc123", + description: "List recent deliveries for an endpoint", + }, + { + command: "clerk webhooks messages --status fail", + description: "List failed deliveries on the relay endpoint", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.messages( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + + webhooks + .command("replay") + .description("Resend one delivery, or bulk-recover a time window of deliveries") + .argument("[msg_id]", "Message ID to resend (mutually exclusive with --since)") + .option( + "--endpoint ", + "Target endpoint (defaults to the relay endpoint for ; required with --since)", + ) + .option("--since ", "Bulk-recover deliveries from this RFC 3339 timestamp") + .option("--until ", "Optional end of the recovery window (requires --since)") + .option("--yes", "Skip the bulk-recovery confirmation prompt (required in agent mode)") + .setExamples([ + { + command: "clerk webhooks replay msg_2xyz", + description: "Resend one delivery to the relay endpoint", + }, + { + command: "clerk webhooks replay msg_2xyz --endpoint ep_2abc123", + description: "Resend one delivery to a specific endpoint", + }, + { + command: + "clerk webhooks replay --since 2026-05-01T00:00:00Z --until 2026-05-01T01:00:00Z --endpoint ep_2abc123", + description: "Recover all deliveries in a bounded window", + }, + ]) + .action((msgId, _opts, cmd) => + webhooksHandlers.replay({ + ...(cmd.optsWithGlobals() as Omit[0], "msgId">), + msgId, + }), + ); + + webhooks + .command("trigger") + .description("Send an example event to an endpoint (validates the type first)") + .argument("", "Event type to trigger (e.g. user.created)") + .option( + "--endpoint ", + "Target endpoint (defaults to this instance's relay endpoint from `webhooks listen`)", + ) + .setExamples([ + { + command: "clerk webhooks trigger user.created", + description: "Send an example user.created event to the relay endpoint", + }, + { + command: "clerk webhooks trigger user.created --endpoint ep_2abc123", + description: "Send an example event to a specific endpoint", + }, + ]) + .action((eventType, _opts, cmd) => + webhooksHandlers.trigger({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "eventType" + >), + eventType, + }), + ); + + webhooks + .command("open") + .description("Open the instance's webhook portal in your browser") + .setExamples([ + { command: "clerk webhooks open", description: "Open the webhook portal" }, + { command: "clerk webhooks open --json", description: "Print the portal URL as JSON" }, + ]) + .action((_opts, cmd) => + webhooksHandlers.open(cmd.optsWithGlobals() as Parameters[0]), + ); + + webhooks + .command("verify") + .description("Verify a webhook signature locally (offline, no auth required)") + .option("--secret ", "Signing secret (whsec_...), always required") + .option( + "--delivery ", + "One `listen` event NDJSON line as @file or - for stdin (alternative to the four explicit flags)", + ) + .option("--payload ", "Raw request body as @file or - for stdin") + .option("--id ", "The svix-id header value") + .option("--timestamp ", "The svix-timestamp header value (Unix epoch seconds)") + .option("--signature ", "The raw svix-signature header value (may hold multiple entries)") + .setExamples([ + { + command: + "clerk webhooks verify --secret whsec_... --payload @body.json --id msg_2xyz --timestamp 1717935000 --signature v1,abc...", + description: "Verify from the four header values", + }, + { + command: "clerk webhooks verify --secret whsec_... --delivery @event.json", + description: "Verify a saved `listen` event line", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.verify( + cmd.optsWithGlobals() as Parameters[0], + ), + ); + + webhooks + .command("listen") + .description("Stream instance events to your terminal and forward them to a local handler") + .option("--forward-to ", "Local URL to POST deliveries to (omit to just print events)") + .option( + "--events ", + "Comma-separated event types to filter on (PATCHes the shared relay endpoint's filter)", + ) + .option("--skip-verify", "Skip HMAC verification of incoming deliveries") + .option( + "--relay-only", + "Standalone tunnel: connect to the Svix relay and forward without registering a Clerk endpoint or fetching a secret (no auth, no backend; verification off)", + ) + .option( + "--token ", + "Pin the relay token (only with --relay-only) so the inbox URL stays fixed. Format: c_ + 10 base62 chars", + ) + .option( + "--headers ", + "Extra headers for the forwarded request, comma-separated k:v pairs (svix-* cannot be overridden)", + ) + .setExamples([ + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + { + command: "clerk webhooks listen --events user.created,user.deleted", + description: "Only receive specific event types", + }, + { + command: "clerk webhooks listen --json", + description: "Emit NDJSON event lines (pipe into a file for `webhooks verify --delivery`)", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.listen( + cmd.optsWithGlobals() as Parameters[0], + ), + ); +} diff --git a/packages/cli-core/src/commands/webhooks/list.test.ts b/packages/cli-core/src/commands/webhooks/list.test.ts new file mode 100644 index 00000000..26017a02 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.test.ts @@ -0,0 +1,185 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEndpoints = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEndpoints: (...args: unknown[]) => mockListWebhookEndpoints(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksList } = await import("./list.ts"); + +const mockEndpoints = [ + { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + id: "ep_2", + url: "https://example.com/other", + version: 1, + disabled: true, + filter_types: null, + channels: null, + created_at: "2026-06-02T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", + }, +]; + +function listResponse(overrides: Partial<{ data: unknown[]; has_next_page: boolean }> = {}) { + return { + data: overrides.data ?? mockEndpoints, + cursor: { + starting_after: "iter_next", + ending_before: null, + has_next_page: overrides.has_next_page ?? false, + }, + }; +} + +describe("webhooks list", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEndpoints.mockResolvedValue(listResponse()); + }); + + afterEach(() => { + mockListWebhookEndpoints.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksList(); + + expect(mockResolveAppContext).toHaveBeenCalledWith({}); + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksList({ limit: 25, iterator: "iter_prev" }); + + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 25, + iterator: "iter_prev", + }); + }); + + test("forwards --app and --instance to context resolution", async () => { + await webhooksList({ app: "app_2", instance: "prod" }); + + expect(mockResolveAppContext).toHaveBeenCalledWith({ app: "app_2", instance: "prod" }); + }); + + test("prints a human-readable table by default", async () => { + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("disabled"); + expect(captured.err).toContain("2 endpoints returned"); + }); + + test("warns when no endpoints exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ data: [] })); + + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No webhook endpoints found."); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ has_next_page: true })); + + await webhooksList(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("still hints at the next --iterator on an empty page with has_next_page", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ data: [], has_next_page: true })); + + await webhooksList(); + + expect(captured.err).toContain("No webhook endpoints found."); + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("omits the pagination hint on the last page", async () => { + await webhooksList(); + + expect(captured.err).not.toContain("--iterator"); + }); + + test("outputs the full list response as JSON with --json", async () => { + await webhooksList({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksList(); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); + + test("resolves to an empty list when the instance has no Svix app yet (svix_app_missing)", async () => { + mockListWebhookEndpoints.mockRejectedValue( + new PlapiError( + 400, + JSON.stringify({ + errors: [ + { + code: "svix_app_missing", + message: "No Svix apps are associated with the current instance.", + }, + ], + }), + ), + ); + + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No webhook endpoints found."); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/list.ts b/packages/cli-core/src/commands/webhooks/list.ts new file mode 100644 index 00000000..5e812cde --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.ts @@ -0,0 +1,78 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { PlapiError, withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEndpoints, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksListOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +const COLUMN_PADDING = 2; + +function endpointStatus(endpoint: WebhookEndpoint): string { + return endpoint.disabled ? "disabled" : "enabled"; +} + +function endpointEvents(endpoint: WebhookEndpoint): string { + return endpoint.filter_types?.length ? endpoint.filter_types.join(",") : "all"; +} + +function formatEndpointsTable(endpoints: WebhookEndpoint[]): void { + const idWidth = Math.max("ID".length, ...endpoints.map((e) => e.id.length)) + COLUMN_PADDING; + const urlWidth = Math.max("URL".length, ...endpoints.map((e) => e.url.length)) + COLUMN_PADDING; + const statusWidth = + Math.max("STATUS".length, ...endpoints.map((e) => endpointStatus(e).length)) + COLUMN_PADDING; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("URL".padEnd(urlWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("EVENTS")}`, + ); + for (const endpoint of endpoints) { + log.info( + `${cyan(endpoint.id.padEnd(idWidth))}${endpoint.url.padEnd(urlWidth)}${endpointStatus(endpoint).padEnd(statusWidth)}${endpointEvents(endpoint)}`, + ); + } +} + +export async function webhooksList(options: WebhooksListOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEndpoints(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook endpoints", + ).catch((error) => { + if (error instanceof PlapiError && error.status === 400 && error.code === "svix_app_missing") { + return { + data: [] as WebhookEndpoint[], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }; + } + throw error; + }); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No webhook endpoints found."); + printIteratorHint(response.cursor); + return; + } + + formatEndpointsTable(response.data); + const count = response.data.length; + log.info(`\n${count} endpoint${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts new file mode 100644 index 00000000..cb0e1448 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -0,0 +1,470 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { createHmac, randomBytes } from "node:crypto"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import type { RelayEventFrame } from "./relay-protocol.ts"; + +type EventHandler = (event: RelayEventFrame, reply: (frame: string) => void) => void; + +interface FakeClientOptions { + token: string; + onEvent: EventHandler; + onTokenRotated: (token: string) => Promise; + onReconnect: () => void; +} + +const relayClients: FakeRelayClient[] = []; +const lastClient = () => relayClients.at(-1); + +class FakeRelayClient { + token: string; + started = false; + stopped = false; + + constructor(readonly options: FakeClientOptions) { + 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 mockGetWebhookEndpoint = mock(); +const mockCreateWebhookEndpoint = mock(); +const mockUpdateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +const mockSetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), + setRelayEntry: (...args: unknown[]) => mockSetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksListen } = await import("./listen.ts"); + +const KEY = randomBytes(24); +const SECRET = `whsec_${KEY.toString("base64")}`; + +const relayEndpoint = (overrides: Record = {}) => ({ + id: "ep_relay", + url: "https://play.svix.com/in/Ab12Cd34Ef/", + version: 1, + disabled: false, + filter_types: null, + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", + ...overrides, +}); + +function signedEvent(body: string, overrides: Partial = {}): RelayEventFrame { + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = `v1,${createHmac("sha256", KEY).update(`msg_1.${timestamp}.${body}`, "utf8").digest("base64")}`; + return { + id: "frame_1", + method: "POST", + headers: { + "svix-id": "msg_1", + "svix-timestamp": timestamp, + "svix-signature": signature, + "content-type": "application/json", + }, + bodyB64: Buffer.from(body, "utf8").toString("base64"), + ...overrides, + }; +} + +/** listen never resolves; run it and wait until the ready output lands. */ +async function startListen( + options: Parameters[0], + captured: { out: string; err: string }, +): Promise { + const run = webhooksListen(options); + run.catch(() => {}); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (captured.out.includes('"ready"') || captured.err.includes("Webhook relay ready")) return; + } + throw new Error("listen never became ready"); +} + +describe("webhooks listen", () => { + const captured = useCaptureLog(); + const originalFetch = globalThis.fetch; + let savedSigintListeners: NodeJS.SignalsListener[] = []; + + beforeEach(() => { + savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; + mockIsAgent.mockReturnValue(false); + relayClients.length = 0; + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockSetRelayEntry.mockResolvedValue(undefined); + mockGetWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockCreateWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockUpdateWebhookEndpoint.mockImplementation( + async (_app: string, _ins: string, _ep: string, patch: Record) => + relayEndpoint(patch), + ); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: SECRET }); + }); + + afterEach(() => { + process.removeAllListeners("SIGINT"); + for (const listener of savedSigintListeners) process.on("SIGINT", listener); + globalThis.fetch = originalFetch; + mockGetWebhookEndpoint.mockReset(); + mockCreateWebhookEndpoint.mockReset(); + mockUpdateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockSetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("invalid --headers is a usage error before any network call", async () => { + await expect(webhooksListen({ headers: "not-a-pair" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("first run generates and persists a c_-prefixed base62 token, then creates the endpoint", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({}, captured); + + const [firstInstanceId, firstEntry] = mockSetRelayEntry.mock.calls[0] as [ + string, + { token: string }, + ]; + const persistedToken = firstEntry.token; + expect(firstInstanceId).toBe("ins_1"); + expect(persistedToken).toMatch(/^c_[0-9A-Za-z]{10}$/); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: `https://play.svix.com/in/${persistedToken}/`, + version: 1, + }); + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: persistedToken, + endpoint_id: "ep_relay", + }); + }); + + test("reuses the persisted endpoint without patching when nothing changed", async () => { + await startListen({}, captured); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay"); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + expect(captured.err).toContain("Webhook relay ready"); + expect(captured.err).toContain(SECRET); + }); + + test("PATCHes filter_types (with a warning) when --events differs", async () => { + await startListen({ events: "user.created,user.deleted" }, captured); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + filter_types: ["user.created", "user.deleted"], + }); + expect(captured.err).toContain("affects any other"); + }); + + test("recreates the endpoint when the persisted one returns 404", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await startListen({}, captured); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalled(); + }); + + test("relay-only skips PLAPI + context, persists a c_ token, renders a standalone banner", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({ relayOnly: true }, captured); + + // No backend, no instance context. + expect(mockResolveAppContext).not.toHaveBeenCalled(); + expect(mockGetWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockGetWebhookEndpointSecret).not.toHaveBeenCalled(); + + // Token persisted under the reserved relay-only key so the URL is stable. + expect(mockGetRelayEntry).toHaveBeenCalledWith("__relay_only__"); + const [key, entry] = mockSetRelayEntry.mock.calls[0] as [string, { token: string }]; + expect(key).toBe("__relay_only__"); + expect(entry.token).toMatch(/^c_[0-9A-Za-z]{10}$/); + + const client = lastClient(); + expect(client?.started).toBe(true); + expect(client?.token).toBe(entry.token); + expect(captured.err).toContain("relay-only"); + expect(captured.err).not.toContain(SECRET); + }); + + test("relay-only reuses the persisted token across runs (stable URL)", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "c_Persisted1" }); + + await startListen({ relayOnly: true }, captured); + + expect(lastClient()?.token).toBe("c_Persisted1"); + expect(mockSetRelayEntry).not.toHaveBeenCalled(); // unchanged → no rewrite + }); + + test("relay-only --token pins the token", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({ relayOnly: true, token: "c_Pinned1234" }, captured); + + expect(lastClient()?.token).toBe("c_Pinned1234"); + expect(mockSetRelayEntry).toHaveBeenCalledWith("__relay_only__", { token: "c_Pinned1234" }); + }); + + test("--token without --relay-only is a usage error", async () => { + await expect(webhooksListen({ token: "c_Whatever12" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("relay-only --token with a malformed token is a usage error", async () => { + await expect(webhooksListen({ relayOnly: true, token: "nope" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("emits the NDJSON ready line in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ forwardTo: "http://localhost:3000/api/webhooks" }, captured); + + const ready = JSON.parse(captured.out) as Record; + expect(ready).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + endpoint_id: "ep_relay", + events_filter: null, + forward_to: "http://localhost:3000/api/webhooks", + }); + // The signing secret must never appear on stdout (it's pipeable/loggable); + // agents fetch it on demand via `clerk webhooks secret `. + expect(ready).not.toHaveProperty("signing_secret"); + expect(captured.out).not.toContain(SECRET); + }); + + 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("deliveries arriving before the secret fetch wait for setup to finish", async () => { + mockIsAgent.mockReturnValue(true); + let releaseSecret!: (value: { secret: string }) => void; + mockGetWebhookEndpointSecret.mockReturnValue( + new Promise((resolve) => { + releaseSecret = resolve; + }), + ); + + const run = webhooksListen({}); + run.catch(() => {}); + for (let i = 0; i < 50 && !lastClient(); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.out).not.toContain('"type":"event"'); + expect(captured.err).not.toContain("signature verification failed"); + + releaseSecret({ secret: SECRET }); + for (let i = 0; i < 50 && !captured.out.includes('"type":"event"'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const readyIndex = captured.out.indexOf('"ready"'); + const eventIndex = captured.out.indexOf('"type":"event"'); + expect(readyIndex).toBeGreaterThanOrEqual(0); + expect(eventIndex).toBeGreaterThan(readyIndex); + expect(captured.err).not.toContain("signature verification failed"); + }); + + test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const replies: string[] = []; + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(JSON.parse(replies[0]!)).toEqual({ + type: "event", + version: 1, + data: { id: "frame_1", status: 200, headers: {}, body: "" }, + }); + + const line = JSON.parse(captured.out) as Record; + expect(line.type).toBe("event"); + expect(line.svix_id).toBe("msg_1"); + expect(line.event_type).toBe("user.created"); + expect(line.forward_status).toBeNull(); + expect(captured.err).toBe(""); // no verification warning for a valid signature + }); + + test("delivery with --forward-to POSTs to the handler and frames the response back", async () => { + mockIsAgent.mockReturnValue(true); + let forwarded: { url: string; headers: Headers; body: string } | undefined; + stubFetch(async (input, init) => { + forwarded = { + url: input.toString(), + headers: new Headers(init?.headers), + body: String(init?.body), + }; + return new Response("handled", { status: 201 }); + }); + + await startListen( + { forwardTo: "http://localhost:3000/api/webhooks", headers: "x-env:dev" }, + captured, + ); + captured.clear(); + + const replies: string[] = []; + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + for (let i = 0; i < 20 && replies.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(forwarded?.url).toBe("http://localhost:3000/api/webhooks"); + expect(forwarded?.headers.get("svix-id")).toBe("msg_1"); + expect(forwarded?.headers.get("x-env")).toBe("dev"); + expect(forwarded?.body).toBe('{"type":"user.created"}'); + + const reply = JSON.parse(replies[0]!) as { data: { status: number; body: string } }; + expect(reply.data.status).toBe(201); + expect(Buffer.from(reply.data.body, "base64").toString("utf8")).toBe("handled"); + + const line = JSON.parse(captured.out) as { forward_status: number }; + expect(line.forward_status).toBe(201); + }); + + test("warns on an invalid signature but still forwards", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient()!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toContain("signature verification failed for msg_1"); + expect(captured.out).toContain('"type":"event"'); + }); + + test("--skip-verify suppresses the signature warning", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ skipVerify: true }, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient()!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toBe(""); + }); + + test("token rotation persists the new token and re-points the endpoint URL", async () => { + await startListen({}, captured); + + await lastClient()!.options.onTokenRotated("c_Zz98Yy76Xx"); + + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: "c_Zz98Yy76Xx", + endpoint_id: "ep_relay", + }); + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + url: "https://play.svix.com/in/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", async () => { + await startListen({}, captured); + captured.clear(); + + lastClient()!.options.onReconnect(); + + expect(captured.err).toContain("reconnect"); + }); + + test("recreates the endpoint when the persisted one returns 400 svix_app_missing", async () => { + mockGetWebhookEndpoint.mockRejectedValue( + new PlapiError( + 400, + JSON.stringify({ errors: [{ code: "svix_app_missing", message: "Svix app missing" }] }), + ), + ); + + await startListen({}, captured); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalled(); + }); +}); 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..455acc63 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -0,0 +1,359 @@ +import { getRelayEntry, resolveAppContext, setRelayEntry } from "../../lib/config.ts"; +import { + EXIT_CODE, + PlapiError, + errorMessage, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; +import { cliSigintHandler } from "../../lib/signals.ts"; +import { dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpoint, + getWebhookEndpointSecret, + updateWebhookEndpoint, + type UpdateWebhookEndpointParams, + type WebhookEndpoint, +} from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { + buildForwardHeaders, + forwardDelivery, + parseHeaderPairs, + type ForwardOutcome, +} from "./forward.ts"; +import { RelayClient } from "./relay-client.ts"; +import { + decodeEventBody, + encodeEventResponseFrame, + generateRelayToken, + relayReceiveUrl, + type RelayEventFrame, +} from "./relay-protocol.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; +import { splitCommaList, type WebhooksGlobalOptions } from "./shared.ts"; +import { verifyWebhookSignature } from "./verify.ts"; + +export interface WebhooksListenOptions extends WebhooksGlobalOptions { + forwardTo?: string; + events?: string; + skipVerify?: boolean; + headers?: string; + relayOnly?: boolean; + token?: string; +} + +interface ListenContext { + appId: string; + instanceId: string; +} + +// Reserved config key for the relay-only token. Real instance IDs are `ins_…`, +// so this never collides with a persisted per-instance relay entry. +const RELAY_ONLY_KEY = "__relay_only__"; + +/** 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).`, + ); + } +} + +function sameFilter(current: string[] | null | undefined, next: string[]): boolean { + const a = [...(current ?? [])].sort(); + const b = [...next].sort(); + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * Find-or-create is CLIENT-side: reuse the persisted endpoint ID, re-pointing + * its URL if the token rotated and PATCHing `filter_types` when `--events` + * differs. On 404 (or first run) create and persist. The backend does no + * URL-uniqueness matching. + */ +async function ensureRelayEndpoint( + ctx: ListenContext, + token: string, + eventsFilter: string[] | undefined, +): Promise { + const relayUrl = relayReceiveUrl(token); + const entry = await getRelayEntry(ctx.instanceId); + + if (entry?.endpoint_id) { + try { + let endpoint = await withApiContext( + getWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id), + "Failed to get relay endpoint", + ); + const patch: UpdateWebhookEndpointParams = {}; + if (endpoint.url !== relayUrl) patch.url = relayUrl; + if (eventsFilter && !sameFilter(endpoint.filter_types, eventsFilter)) { + log.warn( + "Updating the relay endpoint's event filter — this affects any other `listen` session sharing this instance's relay endpoint.", + ); + patch.filter_types = eventsFilter; + } else if (!eventsFilter && (endpoint.filter_types?.length ?? 0) > 0) { + patch.filter_types = []; + } + if (Object.keys(patch).length > 0) { + endpoint = await withApiContext( + updateWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id, patch), + "Failed to update relay endpoint", + ); + } + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; + } catch (error) { + if ( + !( + error instanceof PlapiError && + (error.status === 404 || (error.status === 400 && error.code === "svix_app_missing")) + ) + ) + throw error; + // The persisted endpoint was deleted out from under us — recreate. + } + } + + const endpoint = await withApiContext( + createWebhookEndpoint(ctx.appId, ctx.instanceId, { + url: relayUrl, + version: 1, + ...(eventsFilter ? { filter_types: eventsFilter } : {}), + }), + "Failed to create relay endpoint", + ); + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; +} + +function extractEventType(body: string): string { + try { + const parsed = JSON.parse(body) as { type?: unknown }; + if (typeof parsed.type === "string" && parsed.type) return parsed.type; + } catch { + // Non-JSON bodies still render; the type is just unknown. + } + return "unknown"; +} + +function forwardPath(forwardTo: string): string { + try { + return new URL(forwardTo).pathname; + } catch { + return forwardTo; + } +} + +export async function webhooksListen(options: WebhooksListenOptions = {}): Promise { + const relayOnly = Boolean(options.relayOnly); + const ndjson = Boolean(options.json) || isAgent(); + const extraHeaders = parseHeaderPairs(options.headers); + const rawFilter = splitCommaList(options.events); + const eventsFilter = rawFilter?.length ? rawFilter : undefined; + // relay-only has no signing secret (no backend), so it can't verify. + const verifyDeliveries = !options.skipVerify && !relayOnly; + + if (options.token) { + if (!relayOnly) throwUsageError("--token is only valid with --relay-only."); + assertRelayToken(options.token); + } + + // relay-only is a standalone Svix Play tunnel (no instance context, no PLAPI), + // but it still persists its token under a reserved key so the relay URL stays + // stable across runs — register it once in the dashboard and keep using it. + // `--token` pins an explicit token (shareable / memorable). Every other mode + // resolves the linked instance and reuses its persisted per-instance token. + let ctx: ListenContext | undefined; + let token: string; + if (relayOnly) { + const existing = await getRelayEntry(RELAY_ONLY_KEY); + token = options.token ?? existing?.token ?? generateRelayToken(); + if (token !== existing?.token) await setRelayEntry(RELAY_ONLY_KEY, { token }); + } else { + ctx = await resolveAppContext(options); + const entry = await getRelayEntry(ctx.instanceId); + token = entry?.token ?? generateRelayToken(); + if (!entry?.token) await setRelayEntry(ctx.instanceId, { ...entry, token }); + } + + const inFlight = new Set>(); + let tokenRotationTask: Promise | undefined; + let client: RelayClient | undefined; + let shuttingDown = false; + + // Deliveries can arrive as soon as the relay handshake completes (flow step + // 2), but the signing secret only lands after the endpoint is resolved (step + // 5) — verifying against the empty secret would warn falsely, so processing + // waits on this gate, which resolves WITH the signing secret once the ready + // line is out (the SIGINT path resolves it with "" to unblock the drain). + let resolveSetupGate!: (secret: string) => void; + const setupGate = new Promise((resolve) => { + resolveSetupGate = resolve; + }); + + // Own SIGINT handling, registered BEFORE the socket opens. The global + // handler (cli.ts) is a cleanup-free exit(130) and would fire first, so it + // has to go: close the socket, drain in-flight forwards, then exit 130. + // The relay endpoint is never deleted — its URL and secret stay stable. + process.removeListener("SIGINT", cliSigintHandler); + process.on("SIGINT", () => { + void (async () => { + shuttingDown = true; // MUST precede resolveSetupGate so processDelivery short-circuits + resolveSetupGate(""); // gated deliveries must settle or the drain hangs + client?.stop(); + await Promise.allSettled([...inFlight, ...(tokenRotationTask ? [tokenRotationTask] : [])]); + process.exit(EXIT_CODE.SIGINT); + })(); + }); + + async function processDelivery( + event: RelayEventFrame, + reply: (frame: string) => void, + ): Promise { + const endpointSecret = await setupGate; + if (shuttingDown) return; + + const body = decodeEventBody(event); + const svixId = event.headers["svix-id"] ?? event.id; + const eventType = extractEventType(body); + + if (verifyDeliveries) { + const verified = verifyWebhookSignature({ + secret: endpointSecret, + id: svixId, + timestamp: event.headers["svix-timestamp"] ?? "", + payload: body, + signature: event.headers["svix-signature"] ?? "", + }); + if (!verified) renderVerificationWarning(svixId); + } + + if (!ndjson) renderArrival(eventType, svixId); + + let outcome: ForwardOutcome | null = null; + if (options.forwardTo) { + outcome = await forwardDelivery({ + forwardTo: options.forwardTo, + method: event.method, + headers: buildForwardHeaders(event.headers, extraHeaders), + body, + }); + reply( + encodeEventResponseFrame({ + id: event.id, + status: outcome.status, + headers: outcome.headers, + bodyB64: outcome.bodyB64, + }), + ); + } else { + // No local handler: frame a synthetic 200 so Svix-side delivery + // telemetry records a completed attempt instead of a hang. + reply(encodeEventResponseFrame({ id: event.id, status: 200, headers: {}, bodyB64: "" })); + } + + if (ndjson) { + log.data( + buildEventLine({ + svixId, + eventType, + headers: event.headers, + bodyB64: event.bodyB64, + forwardStatus: outcome ? outcome.status : null, + latencyMs: outcome?.latencyMs ?? 0, + }), + ); + return; + } + + if (outcome && options.forwardTo) { + renderForwardResult(outcome, event.method, forwardPath(options.forwardTo)); + renderForwardDiagnostics(outcome, svixId); + } + } + + client = new RelayClient({ + token, + onEvent: (event, reply) => { + const task = processDelivery(event, reply).catch((error) => { + log.debug(`relay: delivery handling failed: ${errorMessage(error)}`); + }); + inFlight.add(task); + void task.finally(() => inFlight.delete(task)); + }, + onTokenRotated: (newToken) => { + // relay-only: persist the new token so the next run reuses it; there's no + // registered endpoint to re-point (the dashboard endpoint needs a manual + // URL update after a collision, which is rare). + if (!ctx) { + tokenRotationTask = setRelayEntry(RELAY_ONLY_KEY, { token: newToken }); + return tokenRotationTask; + } + const c = ctx; + tokenRotationTask = (async () => { + const current = await getRelayEntry(c.instanceId); + await setRelayEntry(c.instanceId, { ...current, token: newToken }); + // The registered endpoint must follow the new relay URL or deliveries + // land in the old (now foreign) inbox. + if (current?.endpoint_id) { + try { + await updateWebhookEndpoint(c.appId, c.instanceId, current.endpoint_id, { + url: relayReceiveUrl(newToken), + }); + } catch (error) { + log.warn( + `Could not re-point the relay endpoint after a token rotation: ${errorMessage(error)} Webhook deliveries will be lost until you restart \`clerk webhooks listen\`.`, + ); + } + } + })(); + return tokenRotationTask; + }, + onReconnect: () => { + log.ui(dim("relay connection lost — reconnecting…\n")); + }, + }); + + await client.start(); + + let endpointId: string | null = null; + let signingSecret: string | null = null; + if (!relayOnly && ctx) { + const endpoint = await ensureRelayEndpoint(ctx, client.token, eventsFilter); + endpointId = endpoint.id; + ({ secret: signingSecret } = await withApiContext( + getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id), + "Failed to get relay endpoint signing secret", + )); + } + + const readyInfo = { + relayUrl: relayReceiveUrl(client.token), + signingSecret, + endpointId, + eventsFilter: eventsFilter ?? null, + forwardTo: options.forwardTo ?? null, + }; + if (ndjson) { + log.data(buildReadyLine(readyInfo)); + } else { + renderReadyBanner(readyInfo); + } + resolveSetupGate(signingSecret ?? ""); + + // 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/messages.test.ts b/packages/cli-core/src/commands/webhooks/messages.test.ts new file mode 100644 index 00000000..f2ab49e7 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.test.ts @@ -0,0 +1,181 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookMessages: (...args: unknown[]) => mockListWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksMessages } = await import("./messages.ts"); + +const mockMessages = [ + { + id: "msg_1", + event_type: "user.created", + status: "success", + next_attempt: null, + payload: { object: "event" }, + created_at: "2026-06-09T12:00:00Z", + }, + { + id: "msg_2", + event_type: "user.deleted", + status: "fail", + next_attempt: "2026-06-09T12:05:00Z", + payload: { object: "event" }, + created_at: "2026-06-09T12:01:00Z", + }, +]; + +function messagesResponse(hasNextPage = false) { + return { + data: mockMessages, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks messages", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookMessages.mockResolvedValue(messagesResponse()); + }); + + afterEach(() => { + mockListWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("lists deliveries for an explicit --endpoint", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 100, + iterator: undefined, + status: undefined, + }); + }); + + test("defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksMessages(); + + expect(mockGetRelayEntry).toHaveBeenCalledWith("ins_1"); + expect(mockListWebhookMessages).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + expect.anything(), + ); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + await expect(webhooksMessages()).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + message: + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + }); + expect(mockListWebhookMessages).not.toHaveBeenCalled(); + }); + + test("forwards --status, --limit, and --iterator", async () => { + await webhooksMessages({ endpoint: "ep_1", status: "fail", limit: 10, iterator: "iter_x" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 10, + iterator: "iter_x", + status: "fail", + }); + }); + + test("prints a delivery table in human mode", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("msg_1"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("fail"); + expect(captured.err).toContain("2 deliveries returned"); + }); + + test("warns when the endpoint has no deliveries", async () => { + mockListWebhookMessages.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("No deliveries found"); + }); + + test("prints iterator hint on empty-data page when more results exist", async () => { + mockListWebhookMessages.mockResolvedValue({ + data: [], + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: true }, + }); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("No deliveries found"); + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookMessages.mockResolvedValue(messagesResponse(true)); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("outputs the full response (including payloads) as JSON with --json", async () => { + await webhooksMessages({ endpoint: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockListWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksMessages({ endpoint: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/messages.ts b/packages/cli-core/src/commands/webhooks/messages.ts new file mode 100644 index 00000000..03d71bf5 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.ts @@ -0,0 +1,82 @@ +import { cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { + listWebhookMessages, + type WebhookMessage, + type WebhookMessageStatus, +} from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + rejectEndpointNotFound, + resolveEndpointOrRelay, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksMessagesOptions extends WebhooksGlobalOptions { + endpoint?: string; + status?: WebhookMessageStatus; + limit?: number; + iterator?: string; +} + +// Pad before coloring so ANSI codes don't skew the column width. +function paddedStatus(status: WebhookMessage["status"], width: number): string { + const padded = status.padEnd(width); + switch (status) { + case "success": + return green(padded); + case "fail": + return red(padded); + default: + return yellow(padded); + } +} + +function formatMessagesTable(messages: WebhookMessage[]): void { + const idWidth = Math.max("ID".length, ...messages.map((m) => m.id.length)) + 2; + const eventWidth = Math.max("EVENT TYPE".length, ...messages.map((m) => m.event_type.length)) + 2; + const statusWidth = Math.max("STATUS".length, ...messages.map((m) => m.status.length)) + 2; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("EVENT TYPE".padEnd(eventWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("CREATED")}`, + ); + for (const message of messages) { + log.info( + `${cyan(message.id.padEnd(idWidth))}${message.event_type.padEnd(eventWidth)}${paddedStatus(message.status, statusWidth)}${message.created_at}`, + ); + } +} + +export async function webhooksMessages(options: WebhooksMessagesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + const response = await rejectEndpointNotFound( + listWebhookMessages(ctx.appId, ctx.instanceId, endpointId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + status: options.status, + }), + endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn(`No deliveries found for \`${endpointId}\`.`); + printIteratorHint(response.cursor); + return; + } + + formatMessagesTable(response.data); + const count = response.data.length; + log.info(`\n${count} deliver${count === 1 ? "y" : "ies"} returned for \`${endpointId}\``); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/open.test.ts b/packages/cli-core/src/commands/webhooks/open.test.ts new file mode 100644 index 00000000..69d089f2 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.test.ts @@ -0,0 +1,109 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookPortalUrl = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookPortalUrl: (...args: unknown[]) => mockGetWebhookPortalUrl(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const { webhooksOpen } = await import("./open.ts"); + +const PORTAL_URL = "https://app.svix.com/login#key=abc"; + +describe("webhooks open", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookPortalUrl.mockResolvedValue({ url: PORTAL_URL }); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + }); + + afterEach(() => { + mockGetWebhookPortalUrl.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockOpenBrowser.mockReset(); + }); + + test("fetches the portal URL and opens the browser in human mode", async () => { + await webhooksOpen(); + + expect(mockGetWebhookPortalUrl).toHaveBeenCalledWith("app_1", "ins_1"); + expect(mockOpenBrowser).toHaveBeenCalledWith(PORTAL_URL); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Opening the webhook portal"); + }); + + test("prints a fallback URL when the browser cannot be opened", async () => { + mockOpenBrowser.mockResolvedValue({ ok: false, reason: "no-launcher" }); + + await webhooksOpen(); + + expect(captured.err).toContain("Could not open your browser automatically"); + expect(captured.err).toContain(PORTAL_URL); + }); + + test("outputs { url } without launching a browser with --json", async () => { + await webhooksOpen({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("outputs { url } without launching a browser in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksOpen(); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("throws a friendly CliError when no Svix app exists yet (svix_app_missing)", async () => { + mockGetWebhookPortalUrl.mockRejectedValue( + new PlapiError( + 400, + JSON.stringify({ + errors: [ + { + code: "svix_app_missing", + message: "No Svix apps are associated with the current instance.", + }, + ], + }), + ), + ); + + const error = await webhooksOpen().catch((e: unknown) => e); + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toContain("No webhooks configured yet"); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/open.ts b/packages/cli-core/src/commands/webhooks/open.ts new file mode 100644 index 00000000..561d5e8f --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.ts @@ -0,0 +1,39 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, PlapiError, withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { getWebhookPortalUrl } from "../../lib/plapi.ts"; +import { printJson, shouldOutputJson, type WebhooksGlobalOptions } from "./shared.ts"; + +export type WebhooksOpenOptions = WebhooksGlobalOptions; + +export async function webhooksOpen(options: WebhooksOpenOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const { url } = await withApiContext( + getWebhookPortalUrl(ctx.appId, ctx.instanceId), + "Failed to fetch the webhook portal URL", + ).catch((error) => { + if (error instanceof PlapiError && error.status === 400 && error.code === "svix_app_missing") { + throw new CliError( + "No webhooks configured yet. Run `clerk webhooks create` to set up your first endpoint.", + ); + } + throw error; + }); + + if (shouldOutputJson(options)) { + printJson({ url }); + return; + } + + log.info(`↗ Opening the webhook portal for \`${ctx.appLabel}\` (${ctx.instanceLabel})`); + log.info(` ${dim(url)}`); + + const result = await openBrowser(url); + if (!result.ok) { + log.warn( + `Could not open your browser automatically. Open this URL to continue:\n ${cyan(url)}\n${dim(`(Reason: ${result.reason})`)}`, + ); + } +} 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..21ab14db --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -0,0 +1,160 @@ +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; +} + +/** + * Long-lived relay WebSocket using Bun's built-in client. Reconnects with the + * same token (the relay URL — and therefore the registered endpoint — never + * changes across reconnects); rotates the token only on close code 1008. + */ +export class RelayClient { + token: string; + + private ws: WebSocket | undefined; + private stopped = false; + private probeTimer: ReturnType | undefined; + private lastActivityAt = Date.now(); + private resolveFirstOpen: (() => void) | undefined; + 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 opened = new Promise((resolve, reject) => { + 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).then(() => { + 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..62419af2 --- /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 play.svix.com URL with the token verbatim", () => { + expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://play.svix.com/in/Ab12Cd34Ef/"); + }); +}); + +describe("encodeStartFrame", () => { + test("matches the svix-cli handshake shape", () => { + expect(JSON.parse(encodeStartFrame("Ab12Cd34Ef"))).toEqual({ + type: "start", + version: 1, + data: { token: "Ab12Cd34Ef" }, + }); + }); +}); + +describe("decodeFrame", () => { + const eventFrame = JSON.stringify({ + type: "event", + version: 1, + data: { + id: "frame_1", + method: "POST", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body: Buffer.from('{"type":"user.created"}', "utf8").toString("base64"), + }, + }); + + test("decodes an event frame", () => { + const decoded = decodeFrame(eventFrame); + expect(decoded.type).toBe("event"); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.id).toBe("frame_1"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers["svix-id"]).toBe("msg_1"); + expect(decodeEventBody(decoded.event)).toBe('{"type":"user.created"}'); + }); + + test("round-trips: a decoded event re-encodes into a valid response frame", () => { + const decoded = decodeFrame(eventFrame); + if (decoded.type !== "event") throw new Error("unreachable"); + + const reply = encodeEventResponseFrame({ + id: decoded.event.id, + status: 200, + headers: { "content-type": "application/json" }, + bodyB64: Buffer.from("{}", "utf8").toString("base64"), + }); + + expect(JSON.parse(reply)).toEqual({ + type: "event", + version: 1, + data: { + id: "frame_1", + status: 200, + headers: { "content-type": "application/json" }, + body: "e30=", + }, + }); + }); + + test.each([ + { label: "invalid JSON", raw: "{nope" }, + { label: "non-object JSON", raw: '"hello"' }, + { label: "null", raw: "null" }, + { label: "unknown frame type", raw: '{"type":"server-error","version":1,"data":{}}' }, + { label: "event frame without data", raw: '{"type":"event","version":1}' }, + { label: "event frame without an id", raw: '{"type":"event","version":1,"data":{}}' }, + ])("returns unknown for $label", ({ raw }) => { + expect(decodeFrame(raw)).toEqual({ type: "unknown" }); + }); + + test("defaults method to POST and headers/body to empty", () => { + const decoded = decodeFrame('{"type":"event","version":1,"data":{"id":"frame_2"}}'); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers).toEqual({}); + expect(decoded.event.bodyB64).toBe(""); + expect(decodeEventBody(decoded.event)).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.ts new file mode 100644 index 00000000..be6b559b --- /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://play.svix.com/in/${token}/`; +} + +export function encodeStartFrame(token: string): string { + return JSON.stringify({ type: "start", version: 1, data: { token } }); +} + +export interface RelayEventFrame { + /** Relay-internal frame ID, echoed back in the response frame. */ + id: string; + method: string; + headers: Record; + /** Base64-encoded request body, exactly as received. */ + bodyB64: string; +} + +export type DecodedFrame = { type: "event"; event: RelayEventFrame } | { type: "unknown" }; + +export function decodeFrame(raw: string): DecodedFrame { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { type: "unknown" }; + } + if (parsed === null || typeof parsed !== "object") return { type: "unknown" }; + + const frame = parsed as { + type?: string; + data?: { id?: string; method?: string; headers?: Record; body?: string }; + }; + if (frame.type !== "event" || !frame.data || typeof frame.data.id !== "string") { + return { type: "unknown" }; + } + + return { + type: "event", + event: { + id: frame.data.id, + method: frame.data.method ?? "POST", + headers: frame.data.headers ?? {}, + bodyB64: frame.data.body ?? "", + }, + }; +} + +export function decodeEventBody(event: RelayEventFrame): string { + return Buffer.from(event.bodyB64, "base64").toString("utf8"); +} + +/** + * Frame a forward response back to the relay so Svix-side delivery telemetry + * stays honest (status, headers, and body of the local handler's response). + */ +export function encodeEventResponseFrame(reply: { + id: string; + status: number; + headers: Record; + bodyB64: string; +}): string { + return JSON.stringify({ + type: "event", + version: 1, + data: { id: reply.id, status: reply.status, headers: reply.headers, body: reply.bodyB64 }, + }); +} 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..4335e6c4 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -0,0 +1,174 @@ +import { test, expect, describe } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import type { ForwardOutcome } from "./forward.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; + +function outcome(overrides: Partial = {}): ForwardOutcome { + return { + status: 200, + headers: {}, + bodyText: "", + bodyB64: "", + latencyMs: 12, + failed: false, + ...overrides, + }; +} + +describe("buildReadyLine", () => { + test("matches the agent-mode ready contract", () => { + const line = buildReadyLine({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: ["user.created"], + forwardTo: "http://localhost:3000/api/webhooks", + }); + + expect(line).not.toContain("\n"); + const parsed = JSON.parse(line) as Record; + expect(parsed).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + endpoint_id: "ep_1", + events_filter: ["user.created"], + forward_to: "http://localhost:3000/api/webhooks", + }); + // signing_secret must be absent from the machine-readable line — it is + // pipeable/loggable and should never be emitted to stdout. + expect(parsed).not.toHaveProperty("signing_secret"); + }); + + test("includes forward_to as null when not forwarding", () => { + const parsed = JSON.parse( + buildReadyLine({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: null, + forwardTo: null, + }), + ) as Record; + + expect(parsed.forward_to).toBeNull(); + expect(parsed).not.toHaveProperty("signing_secret"); + }); +}); + +describe("buildEventLine", () => { + test("matches the agent-mode event contract", () => { + const line = buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + bodyB64: "e30=", + forwardStatus: 200, + latencyMs: 12, + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "event", + svix_id: "msg_1", + event_type: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body_b64: "e30=", + forward_status: 200, + latency_ms: 12, + }); + }); + + test("forward_status is null when not forwarding", () => { + const parsed = JSON.parse( + buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: {}, + bodyB64: "", + forwardStatus: null, + latencyMs: 0, + }), + ) as { forward_status: number | null }; + + expect(parsed.forward_status).toBeNull(); + }); +}); + +describe("human rendering", () => { + const captured = useCaptureLog(); + + test("ready banner shows the secret, relay URL, and endpoint", () => { + renderReadyBanner({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: null, + forwardTo: null, + }); + + expect(captured.err).toContain("whsec_abc"); + expect(captured.err).toContain("https://play.svix.com/in/Ab12Cd34Ef/"); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("NOT your Dashboard endpoint secret"); + expect(captured.err).toContain("not forwarding"); + expect(captured.out).toBe(""); + }); + + test("arrival and result lines follow the time --> / <-- format", () => { + renderArrival("user.created", "msg_1"); + renderForwardResult(outcome({ status: 200 }), "POST", "/api/webhooks"); + + const plain = 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("verification warning names the delivery", () => { + renderVerificationWarning("msg_1"); + + expect(captured.err).toContain("signature verification failed for msg_1"); + }); + + test.each([ + { + label: "401 → middleware hint", + forward: outcome({ status: 401 }), + expected: "createRouteMatcher(['/api/webhooks(.*)'])", + }, + { + label: "400 → raw-body hint", + forward: outcome({ status: 400 }), + expected: "RAW request body", + }, + { + label: "unreachable handler → dev-server hint", + forward: outcome({ status: 502, failed: true, bodyText: "connection refused" }), + expected: "Is your dev server running", + }, + ])("$label", ({ forward, expected }) => { + renderForwardDiagnostics(forward, "msg_1"); + + expect(captured.err).toContain(expected); + }); + + test("5xx diagnostics include the response body and the replay command", () => { + renderForwardDiagnostics(outcome({ status: 500, bodyText: "stack trace here" }), "msg_9"); + + expect(captured.err).toContain("stack trace here"); + expect(captured.err).toContain("clerk webhooks replay msg_9"); + }); + + test("2xx responses produce no diagnostics", () => { + renderForwardDiagnostics(outcome({ status: 204 }), "msg_1"); + + expect(captured.err).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/render.ts b/packages/cli-core/src/commands/webhooks/render.ts new file mode 100644 index 00000000..0e0d29ac --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.ts @@ -0,0 +1,160 @@ +/** + * 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; + // null in relay-only mode: no registered endpoint, no signing secret. + signingSecret: string | null; + endpointId: string | null; + eventsFilter: string[] | null; + forwardTo: string | null; +} + +/** NDJSON ready line (stdout in agent/--json mode). */ +export function buildReadyLine(info: ReadyInfo): string { + return JSON.stringify({ + type: "ready", + relay_url: info.relayUrl, + endpoint_id: info.endpointId, + events_filter: info.eventsFilter, + 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 { + const forwarding = info.forwardTo ?? dim("(not forwarding — printing events only)"); + const events = info.eventsFilter?.length ? info.eventsFilter.join(", ") : "all"; + + // relay-only: standalone Svix Play tunnel, no Clerk endpoint or secret. + if (info.endpointId === null) { + log.ui( + [ + "", + `${bold("Webhook relay ready")} ${dim("(relay-only — no Clerk endpoint registered)")}`, + ` Relay URL: ${info.relayUrl}`, + ` Forwarding to: ${forwarding}`, + ` Events: ${events}`, + ` Verification: ${dim("off (no signing secret in relay-only mode)")}`, + "", + ` ${dim("POST any JSON to the Relay URL to inject a delivery, or register that URL")}`, + ` ${dim("as an endpoint in your Svix app to receive real instance events.")}`, + ` ${dim("This URL is stable across restarts (pin it with --token) — register it once.")}`, + ` ${dim("Press Ctrl+C to stop.")}`, + "", + "", + ].join("\n"), + ); + return; + } + + log.ui( + [ + "", + `${bold("Webhook relay ready")}`, + ` Endpoint: ${cyan(info.endpointId)}`, + ` Relay URL: ${info.relayUrl}`, + ` Signing secret: ${info.signingSecret}`, + ` ${dim("(local relay endpoint secret, NOT your Dashboard endpoint secret)")}`, + ` Forwarding to: ${forwarding}`, + ` Events: ${events}`, + "", + ` ${dim("Press Ctrl+C to stop. The relay endpoint and secret persist across restarts.")}`, + "", + "", + ].join("\n"), + ); +} + +function timeOfDay(): string { + return new Date().toTimeString().slice(0, 8); +} + +export function renderArrival(eventType: string, svixId: string): void { + log.ui(`${dim(timeOfDay())} ${cyan("-->")} ${eventType} ${dim(svixId)}\n`); +} + +export function renderForwardResult(outcome: ForwardOutcome, method: string, path: string): void { + const color = outcome.status >= 500 ? red : outcome.status >= 400 ? yellow : green; + log.ui( + `${dim(timeOfDay())} ${color(`<-- ${outcome.status}`)} ${method} ${path} ${dim(`${outcome.latencyMs}ms`)}\n`, + ); +} + +export function renderVerificationWarning(svixId: string): void { + log.ui( + yellow( + ` ! signature verification failed for ${svixId} — the relay secret does not match this delivery. Forwarding anyway; pass --skip-verify to silence.\n`, + ), + ); +} + +const BODY_PREVIEW_LIMIT = 500; + +export function renderForwardDiagnostics(outcome: ForwardOutcome, svixId: string): void { + if (outcome.failed) { + log.ui( + yellow(` ! could not reach the local handler: ${outcome.bodyText}\n`) + + dim(" Is your dev server running on the --forward-to URL?\n"), + ); + return; + } + + if (outcome.status === 401) { + log.ui( + yellow(" ! 401 from your handler — middleware is likely protecting the webhook route.\n") + + dim( + " In clerkMiddleware(), allow it with createRouteMatcher(['/api/webhooks(.*)']) as a public route.\n", + ), + ); + return; + } + + if (outcome.status === 400) { + log.ui( + yellow(" ! 400 from your handler — usually a signature check on a parsed body.\n") + + dim( + " Pass the RAW request body to verifyWebhook(); read it before any JSON body parsing.\n", + ), + ); + return; + } + + if (outcome.status >= 500) { + const preview = + outcome.bodyText.length > BODY_PREVIEW_LIMIT + ? `${outcome.bodyText.slice(0, BODY_PREVIEW_LIMIT)}...` + : outcome.bodyText; + log.ui( + yellow(` ! ${outcome.status} from your handler. Response body:\n`) + + (preview ? ` ${preview}\n` : dim(" (empty)\n")) + + dim(` Fix the handler, then resend this delivery: clerk webhooks replay ${svixId}\n`), + ); + } +} diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts new file mode 100644 index 00000000..425efa4f --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -0,0 +1,188 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockResendWebhookMessage = mock(); +const mockRecoverWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + resendWebhookMessage: (...args: unknown[]) => mockResendWebhookMessage(...args), + recoverWebhookMessages: (...args: unknown[]) => mockRecoverWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksReplay } = await import("./replay.ts"); + +describe("webhooks replay", () => { + useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockResendWebhookMessage.mockResolvedValue(undefined); + mockRecoverWebhookMessages.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockResendWebhookMessage.mockReset(); + mockRecoverWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test.each([ + { + label: "both and --since", + options: { msgId: "msg_1", since: "2026-05-01T00:00:00Z" }, + }, + { label: "neither nor --since", options: {} }, + { + label: "--until without --since", + options: { msgId: "msg_1", until: "2026-05-01T00:00:00Z" }, + }, + { + label: "--until alone (no , no --since)", + options: { until: "2026-05-01T00:00:00Z" }, + }, + { + label: "--since without --endpoint", + options: { since: "2026-05-01T00:00:00Z" }, + }, + { + label: "invalid --since timestamp", + options: { since: "not-a-date", endpoint: "ep_1" }, + }, + { + label: "invalid --until timestamp", + options: { since: "2026-05-01T00:00:00Z", until: "nope", endpoint: "ep_1" }, + }, + { + label: "bare-date --since timestamp (missing T and timezone)", + options: { since: "2024-01-01", endpoint: "ep_1" }, + }, + { + label: "missing-timezone --since timestamp", + options: { since: "2024-01-01T10:00:00", endpoint: "ep_1" }, + }, + ])("$label is a usage error", async ({ options }) => { + await expect(webhooksReplay(options)).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResendWebhookMessage).not.toHaveBeenCalled(); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--until alone points at the missing --since instead of a vaguer hint", async () => { + await expect(webhooksReplay({ until: "2026-05-01T00:00:00Z" })).rejects.toThrow( + "--until requires --since.", + ); + }); + + test("resends one message to an explicit --endpoint without prompting", async () => { + await webhooksReplay({ msgId: "msg_1", endpoint: "ep_1" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "msg_1"); + }); + + test("resend defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksReplay({ msgId: "msg_1" }); + + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", "msg_1"); + }); + + test("resend without --endpoint or a relay endpoint is a usage error", async () => { + await expect(webhooksReplay({ msgId: "msg_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("resend maps a PLAPI 404 to webhook_message_not_found", async () => { + mockResendWebhookMessage.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksReplay({ msgId: "msg_gone", endpoint: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, + message: "No webhook message with ID msg_gone was found.", + }); + }); + + test("--since prompts, then recovers the window", async () => { + await webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: undefined, + }); + }); + + test("--since --until bounds the recovery window", async () => { + await webhooksReplay({ + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + endpoint: "ep_1", + yes: true, + }); + + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + }); + + test("--since aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toBeInstanceOf(UserAbortError); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--since in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("--since maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockRecoverWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_missing", yes: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts new file mode 100644 index 00000000..2bf1748b --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -0,0 +1,94 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { recoverWebhookMessages, resendWebhookMessage } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + rejectMessageNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksReplayOptions extends WebhooksGlobalOptions { + msgId?: string; + endpoint?: string; + since?: string; + until?: string; + yes?: boolean; +} + +function assertRfc3339(value: string, flag: string): void { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(value)) { + throwUsageError( + `Invalid ${flag} value "${value}". Must be an RFC 3339 timestamp (e.g. 2024-01-01T00:00:00Z).`, + ); + } +} + +function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover" { + if (options.msgId && options.since) { + throwUsageError("Pass either a or --since, not both."); + } + // Check the orphaned-`--until` case before the generic "neither" message, so + // `replay --until ` points at the real problem instead of a vaguer hint. + if (options.until && !options.since) { + throwUsageError("--until requires --since."); + } + if (!options.msgId && !options.since) { + throwUsageError("Pass a to resend one delivery, or --since to bulk-recover."); + } + if (options.since) { + assertRfc3339(options.since, "--since"); + if (options.until) assertRfc3339(options.until, "--until"); + if (!options.endpoint) { + throwUsageError("--endpoint is required with --since. Bulk recovery never guesses a target."); + } + return "recover"; + } + return "resend"; +} + +export async function webhooksReplay(options: WebhooksReplayOptions): Promise { + const mode = validateReplayMode(options); + + const windowLabel = options.until + ? `between ${options.since} and ${options.until}` + : `since ${options.since}`; + + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. + if (mode === "recover") { + await confirmDestructive( + `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, + options, + ); + } + + const ctx = await resolveAppContext(options); + + if (mode === "resend") { + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + await rejectMessageNotFound( + withApiContext( + resendWebhookMessage(ctx.appId, ctx.instanceId, endpointId, options.msgId!), + "Failed to resend webhook message", + ), + options.msgId!, + ); + log.success(`Queued replay of \`${options.msgId}\` to \`${endpointId}\``); + return; + } + + await rejectEndpointNotFound( + withApiContext( + recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { + since: options.since!, + until: options.until, + }), + "Failed to recover webhook messages", + ), + options.endpoint!, + ); + log.success(`Queued recovery of deliveries to \`${options.endpoint}\` ${windowLabel}`); +} diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts new file mode 100644 index 00000000..e7c2473e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -0,0 +1,134 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpointSecret = mock(); +const mockRotateWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), + rotateWebhookEndpointSecret: (...args: unknown[]) => mockRotateWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksSecret } = await import("./secret.ts"); + +describe("webhooks secret", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_abc123" }); + mockRotateWebhookEndpointSecret.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockGetWebhookEndpointSecret.mockReset(); + mockRotateWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prints the bare secret on stdout in human mode", async () => { + await webhooksSecret({ endpointId: "ep_1" }); + + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("Signing secret for"); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("outputs { secret } as JSON with --json", async () => { + await webhooksSecret({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + expect(captured.err).toBe(""); + }); + + test("outputs { secret } in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + }); + + test("--rotate prompts, rotates, then fetches the new secret", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("dual-signs"); + }); + + test("--rotate --yes skips the prompt", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + + test("--rotate aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toBeInstanceOf( + UserAbortError, + ); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + }); + + test("--rotate --yes in agent mode skips the prompt and rotates", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + + test("--rotate in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toBeInstanceOf(CliError); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/secret.ts b/packages/cli-core/src/commands/webhooks/secret.ts new file mode 100644 index 00000000..9dc349ab --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -0,0 +1,62 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { getWebhookEndpointSecret, rotateWebhookEndpointSecret } from "../../lib/plapi.ts"; +import { + confirmDestructive, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksSecretOptions extends WebhooksGlobalOptions { + endpointId: string; + rotate?: boolean; + yes?: boolean; +} + +export async function webhooksSecret(options: WebhooksSecretOptions): Promise { + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. + if (options.rotate) { + await confirmDestructive( + `Rotate the signing secret for ${options.endpointId}? The old key keeps verifying for 24h (dual-signing grace).`, + options, + ); + } + + const ctx = await resolveAppContext(options); + + if (options.rotate) { + await rejectEndpointNotFound( + withApiContext( + rotateWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + "Failed to rotate webhook signing secret", + ), + options.endpointId, + ); + } + + const { secret } = await rejectEndpointNotFound( + withApiContext( + getWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + "Failed to fetch webhook signing secret", + ), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson({ secret }); + return; + } + + if (options.rotate) { + log.success( + `Signing secret rotated. The previous key remains valid for 24 hours while Svix dual-signs.`, + ); + } + log.info(`Signing secret for \`${options.endpointId}\`:`); + // Bare secret on stdout so $(clerk webhooks secret ep_...) is eval-friendly. + log.data(secret); +} 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..f1452feb --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -0,0 +1,133 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { getRelayEntry } from "../../lib/config.ts"; +import { + CliError, + ERROR_CODE, + type ErrorCode, + PlapiError, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import type { WebhookCursor, WebhookEndpoint } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; + +export interface WebhooksGlobalOptions { + app?: string; + instance?: string; + json?: boolean; +} + +export const DEFAULT_PAGE_LIMIT = 100; + +export function shouldOutputJson(options: { json?: boolean }): boolean { + return Boolean(options.json) || isAgent(); +} + +/** Bare domain JSON on stdout — the only stdout writer for webhook commands. */ +export function printJson(data: unknown): void { + log.data(JSON.stringify(data, null, 2)); +} + +/** Stderr hint with the next `--iterator` value. The CLI never auto-paginates. */ +export function printIteratorHint(cursor: WebhookCursor): void { + if (cursor.has_next_page && cursor.starting_after) { + log.info(`More available — re-run with \`--iterator ${cursor.starting_after}\``); + } +} + +/** Map a PLAPI 404 on a resource-addressed route to a typed CLI error. */ +async function rejectNotFound( + promise: Promise, + id: string, + resourceLabel: string, + code: ErrorCode, +): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + throw new CliError(`No ${resourceLabel} with ID ${id} was found.`, { code }); + } + if (error instanceof PlapiError && error.status === 400 && error.code === "svix_app_missing") { + throw new CliError( + "No webhooks have been configured for this instance yet. Run `clerk webhooks create` to set up your first endpoint.", + ); + } + throw error; + } +} + +/** Map a PLAPI 404 on an endpoint-addressed route to a typed CLI error. */ +export const rejectEndpointNotFound = (promise: Promise, endpointId: string): Promise => + rejectNotFound(promise, endpointId, "webhook endpoint", ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND); + +/** Map a PLAPI 404 on a message-addressed route to a typed CLI error. */ +export const rejectMessageNotFound = (promise: Promise, messageId: string): Promise => + rejectNotFound(promise, messageId, "webhook message", ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND); + +/** + * Destructive-command gate: prompt in human mode, require `--yes` in agent + * mode. Declining the prompt aborts cleanly via UserAbortError. + */ +export async function confirmDestructive( + message: string, + options: { yes?: boolean }, +): Promise { + if (options.yes) return; + if (isAgent()) { + throwUsageError("This action requires confirmation. Pass --yes to proceed in agent mode."); + } + const { confirm } = await import("../../lib/prompts.ts"); + const proceed = await confirm({ message, default: false }); + if (!proceed) throwUserAbort(); +} + +/** + * Resolve `--endpoint`, falling back to the instance's persisted relay + * endpoint (`trigger`, `messages`, and `replay ` convenience rule). + */ +export async function resolveEndpointOrRelay( + endpointFlag: string | undefined, + instanceId: string, +): Promise { + if (endpointFlag) return endpointFlag; + const entry = await getRelayEntry(instanceId); + if (entry?.endpoint_id) return entry.endpoint_id; + return throwUsageError( + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + ); +} + +/** Labeled key/value detail rows for one endpoint, on stderr. */ +export function formatEndpointDetails(endpoint: WebhookEndpoint): void { + const rows: Array<[string, string]> = [ + ["ID", cyan(endpoint.id)], + ["URL", endpoint.url], + ["Status", endpoint.disabled ? "disabled" : "enabled"], + ["Description", endpoint.description || dim("(none)")], + ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], + ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], + ["Created", endpoint.created_at], + ["Updated", endpoint.updated_at], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; + for (const [label, value] of rows) { + log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); + } +} + +/** + * Split a comma-separated flag value into trimmed, non-empty entries. Returns + * undefined when the flag was absent OR carried no real values (`""`, `","`, + * whitespace) — so callers can treat an empty value as "not provided" instead + * of sending an empty array. + */ +export function splitCommaList(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return parts.length > 0 ? parts : undefined; +} diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts new file mode 100644 index 00000000..c0c30b22 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -0,0 +1,145 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +const mockSendWebhookExample = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), + sendWebhookExample: (...args: unknown[]) => mockSendWebhookExample(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksTrigger } = await import("./trigger.ts"); + +function catalogPage(names: string[], hasNextPage = false, startingAfter: string | null = null) { + return { + data: names.map((name) => ({ + name, + description: "", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + })), + cursor: { starting_after: startingAfter, ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks trigger", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(catalogPage(["user.created", "user.deleted"])); + mockSendWebhookExample.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockSendWebhookExample.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("validates the event type, then sends the example", async () => { + await webhooksTrigger({ eventType: "user.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 250, + iterator: undefined, + }); + expect(mockSendWebhookExample).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + "user.created", + ); + expect(captured.err).toContain("delivery is async"); + }); + + test("uses an explicit --endpoint over the relay default", async () => { + await webhooksTrigger({ eventType: "user.created", endpoint: "ep_1" }); + + expect(mockSendWebhookExample).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "user.created"); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.created" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("unknown event type fails fast with unknown_event_type", async () => { + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("unknown event type wins over a missing relay endpoint (fail fast)", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("pages through the catalog before declaring a type unknown", async () => { + mockListWebhookEventTypes + .mockResolvedValueOnce(catalogPage(["user.created"], true, "iter_2")) + .mockResolvedValueOnce(catalogPage(["organization.created"])); + + await webhooksTrigger({ eventType: "organization.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledTimes(2); + expect(mockListWebhookEventTypes).toHaveBeenLastCalledWith("app_1", "ins_1", { + limit: 250, + iterator: "iter_2", + }); + expect(mockSendWebhookExample).toHaveBeenCalled(); + }); + + test("has_next_page=true with null cursor throws a CliError", async () => { + // Server returns has_next_page but no starting_after — defensive cursor guard. + mockListWebhookEventTypes.mockResolvedValue(catalogPage(["other.event"], true, null)); + + await expect(webhooksTrigger({ eventType: "user.created" })).rejects.toThrow( + "Server returned has_next_page=true with no pagination cursor", + ); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 on send to webhook_endpoint_not_found", async () => { + mockSendWebhookExample.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksTrigger({ eventType: "user.created", endpoint: "ep_missing" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts new file mode 100644 index 00000000..cb2a8154 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -0,0 +1,69 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, sendWebhookExample } from "../../lib/plapi.ts"; +import { + rejectEndpointNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksTriggerOptions extends WebhooksGlobalOptions { + eventType: string; + endpoint?: string; +} + +const CATALOG_PAGE_LIMIT = 250; + +async function assertKnownEventType( + appId: string, + instanceId: string, + eventType: string, +): Promise { + let iterator: string | undefined; + do { + const page = await withApiContext( + listWebhookEventTypes(appId, instanceId, { + limit: CATALOG_PAGE_LIMIT, + iterator, + }), + "Failed to list webhook event types", + ); + if (page.data.some((entry) => entry.name === eventType)) return; + if (page.cursor.has_next_page && !page.cursor.starting_after) { + throw new CliError( + "Server returned has_next_page=true with no pagination cursor; cannot verify event type.", + ); + } + iterator = page.cursor.has_next_page ? (page.cursor.starting_after ?? undefined) : undefined; + } while (iterator); + + throw new CliError( + `Unknown event type "${eventType}". Run \`clerk webhooks event-types\` to list available types.`, + { code: ERROR_CODE.UNKNOWN_EVENT_TYPE }, + ); +} + +export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise { + const ctx = await resolveAppContext(options); + + // send_example returns 200 {} asynchronously — an invalid event type would + // otherwise exit 0 and deliver nothing, the silent failure trigger exists to + // kill. Validated first so agents get unknown_event_type even when no relay + // endpoint is configured. + await assertKnownEventType(ctx.appId, ctx.instanceId, options.eventType); + + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + await rejectEndpointNotFound( + withApiContext( + sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), + "Failed to send webhook example", + ), + endpointId, + ); + + log.success( + `Sent example \`${options.eventType}\` event to \`${endpointId}\` (delivery is async)`, + ); +} diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts new file mode 100644 index 00000000..404f4501 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -0,0 +1,154 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockUpdateWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksUpdate } = await import("./update.ts"); + +const updatedEndpoint = { + id: "ep_1", + url: "https://example.com/new", + version: 1, + description: "Updated", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks update", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockUpdateWebhookEndpoint.mockResolvedValue(updatedEndpoint); + }); + + afterEach(() => { + mockUpdateWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test.each([ + { + label: "--url", + options: { url: "https://example.com/new" }, + expected: { url: "https://example.com/new" }, + }, + { + label: "--description", + options: { description: "Updated" }, + expected: { description: "Updated" }, + }, + { + label: "--events (comma-separated)", + options: { events: "user.created, user.deleted" }, + expected: { filter_types: ["user.created", "user.deleted"] }, + }, + { + label: "--channels (comma-separated)", + options: { channels: "a,b" }, + expected: { channels: ["a", "b"] }, + }, + { label: "--enable", options: { enable: true }, expected: { disabled: false } }, + { label: "--disable", options: { disable: true }, expected: { disabled: true } }, + ])("$label maps to the PATCH body", async ({ options, expected }) => { + await webhooksUpdate({ endpointId: "ep_1", ...options }); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", expected); + }); + + test("omits disabled from the PATCH body when neither --enable nor --disable is set", async () => { + await webhooksUpdate({ endpointId: "ep_1", url: "https://example.com/new" }); + + const params = mockUpdateWebhookEndpoint.mock.calls[0]?.[3] as Record; + expect("disabled" in params).toBe(false); + }); + + test("--enable with --disable is a usage error", async () => { + await expect( + webhooksUpdate({ endpointId: "ep_1", enable: true, disable: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("no update flags at all is a usage error", async () => { + await expect(webhooksUpdate({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test('--events "" clears filter_types (sends filter_types: [])', async () => { + await webhooksUpdate({ endpointId: "ep_1", events: "" }); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + filter_types: [], + }); + }); + + test('--channels "" clears channels (sends channels: [])', async () => { + await webhooksUpdate({ endpointId: "ep_1", channels: "" }); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + channels: [], + }); + }); + + test("prints the updated endpoint in human mode", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Updated webhook endpoint"); + expect(captured.err).toContain("https://example.com/new"); + }); + + test("outputs the updated endpoint resource as JSON with --json", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated", json: true }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + expect(captured.err).toBe(""); + }); + + test("outputs the updated endpoint resource in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockUpdateWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksUpdate({ endpointId: "ep_missing", description: "x" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/update.ts b/packages/cli-core/src/commands/webhooks/update.ts new file mode 100644 index 00000000..72ff953e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.ts @@ -0,0 +1,64 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { updateWebhookEndpoint, type UpdateWebhookEndpointParams } from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksUpdateOptions extends WebhooksGlobalOptions { + endpointId: string; + url?: string; + events?: string; + description?: string; + channels?: string; + enable?: boolean; + disable?: boolean; +} + +export function buildUpdateParams(options: WebhooksUpdateOptions): UpdateWebhookEndpointParams { + if (options.enable && options.disable) { + throwUsageError("--enable and --disable are mutually exclusive."); + } + + const params: UpdateWebhookEndpointParams = {}; + if (options.url !== undefined) params.url = options.url; + if (options.description !== undefined) params.description = options.description; + if (options.events !== undefined) params.filter_types = splitCommaList(options.events) ?? []; + if (options.channels !== undefined) params.channels = splitCommaList(options.channels) ?? []; + if (options.enable) params.disabled = false; + if (options.disable) params.disabled = true; + + if (Object.keys(params).length === 0) { + throwUsageError( + "Nothing to update. Pass at least one of --url, --events, --description, --channels, --enable, or --disable.", + ); + } + return params; +} + +export async function webhooksUpdate(options: WebhooksUpdateOptions): Promise { + const params = buildUpdateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await rejectEndpointNotFound( + withApiContext( + updateWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId, params), + "Failed to update webhook endpoint", + ), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + log.success(`Updated webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); +} 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..1e760096 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -0,0 +1,195 @@ +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; + return key; +} + +/** + * Verify a Svix signature: HMAC-SHA256 over `{id}.{timestamp}.{payload}` with + * the decoded secret, compared constant-time against every space-separated + * `v1,` entry in the header (any match wins). During the 24h rotation + * grace window the header carries multiple entries — that's why any-match matters. + */ +export function verifyWebhookSignature(input: { + secret: string; + id: string; + timestamp: string; + payload: string; + signature: string; +}): boolean { + const key = decodeWebhookSecret(input.secret); + if (!key) return false; + + const expected = createHmac("sha256", key) + .update(`${input.id}.${input.timestamp}.${input.payload}`, "utf8") + .digest(); + + return input.signature + .split(/\s+/) + .filter(Boolean) + .some((entry) => { + const commaIndex = entry.indexOf(","); + if (commaIndex === -1) return false; + const version = entry.slice(0, commaIndex); + if (version !== "v1") return false; + const candidate = Buffer.from(entry.slice(commaIndex + 1), "base64"); + return candidate.length === expected.length && timingSafeEqual(candidate, expected); + }); +} + +export interface DeliveryFields { + id?: string; + timestamp?: string; + signature?: string; + payload?: string; +} + +/** + * Parse one `listen` event NDJSON line (`headers` + `body_b64`) into the four + * verification inputs. Explicit flags override these at the call site. + */ +export function parseDeliveryLine(raw: string): DeliveryFields { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throwUsageError("--delivery is not valid JSON. Expected one `listen` event NDJSON line."); + } + if (parsed === null || typeof parsed !== "object") { + throwUsageError("--delivery must be a JSON object (one `listen` event NDJSON line)."); + } + + const record = parsed as { headers?: Record; body_b64?: string }; + const headers = record.headers ?? {}; + const fields: DeliveryFields = { + id: headers["svix-id"], + timestamp: headers["svix-timestamp"], + signature: headers["svix-signature"], + }; + if (typeof record.body_b64 === "string") { + fields.payload = Buffer.from(record.body_b64, "base64").toString("utf8"); + } + return fields; +} + +async function readFileOrStdin(value: string, flag: string): Promise { + if (value === "-") { + return await Bun.stdin.text(); + } + if (value.startsWith("@")) { + const path = value.slice(1); + const file = Bun.file(path); + if (!(await file.exists())) { + throw new CliError(`File not found: ${path}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } + return await file.text(); + } + return throwUsageError( + `${flag} takes @file or - for stdin (inline values get mangled by shells).`, + ); +} + +function humanizeSkew(deltaSeconds: number): string { + const minutes = Math.round(Math.abs(deltaSeconds) / 60); + const span = minutes >= 1 ? `${minutes} minute${minutes === 1 ? "" : "s"}` : "less than a minute"; + return deltaSeconds > 0 ? `${span} in the past` : `${span} in the future`; +} + +export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promise { + if (!options.secret) { + throwUsageError("Missing required --secret whsec_..."); + } + if (!decodeWebhookSecret(options.secret)) { + throwUsageError("Invalid --secret. Expected a whsec_-prefixed base64 signing secret."); + } + + let fields: DeliveryFields = {}; + if (options.delivery) { + const raw = await readFileOrStdin(options.delivery, "--delivery"); + const firstLine = raw.split("\n").find((line) => line.trim()); + if (!firstLine) { + throwUsageError("--delivery input is empty. Expected one `listen` event NDJSON line."); + } + fields = parseDeliveryLine(firstLine); + } + + // Explicit flags override --delivery fields. + const id = options.id ?? fields.id; + const timestamp = options.timestamp ?? fields.timestamp; + const signature = options.signature ?? fields.signature; + const hasPayload = options.payload !== undefined || fields.payload !== undefined; + + const missing = [ + !id && "--id", + !timestamp && "--timestamp", + !signature && "--signature", + !hasPayload && "--payload", + ].filter(Boolean); + if (missing.length > 0) { + throwUsageError( + `Missing ${missing.join(", ")}. Pass --delivery @event.json or all four explicit flags.`, + ); + } + + if (!/^\d+$/.test(timestamp!)) { + throwUsageError( + `Invalid --timestamp "${timestamp}". Expected Unix epoch seconds (the raw svix-timestamp header value).`, + ); + } + + // 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."; + const deltaSeconds = Math.floor(Date.now() / 1000) - Number(timestamp); + if (Math.abs(deltaSeconds) > SKEW_HINT_THRESHOLD_SECONDS) { + message += ` Note: the timestamp is ${humanizeSkew(deltaSeconds)} — make sure it is the raw svix-timestamp header from the same delivery as the signature.`; + } + throw new CliError(message, { 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.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..2c997eaa 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { join } from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; const { @@ -16,6 +16,8 @@ const { resolveInstanceId, resolveAppContext, resolveFetchedApplicationInstance, + getRelayEntry, + setRelayEntry, _setConfigDir, } = await import("./config.ts"); type Profile = @@ -60,6 +62,15 @@ describe("config", () => { expect(result.profiles).toEqual(config.profiles); }); + test("writeConfig creates config file with mode 0600 and directory with mode 0700", async () => { + await writeConfig({ profiles: {} }); + const configPath = join(tempDir, "config.json"); + const fileStats = await stat(configPath); + const dirStats = await stat(tempDir); + expect(fileStats.mode & 0o777).toBe(0o600); + expect(dirStats.mode & 0o777).toBe(0o700); + }); + test("readConfig migrates legacy auth format", async () => { // Write old-format config directly const legacyConfig = { @@ -71,6 +82,37 @@ describe("config", () => { expect(result.auth).toEqual({ production: { userId: "user_legacy" } }); }); + test("getRelayEntry returns undefined when nothing is persisted", async () => { + expect(await getRelayEntry("ins_123")).toBeUndefined(); + }); + + test("setRelayEntry and getRelayEntry roundtrip per instance", async () => { + await setRelayEntry("ins_a", { token: "Ab12Cd34Ef" }); + await setRelayEntry("ins_b", { token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "Ab12Cd34Ef" }); + expect(await getRelayEntry("ins_b")).toEqual({ token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + }); + + test("setRelayEntry overwrites only the targeted instance", async () => { + await setRelayEntry("ins_a", { token: "tokenAAAAA" }); + await setRelayEntry("ins_a", { token: "tokenAAAAA", endpoint_id: "ep_2" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "tokenAAAAA", endpoint_id: "ep_2" }); + }); + + test("readConfig preserves relay through the legacy-auth migration", async () => { + const legacyConfig = { + auth: { userId: "user_legacy" }, + profiles: {}, + relay: { ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }, + }; + await Bun.write(`${tempDir}/config.json`, JSON.stringify(legacyConfig)); + + const result = await readConfig(); + expect(result.relay).toEqual({ ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }); + }); + test("setAuth and getAuth", async () => { expect(await getAuth()).toBeUndefined(); await setAuth({ userId: "user_456" }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..37ac3029 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -4,7 +4,7 @@ */ import { dirname, join } from "node:path"; -import { mkdir } from "node:fs/promises"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import { CONFIG_FILE } from "./constants.ts"; import { getCurrentEnvName } from "./environment.ts"; import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; @@ -45,10 +45,17 @@ export function profileLabel(profile: Profile): string { return profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId; } +/** Persisted Svix relay state for `clerk webhooks listen`, keyed by instance ID. */ +interface RelayEntry { + token: string; + endpoint_id?: string; +} + interface ClerkConfig { environment?: string; auth?: Record; profiles: Record; + relay?: Record; } function defaultConfig(): ClerkConfig { @@ -65,6 +72,10 @@ function migrateRawConfig(raw: Record): ClerkConfig { profiles: (raw.profiles as Record) ?? {}, }; + if (raw.relay && typeof raw.relay === "object") { + config.relay = raw.relay as Record; + } + if (raw.auth && typeof raw.auth === "object") { const auth = raw.auth as Record; if (typeof auth.userId === "string") { @@ -102,8 +113,9 @@ export async function writeConfig(config: ClerkConfig): Promise { await withHomeFsAccess( { operation: "write", target: path, label: "CLI config directory" }, async () => { - await mkdir(dirname(path), { recursive: true }); - await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); + await chmod(path, 0o600); // reset perms in case the file pre-existed with looser mode }, ); } @@ -169,6 +181,18 @@ export async function moveProfile(oldKey: string, newKey: string): Promise await writeConfig(config); } +export async function getRelayEntry(instanceId: string): Promise { + const config = await readConfig(); + return config.relay?.[instanceId]; +} + +export async function setRelayEntry(instanceId: string, entry: RelayEntry): Promise { + const config = await readConfig(); + if (!config.relay) config.relay = {}; + config.relay[instanceId] = entry; + await writeConfig(config); +} + export async function listProfiles(): Promise> { const config = await readConfig(); return config.profiles; @@ -362,4 +386,4 @@ export async function resolveAppContext( }; } -export type { Auth, Profile, ClerkConfig, AppContextOptions }; +export type { Auth, Profile, ClerkConfig, AppContextOptions, RelayEntry }; diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..73c2ef75 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,16 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** Referenced webhook endpoint not found. */ + WEBHOOK_ENDPOINT_NOT_FOUND: "webhook_endpoint_not_found", + /** Referenced webhook message (delivery) not found. */ + WEBHOOK_MESSAGE_NOT_FOUND: "webhook_message_not_found", + /** Event type is not in the instance's event-type catalog. */ + UNKNOWN_EVENT_TYPE: "unknown_event_type", + /** Offline webhook signature verification found no matching entry. */ + INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature", + /** Endpoint was created but its signing secret could not be fetched. */ + WEBHOOK_SECRET_FETCH_FAILED: "webhook_secret_fetch_failed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/fetch.test.ts b/packages/cli-core/src/lib/fetch.test.ts index 39f03188..675cdb7e 100644 --- a/packages/cli-core/src/lib/fetch.test.ts +++ b/packages/cli-core/src/lib/fetch.test.ts @@ -41,4 +41,46 @@ describe("loggedFetch", () => { expect(init.headers.get("Authorization")).toBe("Bearer abc"); expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//); }); + + // A server that accepts the connection but never responds. The mock rejects + // only when the request's AbortSignal fires, so it exercises the real timeout + // path: without a default timeout this hangs until bun's test timeout. + const hangingFetch = () => + ((_url: unknown, init: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal!.reason)); + })) as unknown as typeof fetch; + + test("aborts with a clear, tagged error after the default timeout when the server never responds", async () => { + globalThis.fetch = hangingFetch(); + await expect( + loggedFetch("https://example.test/hang", { tag: "plapi", timeoutMs: 30 }), + ).rejects.toThrow(/plapi: request timed out after 30ms/); + }, 2000); + + test("a shorter caller signal wins over the default timeout and is not masked by the timeout message", async () => { + globalThis.fetch = hangingFetch(); + const caller = AbortSignal.timeout(20); + // Must reject (the onFulfilled branch throws if it unexpectedly resolves)... + const err = await loggedFetch("https://example.test/hang", { + tag: "plapi", + timeoutMs: 10_000, + signal: caller, + }).then( + () => { + throw new Error("expected loggedFetch to reject, but it resolved"); + }, + (e: unknown) => e, + ); + // ...with the caller's 20ms abort, not relabeled as our 10s default timeout. + expect(String(err)).not.toMatch(/timed out after 10000ms/); + }, 2000); + + test("returns the response for a fast request without aborting", async () => { + globalThis.fetch = mock( + async () => new Response("ok", { status: 200 }), + ) as unknown as typeof fetch; + const res = await loggedFetch("https://example.test/ok", { tag: "plapi", timeoutMs: 5_000 }); + expect(res.status).toBe(200); + }); }); diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts index ffebe505..62d80585 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -14,7 +14,10 @@ import { buildUserAgent } from "./user-agent.ts"; const USER_AGENT = buildUserAgent(); -export type LoggedFetchInit = RequestInit & { tag: string }; +/** Native `fetch()` has no timeout, so a stalled connection would hang forever. */ +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; + +export type LoggedFetchInit = RequestInit & { tag: string; timeoutMs?: number }; /** * Normalized response shape returned by the higher-level API request wrappers @@ -29,16 +32,30 @@ export interface ApiResponse { } export async function loggedFetch(url: URL | string, options: LoggedFetchInit): Promise { - const { tag, ...init } = options; + const { tag, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, signal: callerSignal, ...init } = options; const method = init.method ?? "GET"; const urlStr = url.toString(); const headers = new Headers(init.headers); if (!headers.has("user-agent")) headers.set("User-Agent", USER_AGENT); log.debug(`${tag}: ${method} ${urlStr}`); - const response = await withNetworkAccess( - { operation: "connect", target: urlStr, label: tag }, - async () => fetch(url, { ...init, headers }), - ); + + // A caller signal (e.g. keyless.ts's tighter 15s) composes with our default. + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal; + + let response: Response; + try { + response = await withNetworkAccess( + { operation: "connect", target: urlStr, label: tag }, + async () => fetch(url, { ...init, headers, signal }), + ); + } catch (err) { + // Only relabel when our timeout fired, not a caller abort or network error. + if (timeoutSignal.aborted && !callerSignal?.aborted) { + throw new Error(`${tag}: request timed out after ${timeoutMs}ms — ${method} ${urlStr}`); + } + throw err; + } if (!response.ok) { // Clone so the caller can still consume the body for error construction. const body = await response.clone().text(); diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 954ac2ee..503ffb41 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -370,5 +370,14 @@ describe("expandInputJson", () => { const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); }); + + test("auto-stdin stands down when a flag reads stdin itself (literal -)", async () => { + const argv = ["clerk", "webhooks", "verify", "--secret", "whsec_x", "--delivery", "-"]; + const result = await expandViaStdin( + argv, + '{"headers":{"svix-id":"msg_1"},"body_b64":"e30="}', + ); + expect(result.result).toEqual(argv); + }); }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index bcc37281..0938e750 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -151,8 +151,10 @@ export async function expandInputJson(argv: string[]): Promise { return argv; } - // No explicit --input-json flag — check for piped stdin - if (hasStdinPipe()) { + // No explicit --input-json flag — check for piped stdin. A literal `-` in + // argv means some flag reads stdin itself (e.g. `verify --delivery -`); the + // implicit JSON slurp must not consume the stream first. + if (hasStdinPipe() && !argv.includes(STDIN_MARKER)) { const jsonStr = await readOptionalStdin(); if (jsonStr === undefined) return argv; const parsed = parseJsonString(jsonStr); diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8..6afec2be 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,6 +19,19 @@ const { getApplicationDomainStatus, triggerApplicationDomainDNSCheck, listApplicationDomains, + listWebhookEndpoints, + getWebhookEndpoint, + createWebhookEndpoint, + updateWebhookEndpoint, + deleteWebhookEndpoint, + getWebhookEndpointSecret, + rotateWebhookEndpointSecret, + listWebhookEventTypes, + listWebhookMessages, + resendWebhookMessage, + recoverWebhookMessages, + sendWebhookExample, + getWebhookPortalUrl, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -563,3 +576,227 @@ describe("plapi", () => { }); }); }); + +describe("plapi webhooks", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + type CapturedRequest = { + url: URL; + method: string; + body: string | undefined; + contentType: string | null; + }; + + let captured: CapturedRequest[]; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "test_key_123"; + captured = []; + stubFetch(async (input, init) => { + captured.push({ + url: new URL(input.toString()), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : undefined, + contentType: new Headers(init?.headers).get("Content-Type"), + }); + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + const PREFIX = "/v1/platform/applications/app_1/instances/ins_1/webhooks"; + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1"), + method: "GET", + path: PREFIX, + body: undefined as string | undefined, + }, + { + name: "getWebhookEndpoint", + call: () => getWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "createWebhookEndpoint", + call: () => + createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }), + method: "POST", + path: PREFIX, + body: '{"url":"https://example.com/webhooks","version":1}', + }, + { + name: "updateWebhookEndpoint", + call: () => updateWebhookEndpoint("app_1", "ins_1", "ep_1", { description: "d" }), + method: "PATCH", + path: `${PREFIX}/ep_1`, + body: '{"description":"d"}', + }, + { + name: "deleteWebhookEndpoint", + call: () => deleteWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "DELETE", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "getWebhookEndpointSecret", + call: () => getWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/secret`, + body: undefined, + }, + { + name: "rotateWebhookEndpointSecret", + call: () => rotateWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "POST", + path: `${PREFIX}/ep_1/secret/rotate`, + body: undefined, + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1"), + method: "GET", + path: `${PREFIX}/event_types`, + body: undefined, + }, + { + name: "listWebhookMessages", + call: () => listWebhookMessages("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/messages`, + body: undefined, + }, + { + name: "resendWebhookMessage", + call: () => resendWebhookMessage("app_1", "ins_1", "ep_1", "msg_1"), + method: "POST", + path: `${PREFIX}/ep_1/messages/msg_1/resend`, + body: undefined, + }, + { + name: "recoverWebhookMessages", + call: () => + recoverWebhookMessages("app_1", "ins_1", "ep_1", { since: "2026-05-01T00:00:00Z" }), + method: "POST", + path: `${PREFIX}/ep_1/recover`, + body: '{"since":"2026-05-01T00:00:00Z"}', + }, + { + name: "sendWebhookExample", + call: () => sendWebhookExample("app_1", "ins_1", "ep_1", "user.created"), + method: "POST", + path: `${PREFIX}/ep_1/send_example`, + body: '{"event_type":"user.created"}', + }, + { + name: "getWebhookPortalUrl", + call: () => getWebhookPortalUrl("app_1", "ins_1"), + method: "POST", + path: `${PREFIX}/url`, + body: "{}", + }, + ])("$name sends $method $path", async ({ call, method, path, body }) => { + await call(); + + expect(captured).toHaveLength(1); + const request = captured[0]!; + expect(request.method).toBe(method); + expect(request.url.pathname).toBe(path); + expect(request.body).toBe(body); + expect(request.contentType).toBe(body === undefined ? null : "application/json"); + }); + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookMessages", + call: () => + listWebhookMessages("app_1", "ins_1", "ep_1", { limit: 50, iterator: "iter_abc" }), + }, + ])("$name translates --iterator to the starting_after query param", async ({ call }) => { + await call(); + + const url = captured[0]!.url; + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.get("starting_after")).toBe("iter_abc"); + expect(url.searchParams.has("iterator")).toBe(false); + }); + + test.each([ + { name: "listWebhookEndpoints", call: () => listWebhookEndpoints("app_1", "ins_1") }, + { name: "listWebhookEventTypes", call: () => listWebhookEventTypes("app_1", "ins_1") }, + { name: "listWebhookMessages", call: () => listWebhookMessages("app_1", "ins_1", "ep_1") }, + ])("$name omits pagination params when not provided", async ({ call }) => { + await call(); + expect(captured[0]!.url.search).toBe(""); + }); + + test("listWebhookMessages forwards the status filter", async () => { + await listWebhookMessages("app_1", "ins_1", "ep_1", { status: "fail" }); + + expect(captured[0]!.url.searchParams.get("status")).toBe("fail"); + }); + + test("recoverWebhookMessages includes until only when provided", async () => { + await recoverWebhookMessages("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + + expect(captured[0]!.body).toBe( + '{"since":"2026-05-01T00:00:00Z","until":"2026-05-01T01:00:00Z"}', + ); + }); + + test("createWebhookEndpoint serializes optional fields", async () => { + await createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + + expect(JSON.parse(captured[0]!.body!)).toEqual({ + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + }); + + test("throws PlapiError on non-ok responses", async () => { + stubFetch( + async () => new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { status: 404 }), + ); + + await expect(getWebhookEndpoint("app_1", "ins_1", "ep_missing")).rejects.toBeInstanceOf( + PlapiError, + ); + }); +}); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 53f78ba2..742b8563 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -331,3 +331,234 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +// ── Webhooks (instance-scoped /webhooks routes) ────────────────────────── + +export type WebhookEndpoint = { + id: string; + url: string; + version: number; + description?: string | null; + disabled: boolean; + filter_types?: string[] | null; + channels?: string[] | null; + created_at: string; + updated_at: string; +}; + +export type WebhookCursor = { + starting_after: string | null; + ending_before: string | null; + has_next_page: boolean; +}; + +export type WebhookEndpointList = { + data: WebhookEndpoint[]; + cursor: WebhookCursor; +}; + +export type WebhookEventType = { + name: string; + description: string; + archived: boolean; + created_at: string; + updated_at: string; +}; + +export type WebhookEventTypeList = { + data: WebhookEventType[]; + cursor: WebhookCursor; +}; + +export const WEBHOOK_MESSAGE_STATUSES = ["success", "pending", "fail", "sending"] as const; +export type WebhookMessageStatus = (typeof WEBHOOK_MESSAGE_STATUSES)[number]; + +export type WebhookMessage = { + id: string; + event_type: string; + status: WebhookMessageStatus; + next_attempt: string | null; + payload: unknown; + created_at: string; +}; + +export type WebhookMessageList = { + data: WebhookMessage[]; + cursor: WebhookCursor; +}; + +export type CreateWebhookEndpointParams = { + url: string; + version: 1; + description?: string; + disabled?: boolean; + filter_types?: string[]; + channels?: string[]; +}; + +export type UpdateWebhookEndpointParams = Partial; + +export type WebhookPageParams = { + limit?: number; + iterator?: string; +}; + +function webhooksUrl(applicationId: string, instanceId: string, path = ""): URL { + return new URL( + `/v1/platform/applications/${applicationId}/instances/${instanceId}/webhooks${path}`, + getPlapiBaseUrl(), + ); +} + +/** + * The CLI flag is `--iterator` (the Svix pagination concept); the wire query + * param is Clerk's cursor convention `starting_after`. The translation lives + * here so commands never see the wire name. + */ +function appendPageParams(url: URL, params?: WebhookPageParams): void { + if (typeof params?.limit === "number") { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.iterator) { + url.searchParams.set("starting_after", params.iterator); + } +} + +export async function listWebhookEndpoints( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function getWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createWebhookEndpoint( + applicationId: string, + instanceId: string, + params: CreateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function updateWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, + params: UpdateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("PATCH", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function deleteWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + await plapiFetch("DELETE", url); +} + +export async function getWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise<{ secret: string }> { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret`); + const response = await plapiFetch("GET", url); + return response.json() as Promise<{ secret: string }>; +} + +export async function rotateWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret/rotate`); + await plapiFetch("POST", url); +} + +export async function listWebhookEventTypes( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, "/event_types"); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export type WebhookMessageListParams = WebhookPageParams & { status?: WebhookMessageStatus }; + +export async function listWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + params?: WebhookMessageListParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages`); + appendPageParams(url, params); + if (params?.status) { + url.searchParams.set("status", params.status); + } + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function resendWebhookMessage( + applicationId: string, + instanceId: string, + endpointId: string, + messageId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages/${messageId}/resend`); + await plapiFetch("POST", url); +} + +export async function recoverWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + timeWindow: { since: string; until?: string }, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/recover`); + // JSON.stringify already omits an absent `until`, so the window is the wire body as-is. + await plapiFetch("POST", url, { body: JSON.stringify(timeWindow) }); +} + +export async function sendWebhookExample( + applicationId: string, + instanceId: string, + endpointId: string, + eventType: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/send_example`); + await plapiFetch("POST", url, { body: JSON.stringify({ event_type: eventType }) }); +} + +export async function getWebhookPortalUrl( + applicationId: string, + instanceId: string, +): Promise<{ url: string }> { + const url = webhooksUrl(applicationId, instanceId, "/url"); + // PLAPI /webhooks/url requires Content-Type: application/json; the body is intentionally empty. + const response = await plapiFetch("POST", url, { body: JSON.stringify({}) }); + return response.json() as Promise<{ url: string }>; +} diff --git a/packages/cli-core/src/lib/signals.ts b/packages/cli-core/src/lib/signals.ts new file mode 100644 index 00000000..5d04089e --- /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", cliSigintHandler)` instead of nuking all + * SIGINT listeners. + */ +export const cliSigintHandler = (): never => process.exit(EXIT_CODE.SIGINT); diff --git a/test/e2e/lib/dev-server.ts b/test/e2e/lib/dev-server.ts index 8ea78661..c9729d0b 100644 --- a/test/e2e/lib/dev-server.ts +++ b/test/e2e/lib/dev-server.ts @@ -101,8 +101,6 @@ async function tryStart(opts: { const stderrLines: string[] = []; const stdoutLines: string[] = []; - log(`starting dev server: npx ${fullCmd.join(" ")} on port ${port}`); - const proc = Bun.spawn(["npx", ...fullCmd], { cwd: projectDir, stdout: "pipe", @@ -170,7 +168,6 @@ async function tryStart(opts: { } if (await canConnect(host, port, 1000)) { - log(`dev server ready (accepting TCP on ${host}:${port})`); return { kind: "ready", value: { proc, port, host, stdout: stdoutLines, stderr: stderrLines }, @@ -222,7 +219,6 @@ export async function startDevServer(opts: { /** Kill a dev server process, falling back to SIGKILL after 5 seconds. */ export async function killDevServer(proc: Subprocess): Promise { - log("killing dev server"); proc.kill("SIGTERM"); const timeout = setTimeout(() => { @@ -234,6 +230,4 @@ export async function killDevServer(proc: Subprocess): Promise { } finally { clearTimeout(timeout); } - - log("dev server stopped"); } diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 08486393..2b06df9b 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -42,6 +42,20 @@ async function copyFixture(fixtureDir: string, projectDir: string): Promise { + await Bun.write( + join(projectDir, ".npmrc"), + "fetch-timeout=20000\nfetch-retries=2\nfetch-retry-mintimeout=1000\nfetch-retry-maxtimeout=8000\n", + ); +} + /** * Best-effort recursive remove. Cleanup runs after the test has already * passed, so a stray filesystem error here must not fail the test. Bun's @@ -56,6 +70,36 @@ async function safeRm(path: string): Promise { } } +/** + * Run a step with a hard timeout, retrying once on a fresh subprocess. In human + * mode `clerk link`/`clerk init` shell out to git and can intermittently stall + * in a non-fetch path (a git subprocess, a prompt) that the CLI's own request + * timeout doesn't bound — which would otherwise burn the whole 300s beforeAll + * budget. Promise.race abandons a hung subprocess (no stream deadlock), and the + * retry lands on a clean run; beforeAll is not retried, so a brief orphan can't + * cascade. + */ +async function withRetry(label: string, timeoutMs: number, fn: () => Promise): Promise { + for (let attempt = 1; attempt <= 2; attempt++) { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + try { + await Promise.race([fn(), timeout]); + return; + } catch (err) { + if (attempt === 2) throw err; + log(`${label} attempt ${attempt} failed (${err}); retrying`); + } finally { + clearTimeout(timer); + } + } +} + /** * Pre-link the project to the test Clerk application using an isolated * CLERK_CONFIG_DIR, so `clerk init` finds an existing link and skips the @@ -65,7 +109,10 @@ async function linkProject(projectDir: string, configDir: string): Promise const appId = requireEnv("CLERK_CLI_TEST_APP_ID"); const platformAPIKey = requireEnv("CLERK_PLATFORM_API_KEY"); - const result = await Bun.$`bun ${CLI_PATH} --mode human link --app ${appId}` + // Agent mode keeps link non-interactive: if a retry re-runs it on a project + // the first (hung-then-killed) attempt already linked, agent mode prints + // "already linked" and exits 0 instead of blocking on a human confirm prompt. + const result = await Bun.$`bun ${CLI_PATH} --mode agent link --app ${appId}` .cwd(projectDir) .env({ CLERK_CONFIG_DIR: configDir, @@ -152,31 +199,31 @@ export type Fixture = { export async function setupFixture(name: FixtureName): Promise { const config = fixtures[name]; const fixtureDir = join(FIXTURES_DIR, name); - log("setup started"); // Resolve symlinks (macOS /var -> /private/var) so profile keys match across commands const tmp = await realpath(tmpdir()); const projectDir = await mkdtemp(join(tmp, `clerk-e2e-${name}-`)); const configDir = await mkdtemp(join(tmp, "clerk-e2e-config-")); await copyFixture(fixtureDir, projectDir); + await writeNpmrc(projectDir); log("fixture copied"); let publishableKey = ""; let secretKey = ""; try { - // Git-init before linking so the profile key matches for later commands - await gitInit(projectDir); + // Git-init before linking so the profile key matches for later commands. + // Step markers are debug-gated (CLERK_E2E_DEBUG) and pinpoint which step + // stalls if setup ever hits the 300s beforeAll budget. + await withRetry("git init", 30_000, () => gitInit(projectDir)); log("git init done"); - - // The magic happens here, we actually test out `clerk link` and `clerk init` - await linkProject(projectDir, configDir); + // Budgets sit above loggedFetch's 60s request timeout so a genuinely slow + // API call is handled there; withRetry only trips on a non-fetch stall. + await withRetry("clerk link", 90_000, () => linkProject(projectDir, configDir)); log("clerk link done"); - - await runClerkInit(projectDir, configDir); + await withRetry("clerk init", 120_000, () => runClerkInit(projectDir, configDir)); log("clerk init done"); - // Verify clerk init wrote env files and extract keys. const envVars = await parseEnvFiles(projectDir); const publishableKeyName = await detectPublishableKeyName(projectDir); @@ -191,11 +238,15 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error(`${secretKeyName} not found in env files written by clerk init.`); } - const install = await Bun.$`npm ci --ignore-scripts --legacy-peer-deps` - .cwd(projectDir) - .quiet() - .nothrow(); - assertSuccess("npm ci failed", install); + // fetch-timeout/retries come from the project .npmrc (writeNpmrc); --no-audit + // and --no-fund drop npm's advisory network round-trips during `ci`. + await withRetry("npm ci", 120_000, async () => { + const install = await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + .cwd(projectDir) + .quiet() + .nothrow(); + assertSuccess("npm ci failed", install); + }); log("npm ci done"); } catch (err) { await safeRm(projectDir); @@ -203,13 +254,9 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error("setup failed", { cause: err }); } - log("setup complete"); - const cleanup = async () => { - log("cleanup started"); await safeRm(projectDir); await safeRm(configDir); - log("cleanup done"); }; return { diff --git a/test/e2e/lib/fixture-test.ts b/test/e2e/lib/fixture-test.ts index 6812101e..87cab863 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -101,20 +101,16 @@ export function createFixtureHarness(name: FixtureName): FixtureHarness { let users: Users | null = null; beforeAll(async () => { - log("beforeAll started"); fixture = await setupFixture(name); users = createUsers(fixture); - log("beforeAll finished"); }, 300_000); afterEach(async () => { await users?.cleanup(); - }); + }, 30_000); // BAPI deletes can exceed bun's 5s default under load afterAll(async () => { - log("afterAll started"); await fixture?.cleanup(); - log("afterAll finished"); }, 60_000); return () => { @@ -142,14 +138,12 @@ export function runFixtureTests(harness: FixtureHarness): void { const { projectDir, config } = fixture; // Build first so type generation artifacts are available for tsc. - log("build started"); const build = await Bun.$`npx ${config.buildCmd}`.cwd(projectDir).quiet().nothrow(); if (build.exitCode !== 0) { throw new Error( `${config.buildCmd.join(" ")} failed:\n${build.stdout.toString()}\n${build.stderr.toString()}`, ); } - log("build succeeded"); }, { timeout: 300_000 }, // 5 minutes - install + build can be slow) ); @@ -164,14 +158,12 @@ export function runFixtureTests(harness: FixtureHarness): void { // framework-specific type generation), otherwise plain tsc. const useTypecheck = await hasTypecheckScript(projectDir); const command = useTypecheck ? "npm run typecheck" : "bunx tsc --noEmit"; - log(`typecheck started (${command} in ${projectDir})`); const shell = useTypecheck ? await Bun.$`npm run typecheck 2>&1`.cwd(projectDir).quiet().nothrow() : await Bun.$`bunx tsc --noEmit 2>&1`.cwd(projectDir).quiet().nothrow(); if (shell.exitCode !== 0) { throw new Error(`${command} failed in ${projectDir}:\n${shell.text()}`); } - log("typecheck succeeded"); }, { timeout: 300_000 }, // 5 minutes - install + typecheck can be slow ); @@ -198,7 +190,6 @@ export function runFileExistsTest(harness: FixtureHarness, expectedFiles: string ); const existing = found.filter(Boolean); expect(existing.length).toBeGreaterThanOrEqual(1); - log(`found: ${existing.join(", ")}`); }); } @@ -265,11 +256,9 @@ export function runBrowserTests(harness: FixtureHarness): void { context, options: frontendApiUrl ? { frontendApiUrl } : undefined, }); - log(`navigating to http://${host}:${port}`); await page.goto(`http://${host}:${port}`, { waitUntil: "load" }); // 5. Sign in - log("signing in"); await clerk.signIn({ page, signInParams: { @@ -281,7 +270,6 @@ export function runBrowserTests(harness: FixtureHarness): void { // 6. Verify Clerk loaded await clerk.loaded({ page }); - log("clerk has been loaded"); // 7. Check to see that the user is now on the window object. await page.waitForFunction( @@ -289,7 +277,6 @@ export function runBrowserTests(harness: FixtureHarness): void { null, { timeout: 10_000 }, ); - log("auth flow passed"); // Log any console errors as warnings (non-fatal) if (consoleErrors.length > 0) { diff --git a/test/e2e/lib/logger.ts b/test/e2e/lib/logger.ts index 96aa173e..d27f9b86 100644 --- a/test/e2e/lib/logger.ts +++ b/test/e2e/lib/logger.ts @@ -2,7 +2,7 @@ const startTime = Date.now(); const isDebug = process.env.CLERK_E2E_DEBUG === "1" || process.env.CLERK_E2E_DEBUG === "true"; -/** Log a timestamped message with fixture name for tracing execution order. */ +/** Emit a timestamped diagnostic line when CLERK_E2E_DEBUG is set. */ export function log(message: string): void { if (!isDebug) return; const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); diff --git a/test/e2e/lib/test-user.ts b/test/e2e/lib/test-user.ts index d679d65d..518f82ea 100644 --- a/test/e2e/lib/test-user.ts +++ b/test/e2e/lib/test-user.ts @@ -53,8 +53,6 @@ export async function createTestUser(configDir: string, target: TestUserTarget): skip_password_checks: true, }); - log(`creating test user: ${email}`); - const result = await Bun.$`bun ${CLI_PATH} users create -d ${body} --json --yes ${targetArgs(target)}` .env(clerkEnv(configDir, target)) @@ -69,7 +67,6 @@ export async function createTestUser(configDir: string, target: TestUserTarget): } const user: { id: string } = JSON.parse(result.stdout.toString()); - log(`test user created: ${user.id}`); return { id: user.id, email, password }; } @@ -80,8 +77,6 @@ export async function deleteTestUser( configDir: string, target: TestUserTarget, ): Promise { - log(`deleting test user: ${userId}`); - const result = await Bun.$`bun ${CLI_PATH} api /users/${userId} -X DELETE --yes ${targetArgs(target)}` .env(clerkEnv(configDir, target)) @@ -93,7 +88,5 @@ export async function deleteTestUser( const stderr = result.stderr.toString().trim(); const detail = stderr || stdout || "(no output)"; log(`warning: failed to delete test user ${userId}: ${detail}`); - } else { - log(`test user deleted: ${userId}`); } }