feat(webhooks): clerk webhooks toolkit (V1)#366
Conversation
Ship the local webhooks toolkit that needs no Clerk backend, carved off main: - `clerk webhooks listen` — standalone Svix relay tunnel. Dials the relay, prints a stable inbox URL (token persisted under relay.__relay_only__ so it survives restarts; --token pins an explicit one), and forwards deliveries to --forward-to. No auth, no instance context, no signing secret, no HMAC. - `clerk webhooks verify` — offline HMAC-SHA256 signature verification from a saved listen event line or the four explicit header values. No network. Relay transport (relay-client/relay-protocol/forward/render) and verify are ported unchanged from the full webhooks branch; listen is rewritten to the relay-only path with all PLAPI coupling removed. Additive lib edits: relay config accessors, INVALID_WEBHOOK_SIGNATURE error code, and a named cliSigintHandler so listen can hand off SIGINT cleanly. Registered via the registrant pattern with no group auth gate. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…s group Ports the registrant-pattern rule from the full webhooks branch. The closing example is updated for this branch: `users` shows inherited group options via optsWithGlobals, and the auth-free `webhooks` group (listen + verify) shows a group that omits the preAction gate. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…roup help `clerk webhooks token` generates a valid relay token (c_ + 10 base62 chars) for `listen --token`, removing the guesswork of hand-writing the exact format. The bare token is the default stdout output (even non-interactively, so command substitution works) with `--json` opt-in; the usage hint is stderr-only and interactive-only so it never pollutes a pipe: clerk webhooks listen --token "$(clerk webhooks token)" The `webhooks` group now carries a 3-step examples block (token -> listen -> verify) shown when run solo or with --help. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
- `listen --forward-to` is now a required option (forwarding is the point of the command); the examples and help reflect this. - When `listen` runs WITHOUT `--token`, the ready banner now warns that the auto-generated relay token isn't a guaranteed-stable handle (it can differ across machines, a cleared config, or a rare collision) and prints the exact `--token <c_…>` to pass next time to lock the current URL, plus a pointer to `clerk webhooks token`. Passing `--token` suppresses the warning. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
…banner Surface https://dashboard.clerk.com/last-active?path=webhooks in the ready banner so users can jump straight to adding the relay URL as an endpoint. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
expandInputJson auto-reads piped stdin as --input-json for any command in a non-TTY context. That stole stdin from `webhooks verify --payload -` / `--delivery -`, whose JSON keys were then mis-parsed into flags (e.g. "unknown option '--type'"). Skip the auto-stdin expansion when argv contains a bare `-`, so a command that explicitly claims stdin gets it. Verified: verify --payload - and --delivery - now succeed. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
From a multi-agent adversarial sweep (27/28 findings confirmed). 14 fixes:
listen:
- validate --forward-to as our own usage error (JSON in agent mode) + reject
non-http(s)/invalid URLs at startup before opening the relay
- warn once when --headers carries a svix-* key (always overridden by delivery)
- emit a structured {"type":"reconnecting"} NDJSON line in agent mode
- drop endpoint_id/events_filter from the ready line (always null in V1)
- banner: remove internal "relay-only" jargon and the always-"all" events row
verify:
- --delivery accepts the full `listen --json` stream (skip the ready line)
- reject garbled/non-base64 secrets (Buffer.from silently truncates them)
- guard against --delivery - and --payload - both consuming stdin
- only show the clock-skew hint for structurally valid (32-byte v1) signatures
- hint when a --payload file has a trailing newline (HMAC is byte-exact)
- read @files directly so character devices like /dev/null work
misc: clearer --headers comma error; delete dead renderVerificationWarning.
Rejected on judgment: routing verify --json failures to stdout (would break the
CLI-wide convention that errors are JSON on stderr; commands throw, never exit).
Deferred: global Commander-error JSON formatting (touches all commands).
Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma
The header sweep stepped the cursor up by the body's newline count, which under-counts any line that wraps past the terminal width. The overshoot left a duplicate "Next steps" stranded on screen. Extract `cursorRowsBelowHeader` to add the extra rows each wrapped line spans, and use the live terminal width. Claude-Session: https://claude.ai/code/session_01Nimc3t87jP8rm8DZ33kpJi
…ect spinner Collapse the listen delivery path now that V1 is relay-only: extract `assertForwardTo`, drop the dead PLAPI banner branch, and trim `ReadyInfo` to the fields the relay flow actually emits. Wrap the relay handshake in `withSpinner` (a no-op in agent/--json mode) and render the `token` Next steps through the animated `outro`. Claude-Session: https://claude.ai/code/session_01Nimc3t87jP8rm8DZ33kpJi
🦋 Changeset detectedLatest commit: 2e61d8f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📝 WalkthroughWalkthroughAdds the Estimated code review effort🎯 5 (Critical) | ⏱️ ~90+ minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
.claude/rules/command-registration.md (1)
66-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAside omits the new
tokensubcommand.This PR registers three webhooks subcommands (
token,listen,verify), but the example here only mentionslistenandverify. Since this rules doc is authored alongside the command, consider listingtokentoo so the description stays accurate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.claude/rules/command-registration.md at line 66, The webhooks group description is missing the new token subcommand, so update the guidance in the registerWebhooks example to include token alongside listen and verify. Keep the structure aligned with registerWebhooks by describing all three .command(...) entries and the inherited optsWithGlobals() options, while leaving preAction omitted since this group is auth-free.packages/cli-core/src/commands/webhooks/verify.ts (1)
113-118: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueCatch-all collapses all read failures to
FILE_NOT_FOUND.A permission error (EACCES), a directory path, or a decode failure on
Bun.file(path).text()would all surface as "File not found", which can mislead users diagnosing the real cause.♻️ Optionally preserve the underlying cause
try { return await Bun.file(path).text(); - } catch { - throw new CliError(`File not found: ${path}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } catch (err) { + const reason = err instanceof Error ? `: ${err.message}` : ""; + throw new CliError(`Could not read ${path}${reason}`, { code: ERROR_CODE.FILE_NOT_FOUND }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli-core/src/commands/webhooks/verify.ts` around lines 113 - 118, The read helper in verify.ts is mapping every failure from Bun.file(path).text() to FILE_NOT_FOUND, so update the catch path to preserve and classify the real error instead of treating all cases as missing files. In the function that reads the file, inspect the thrown error (for example permission, directory, or decode failures) and either rethrow with the original cause attached or translate it to a more accurate CliError code/message while keeping the underlying error details available.packages/cli-core/src/commands/webhooks/relay-client.ts (1)
120-131: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winHandle
onTokenRotatedrejection so the redial still fires.If
onTokenRotatedrejects (e.g.setRelayEntryfails to persist), the.then()never runs, so thesetTimeoutredial is never scheduled and the client silently stalls after a 1008 collision. The non-collision path at Line 134-135 reconnects unconditionally; the rotation path should be equally resilient.♻️ Schedule the redial regardless of persistence outcome
- void this.options.onTokenRotated(this.token).then(() => { - if (this.stopped) return; - setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); - }); + void this.options.onTokenRotated(this.token).finally(() => { + if (this.stopped) return; + setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli-core/src/commands/webhooks/relay-client.ts` around lines 120 - 131, The token-collision branch in relay-client’s reconnect flow only schedules the redial inside the onTokenRotated().then() success path, so a rejected persistence callback can stall reconnection. Update the collision handling in relay-client to use the same resilient redial behavior as the normal drop path: always schedule connect() after RELAY_RECONNECT_DELAY_MS once the token is rotated, and ensure any onTokenRotated failure is caught/logged without preventing the timeout from firing.packages/cli-core/src/lib/config.ts (1)
74-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional:
raw.relayis cast without validating entry shape.
typeof raw.relay === "object"also accepts arrays, and inner entries aren't checked to be{ token: string }. A hand-edited/corrupted config could surface a malformedRelayEntrytolistenwithout a usage error. Given this is local config, it's low risk — consider a light shape guard if you want defense in depth.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli-core/src/lib/config.ts` around lines 74 - 76, The raw relay config is being accepted too loosely in config.ts, since raw.relay can be an array and its entries are cast to RelayEntry without checking they match the expected shape. Tighten the parsing in the config loading logic by validating raw.relay is a plain object and confirming each relay entry has the expected token string before assigning to config.relay, so malformed local config cannot flow into listen unchecked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In @.claude/rules/command-registration.md:
- Line 66: The webhooks group description is missing the new token subcommand,
so update the guidance in the registerWebhooks example to include token
alongside listen and verify. Keep the structure aligned with registerWebhooks by
describing all three .command(...) entries and the inherited optsWithGlobals()
options, while leaving preAction omitted since this group is auth-free.
In `@packages/cli-core/src/commands/webhooks/relay-client.ts`:
- Around line 120-131: The token-collision branch in relay-client’s reconnect
flow only schedules the redial inside the onTokenRotated().then() success path,
so a rejected persistence callback can stall reconnection. Update the collision
handling in relay-client to use the same resilient redial behavior as the normal
drop path: always schedule connect() after RELAY_RECONNECT_DELAY_MS once the
token is rotated, and ensure any onTokenRotated failure is caught/logged without
preventing the timeout from firing.
In `@packages/cli-core/src/commands/webhooks/verify.ts`:
- Around line 113-118: The read helper in verify.ts is mapping every failure
from Bun.file(path).text() to FILE_NOT_FOUND, so update the catch path to
preserve and classify the real error instead of treating all cases as missing
files. In the function that reads the file, inspect the thrown error (for
example permission, directory, or decode failures) and either rethrow with the
original cause attached or translate it to a more accurate CliError code/message
while keeping the underlying error details available.
In `@packages/cli-core/src/lib/config.ts`:
- Around line 74-76: The raw relay config is being accepted too loosely in
config.ts, since raw.relay can be an array and its entries are cast to
RelayEntry without checking they match the expected shape. Tighten the parsing
in the config loading logic by validating raw.relay is a plain object and
confirming each relay entry has the expected token string before assigning to
config.relay, so malformed local config cannot flow into listen unchecked.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c28a1b71-572a-45b7-a233-fcaed0bf2e9c
📒 Files selected for processing (29)
.changeset/webhooks-listen-v1.md.claude/rules/command-registration.md.oxlintrc.jsonpackages/cli-core/src/cli-program.tspackages/cli-core/src/cli.tspackages/cli-core/src/commands/webhooks/README.mdpackages/cli-core/src/commands/webhooks/forward.test.tspackages/cli-core/src/commands/webhooks/forward.tspackages/cli-core/src/commands/webhooks/index.tspackages/cli-core/src/commands/webhooks/listen.test.tspackages/cli-core/src/commands/webhooks/listen.tspackages/cli-core/src/commands/webhooks/relay-client.test.tspackages/cli-core/src/commands/webhooks/relay-client.tspackages/cli-core/src/commands/webhooks/relay-protocol.test.tspackages/cli-core/src/commands/webhooks/relay-protocol.tspackages/cli-core/src/commands/webhooks/render.test.tspackages/cli-core/src/commands/webhooks/render.tspackages/cli-core/src/commands/webhooks/shared.tspackages/cli-core/src/commands/webhooks/token.test.tspackages/cli-core/src/commands/webhooks/token.tspackages/cli-core/src/commands/webhooks/verify.test.tspackages/cli-core/src/commands/webhooks/verify.tspackages/cli-core/src/lib/config.tspackages/cli-core/src/lib/errors.tspackages/cli-core/src/lib/gradient.test.tspackages/cli-core/src/lib/gradient.tspackages/cli-core/src/lib/input-json.test.tspackages/cli-core/src/lib/input-json.tspackages/cli-core/src/lib/signals.ts
|
!snapshot |
Snapshot failedThe snapshot publish workflow failed. View the workflow run for details. |
1 similar comment
Snapshot failedThe snapshot publish workflow failed. View the workflow run for details. |
clerk webhooks toolkit (V1)clerk webhooks toolkit (V1)
Summary
Adds the
clerk webhookscommand group (V1) — a PLAPI-free local webhooks toolkit. Every command runs with no auth, no linked project, and no Clerk backend, so a developer can test webhook delivery and signature verification before any dashboard setup.Three subcommands:
clerk webhooks listen --forward-to <url>— opens a standalone Svix relay tunnel and forwards each delivery to a local handler.--forward-tois required. Without--tokenthe banner warns that the auto-generated relay token isn't a guaranteed-stable handle and prints the exact--tokento pin next time;--token <c_…>pins an explicit, shareable URL that survives restarts (persisted in config). Flags:--forward-to(required),--token,--headers,--json.clerk webhooks verify— verifies a webhook signature offline (HMAC-SHA256), from a savedlistenevent line (--delivery) or the four explicit header values. No network calls.clerk webhooks token— generates a valid relay token (c_+ 10 base62 chars) forlisten --token. Prints the bare token to stdout so it pipes:clerk webhooks listen --token "$(clerk webhooks token)".Agent mode
listenemits NDJSON (ready/event/reconnectinglines) under--jsonor in agent mode;verifyandtokenemit machine-readable output too. Usage/validation errors surface as JSON in agent mode (e.g. a malformed--tokenor missing--forward-to).