From c6123ea9b390e49102d4534995e806ea1ba01408 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 18:51:21 -0300 Subject: [PATCH 01/42] fix(e2e): bound network and setup steps to kill flaky 300s timeouts The flaky E2E failures were Nuxt's beforeAll hitting the 300s budget. Two distinct stalls shared one opaque "hook timed out" signature: one CI run hung in `clerk link` (an untimed `fetch()` to the production Clerk API), another in `git init`. - Add a default 60s timeout to `loggedFetch`, composed with any caller signal via `AbortSignal.any` so tighter budgets (keyless's 15s) still win. A stalled connection now fails fast across every CLI command, not just in tests. - Wrap each fixture setup step (git / clerk link / clerk init / npm ci) in a per-step timeout that fails with a labeled error instead of silently eating the whole 300s budget. - Cap e2e `--parallel=4` to cut startup contention; add an explicit afterEach cleanup budget and `npm ci --no-audit --no-fund`. - Drop noisy success-path debug traces; keep failure diagnostics. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- .changeset/fix-network-request-timeout.md | 5 +++ package.json | 2 +- packages/cli-core/src/lib/fetch.test.ts | 37 ++++++++++++++++ packages/cli-core/src/lib/fetch.ts | 38 ++++++++++++++--- test/e2e/lib/dev-server.ts | 6 --- test/e2e/lib/fixture-setup.ts | 52 ++++++++++++++--------- test/e2e/lib/fixture-test.ts | 16 +------ test/e2e/lib/logger.ts | 2 +- test/e2e/lib/test-user.ts | 7 --- 9 files changed, 109 insertions(+), 56 deletions(-) create mode 100644 .changeset/fix-network-request-timeout.md 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/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/lib/fetch.test.ts b/packages/cli-core/src/lib/fetch.test.ts index 39f03188..e765048c 100644 --- a/packages/cli-core/src/lib/fetch.test.ts +++ b/packages/cli-core/src/lib/fetch.test.ts @@ -41,4 +41,41 @@ 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); + const err = await loggedFetch("https://example.test/hang", { + tag: "plapi", + timeoutMs: 10_000, + signal: caller, + }).catch((e: unknown) => e); + // The caller's signal fired first, so we must surface its abort, not + // pretend our 10s default timeout elapsed. + 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..0ff89ac8 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -14,7 +14,17 @@ import { buildUserAgent } from "./user-agent.ts"; const USER_AGENT = buildUserAgent(); -export type LoggedFetchInit = RequestInit & { tag: string }; +/** + * Default per-request timeout. Native `fetch()` has no timeout, so without this + * a stalled TCP connection to a Clerk API hangs the command indefinitely (this + * was the root cause of the flaky e2e setup, where `clerk link`/`clerk init` + * could hang for the full 300s test budget). 60s is generous for any single + * REST call while still bounding the worst case. Callers needing a tighter or + * looser bound pass `timeoutMs`; an explicit `signal` composes with this one. + */ +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 +39,32 @@ 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 }), - ); + + // Compose our default timeout with any caller-supplied signal so whichever + // fires first wins (e.g. keyless.ts's tighter 15s budget still applies). + 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) { + // Distinguish our timeout from a caller abort or a plain network error, so + // the failure is self-diagnosing instead of a cryptic DOMException/hang. + 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/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..a51a1ac6 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -56,6 +56,25 @@ async function safeRm(path: string): Promise { } } +/** + * Run a setup step under a hard timeout so a single stalled subprocess fails + * fast with a labeled error instead of silently burning the whole 300s + * `beforeAll` budget (the flaky-setup signature seen in CI, which stalled in + * `clerk link` on one run and `git init` on another). `beforeAll` is never + * retried, so leaving the inner promise to settle on timeout is safe. + */ +async function withStepTimeout(label: string, ms: number, run: () => Promise): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + try { + return await Promise.race([run(), timeout]); + } 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 @@ -152,31 +171,24 @@ 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); - log("fixture copied"); let publishableKey = ""; let secretKey = ""; try { // Git-init before linking so the profile key matches for later commands - await gitInit(projectDir); - log("git init done"); - - // The magic happens here, we actually test out `clerk link` and `clerk init` - await linkProject(projectDir, configDir); - log("clerk link done"); + await withStepTimeout("git init", 60_000, () => gitInit(projectDir)); + // `clerk link`/`clerk init` hit the production Clerk API; these step budgets + // back up the CLI's own per-request timeout. + await withStepTimeout("clerk link", 60_000, () => linkProject(projectDir, configDir)); + await withStepTimeout("clerk init", 90_000, () => runClerkInit(projectDir, configDir)); - await 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,25 +203,23 @@ 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(); + // --no-audit/--no-fund drop npm's advisory network round-trips during `ci`. + const install = await withStepTimeout("npm ci", 240_000, () => + 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); await safeRm(configDir); 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..e5792276 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -101,20 +101,17 @@ 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; an explicit + // budget avoids silently orphaning test users when cleanup runs long. afterAll(async () => { - log("afterAll started"); await fixture?.cleanup(); - log("afterAll finished"); }, 60_000); return () => { @@ -142,14 +139,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 +159,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 +191,6 @@ export function runFileExistsTest(harness: FixtureHarness, expectedFiles: string ); const existing = found.filter(Boolean); expect(existing.length).toBeGreaterThanOrEqual(1); - log(`found: ${existing.join(", ")}`); }); } @@ -265,11 +257,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 +271,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 +278,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}`); } } From 5ce158ad1ed4a65b936429aae4bd829a351c30ed Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 19:39:23 -0300 Subject: [PATCH 02/42] fix(e2e): kill stalled setup subprocesses; harden timeout test Address PR review feedback: - `runStep` now spawns each setup step via `Bun.spawn` with an `AbortSignal` (Bun.$ can't be cancelled), so a timed-out git/clerk/npm step is killed instead of orphaned and left to race teardown. Adds runStep unit tests. - fetch timeout test now fails if `loggedFetch` resolves instead of rejecting (no more false pass via swallowed error). - Trim verbose comments. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- packages/cli-core/src/lib/fetch.test.ts | 11 ++- packages/cli-core/src/lib/fetch.ts | 15 +-- test/e2e/lib/fixture-setup.test.ts | 32 ++++++ test/e2e/lib/fixture-setup.ts | 124 +++++++++++++----------- test/e2e/lib/fixture-test.ts | 3 +- 5 files changed, 109 insertions(+), 76 deletions(-) create mode 100644 test/e2e/lib/fixture-setup.test.ts diff --git a/packages/cli-core/src/lib/fetch.test.ts b/packages/cli-core/src/lib/fetch.test.ts index e765048c..675cdb7e 100644 --- a/packages/cli-core/src/lib/fetch.test.ts +++ b/packages/cli-core/src/lib/fetch.test.ts @@ -61,13 +61,18 @@ describe("loggedFetch", () => { 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, - }).catch((e: unknown) => e); - // The caller's signal fired first, so we must surface its abort, not - // pretend our 10s default timeout elapsed. + }).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); diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts index 0ff89ac8..62d80585 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -14,14 +14,7 @@ import { buildUserAgent } from "./user-agent.ts"; const USER_AGENT = buildUserAgent(); -/** - * Default per-request timeout. Native `fetch()` has no timeout, so without this - * a stalled TCP connection to a Clerk API hangs the command indefinitely (this - * was the root cause of the flaky e2e setup, where `clerk link`/`clerk init` - * could hang for the full 300s test budget). 60s is generous for any single - * REST call while still bounding the worst case. Callers needing a tighter or - * looser bound pass `timeoutMs`; an explicit `signal` composes with this one. - */ +/** 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 }; @@ -46,8 +39,7 @@ export async function loggedFetch(url: URL | string, options: LoggedFetchInit): if (!headers.has("user-agent")) headers.set("User-Agent", USER_AGENT); log.debug(`${tag}: ${method} ${urlStr}`); - // Compose our default timeout with any caller-supplied signal so whichever - // fires first wins (e.g. keyless.ts's tighter 15s budget still applies). + // 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; @@ -58,8 +50,7 @@ export async function loggedFetch(url: URL | string, options: LoggedFetchInit): async () => fetch(url, { ...init, headers, signal }), ); } catch (err) { - // Distinguish our timeout from a caller abort or a plain network error, so - // the failure is self-diagnosing instead of a cryptic DOMException/hang. + // 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}`); } diff --git a/test/e2e/lib/fixture-setup.test.ts b/test/e2e/lib/fixture-setup.test.ts new file mode 100644 index 00000000..bd95429a --- /dev/null +++ b/test/e2e/lib/fixture-setup.test.ts @@ -0,0 +1,32 @@ +import { test, expect, describe } from "bun:test"; +import { tmpdir } from "node:os"; +import { runStep } from "./fixture-setup.ts"; + +describe("runStep", () => { + const base = { cwd: tmpdir(), env: process.env }; + + test("resolves when the command exits 0", async () => { + await expect( + runStep("ok", ["bash", "-c", "exit 0"], { ...base, timeoutMs: 5_000 }), + ).resolves.toBeUndefined(); + }); + + test("rejects with a labeled error including stderr on non-zero exit", async () => { + const err = await runStep("clerk link", ["bash", "-c", "echo boom >&2; exit 3"], { + ...base, + timeoutMs: 5_000, + }).catch((e: unknown) => e); + expect(String(err)).toMatch(/clerk link failed/); + expect(String(err)).toMatch(/boom/); + }); + + test("kills the subprocess and rejects promptly when the step exceeds its timeout", async () => { + const start = Date.now(); + const err = await runStep("slow", ["sleep", "10"], { ...base, timeoutMs: 150 }).catch( + (e: unknown) => e, + ); + expect(String(err)).toMatch(/slow timed out after 150ms/); + // Proves the child was killed rather than awaited: sleep 10 would take ~10s. + expect(Date.now() - start).toBeLessThan(2_000); + }); +}); diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index a51a1ac6..537d6d28 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -25,16 +25,6 @@ function requireEnv(name: string): string { return val; } -/** Throw with a descriptive message if a shell command failed. */ -function assertSuccess( - label: string, - result: { exitCode: number; stderr: { toString(): string } }, -): void { - if (result.exitCode !== 0) { - throw new Error(`${label}:\n${result.stderr.toString()}`); - } -} - /** * Copy the fixture directory into an existing project dir. */ @@ -56,20 +46,33 @@ async function safeRm(path: string): Promise { } } +interface RunStepOptions { + cwd: string; + env: Record; + timeoutMs: number; +} + /** - * Run a setup step under a hard timeout so a single stalled subprocess fails - * fast with a labeled error instead of silently burning the whole 300s - * `beforeAll` budget (the flaky-setup signature seen in CI, which stalled in - * `clerk link` on one run and `git init` on another). `beforeAll` is never - * retried, so leaving the inner promise to settle on timeout is safe. + * Spawn a setup step, killing the child on timeout so a stall fails fast with a + * labeled error instead of silently eating the whole 300s `beforeAll` budget. */ -async function withStepTimeout(label: string, ms: number, run: () => Promise): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); +export async function runStep(label: string, cmd: string[], opts: RunStepOptions): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs); + const proc = Bun.spawn(cmd, { + cwd: opts.cwd, + env: opts.env, + stdout: "ignore", + stderr: "pipe", + signal: controller.signal, }); try { - return await Promise.race([run(), timeout]); + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text().catch(() => ""), + proc.exited.catch(() => -1), + ]); + if (controller.signal.aborted) throw new Error(`${label} timed out after ${opts.timeoutMs}ms`); + if (exitCode !== 0) throw new Error(`${label} failed:\n${stderr}`); } finally { clearTimeout(timer); } @@ -84,33 +87,38 @@ 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}` - .cwd(projectDir) - .env({ + await runStep("clerk link", ["bun", CLI_PATH, "--mode", "human", "link", "--app", appId], { + cwd: projectDir, + // PATH lets Bun.spawn resolve `bun`; the rest of the env stays isolated. + env: { + PATH: process.env.PATH, CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, - }) - .quiet() - .nothrow(); - - assertSuccess("clerk link failed", result); + }, + timeoutMs: 60_000, + }); } async function gitInit(projectDir: string): Promise { - const result = - await Bun.$`git -c commit.gpgsign=false init && git add -A && git -c commit.gpgsign=false commit -m "init" --allow-empty` - .cwd(projectDir) - .env({ + await runStep( + "git init", + [ + "bash", + "-c", + 'git -c commit.gpgsign=false init && git add -A && git -c commit.gpgsign=false commit -m "init" --allow-empty', + ], + { + cwd: projectDir, + env: { ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@test.com", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@test.com", - }) - .quiet() - .nothrow(); - - assertSuccess("git init failed", result); + }, + timeoutMs: 60_000, + }, + ); } /** @@ -121,16 +129,19 @@ async function gitInit(projectDir: string): Promise { async function runClerkInit(projectDir: string, configDir: string): Promise { const platformAPIKey = requireEnv("CLERK_PLATFORM_API_KEY"); - const result = await Bun.$`bun ${CLI_PATH} --mode human init --yes --no-skills` - .cwd(projectDir) - .env({ - CLERK_CONFIG_DIR: configDir, - CLERK_PLATFORM_API_KEY: platformAPIKey, - }) - .quiet() - .nothrow(); - - assertSuccess("clerk init failed", result); + await runStep( + "clerk init", + ["bun", CLI_PATH, "--mode", "human", "init", "--yes", "--no-skills"], + { + cwd: projectDir, + env: { + PATH: process.env.PATH, + CLERK_CONFIG_DIR: configDir, + CLERK_PLATFORM_API_KEY: platformAPIKey, + }, + timeoutMs: 90_000, + }, + ); } /** Parse env files written by clerk init into a merged Record. @@ -183,11 +194,9 @@ export async function setupFixture(name: FixtureName): Promise { try { // Git-init before linking so the profile key matches for later commands - await withStepTimeout("git init", 60_000, () => gitInit(projectDir)); - // `clerk link`/`clerk init` hit the production Clerk API; these step budgets - // back up the CLI's own per-request timeout. - await withStepTimeout("clerk link", 60_000, () => linkProject(projectDir, configDir)); - await withStepTimeout("clerk init", 90_000, () => runClerkInit(projectDir, configDir)); + await gitInit(projectDir); + await linkProject(projectDir, configDir); + await runClerkInit(projectDir, configDir); const envVars = await parseEnvFiles(projectDir); @@ -203,14 +212,11 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error(`${secretKeyName} not found in env files written by clerk init.`); } - // --no-audit/--no-fund drop npm's advisory network round-trips during `ci`. - const install = await withStepTimeout("npm ci", 240_000, () => - Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` - .cwd(projectDir) - .quiet() - .nothrow(), + await runStep( + "npm ci", + ["npm", "ci", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"], + { cwd: projectDir, env: process.env, timeoutMs: 240_000 }, ); - assertSuccess("npm ci failed", install); } catch (err) { await safeRm(projectDir); await safeRm(configDir); diff --git a/test/e2e/lib/fixture-test.ts b/test/e2e/lib/fixture-test.ts index e5792276..87cab863 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -107,8 +107,7 @@ export function createFixtureHarness(name: FixtureName): FixtureHarness { afterEach(async () => { await users?.cleanup(); - }, 30_000); // BAPI deletes can exceed bun's 5s default under load; an explicit - // budget avoids silently orphaning test users when cleanup runs long. + }, 30_000); // BAPI deletes can exceed bun's 5s default under load afterAll(async () => { await fixture?.cleanup(); From 7d8c1a9806aae1aa472b8209ca5b1e4d745dedb0 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:02:57 -0300 Subject: [PATCH 03/42] fix(e2e): revert setup steps to Bun.$ + Promise.race timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bun.spawn rewrite (5ce158a) regressed the E2E job: 3 fixtures hung the full 300s in beforeAll with no per-step timeout recovering, because reading a killed child's piped stderr to EOF can block when a grandchild keeps the pipe open. Restore the prior approach, which passed E2E in 52s: - setup steps use Bun.$ again, wrapped in the Promise.race `withStepTimeout` (a timed-out step's subprocess is left to settle — beforeAll is never retried, so it can't cascade). - drop the runStep Bun.spawn helper and its unit test. The real root-cause fix (the 60s loggedFetch timeout that bounds a stalled clerk link/init network call at the source) is unchanged. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- test/e2e/lib/fixture-setup.test.ts | 32 -------- test/e2e/lib/fixture-setup.ts | 121 +++++++++++++---------------- 2 files changed, 56 insertions(+), 97 deletions(-) delete mode 100644 test/e2e/lib/fixture-setup.test.ts diff --git a/test/e2e/lib/fixture-setup.test.ts b/test/e2e/lib/fixture-setup.test.ts deleted file mode 100644 index bd95429a..00000000 --- a/test/e2e/lib/fixture-setup.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { tmpdir } from "node:os"; -import { runStep } from "./fixture-setup.ts"; - -describe("runStep", () => { - const base = { cwd: tmpdir(), env: process.env }; - - test("resolves when the command exits 0", async () => { - await expect( - runStep("ok", ["bash", "-c", "exit 0"], { ...base, timeoutMs: 5_000 }), - ).resolves.toBeUndefined(); - }); - - test("rejects with a labeled error including stderr on non-zero exit", async () => { - const err = await runStep("clerk link", ["bash", "-c", "echo boom >&2; exit 3"], { - ...base, - timeoutMs: 5_000, - }).catch((e: unknown) => e); - expect(String(err)).toMatch(/clerk link failed/); - expect(String(err)).toMatch(/boom/); - }); - - test("kills the subprocess and rejects promptly when the step exceeds its timeout", async () => { - const start = Date.now(); - const err = await runStep("slow", ["sleep", "10"], { ...base, timeoutMs: 150 }).catch( - (e: unknown) => e, - ); - expect(String(err)).toMatch(/slow timed out after 150ms/); - // Proves the child was killed rather than awaited: sleep 10 would take ~10s. - expect(Date.now() - start).toBeLessThan(2_000); - }); -}); diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 537d6d28..7da1ef6c 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -25,6 +25,16 @@ function requireEnv(name: string): string { return val; } +/** Throw with a descriptive message if a shell command failed. */ +function assertSuccess( + label: string, + result: { exitCode: number; stderr: { toString(): string } }, +): void { + if (result.exitCode !== 0) { + throw new Error(`${label}:\n${result.stderr.toString()}`); + } +} + /** * Copy the fixture directory into an existing project dir. */ @@ -46,33 +56,18 @@ async function safeRm(path: string): Promise { } } -interface RunStepOptions { - cwd: string; - env: Record; - timeoutMs: number; -} - /** - * Spawn a setup step, killing the child on timeout so a stall fails fast with a - * labeled error instead of silently eating the whole 300s `beforeAll` budget. + * Run a setup step under a hard timeout so a stall fails fast with a labeled + * error instead of silently burning the whole 300s `beforeAll` budget. The + * subprocess is left to settle on timeout — `beforeAll` is never retried. */ -export async function runStep(label: string, cmd: string[], opts: RunStepOptions): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), opts.timeoutMs); - const proc = Bun.spawn(cmd, { - cwd: opts.cwd, - env: opts.env, - stdout: "ignore", - stderr: "pipe", - signal: controller.signal, +async function withStepTimeout(label: string, ms: number, run: () => Promise): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); }); try { - const [stderr, exitCode] = await Promise.all([ - new Response(proc.stderr).text().catch(() => ""), - proc.exited.catch(() => -1), - ]); - if (controller.signal.aborted) throw new Error(`${label} timed out after ${opts.timeoutMs}ms`); - if (exitCode !== 0) throw new Error(`${label} failed:\n${stderr}`); + return await Promise.race([run(), timeout]); } finally { clearTimeout(timer); } @@ -87,38 +82,33 @@ async function linkProject(projectDir: string, configDir: string): Promise const appId = requireEnv("CLERK_CLI_TEST_APP_ID"); const platformAPIKey = requireEnv("CLERK_PLATFORM_API_KEY"); - await runStep("clerk link", ["bun", CLI_PATH, "--mode", "human", "link", "--app", appId], { - cwd: projectDir, - // PATH lets Bun.spawn resolve `bun`; the rest of the env stays isolated. - env: { - PATH: process.env.PATH, + const result = await Bun.$`bun ${CLI_PATH} --mode human link --app ${appId}` + .cwd(projectDir) + .env({ CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, - }, - timeoutMs: 60_000, - }); + }) + .quiet() + .nothrow(); + + assertSuccess("clerk link failed", result); } async function gitInit(projectDir: string): Promise { - await runStep( - "git init", - [ - "bash", - "-c", - 'git -c commit.gpgsign=false init && git add -A && git -c commit.gpgsign=false commit -m "init" --allow-empty', - ], - { - cwd: projectDir, - env: { + const result = + await Bun.$`git -c commit.gpgsign=false init && git add -A && git -c commit.gpgsign=false commit -m "init" --allow-empty` + .cwd(projectDir) + .env({ ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@test.com", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@test.com", - }, - timeoutMs: 60_000, - }, - ); + }) + .quiet() + .nothrow(); + + assertSuccess("git init failed", result); } /** @@ -129,19 +119,16 @@ async function gitInit(projectDir: string): Promise { async function runClerkInit(projectDir: string, configDir: string): Promise { const platformAPIKey = requireEnv("CLERK_PLATFORM_API_KEY"); - await runStep( - "clerk init", - ["bun", CLI_PATH, "--mode", "human", "init", "--yes", "--no-skills"], - { - cwd: projectDir, - env: { - PATH: process.env.PATH, - CLERK_CONFIG_DIR: configDir, - CLERK_PLATFORM_API_KEY: platformAPIKey, - }, - timeoutMs: 90_000, - }, - ); + const result = await Bun.$`bun ${CLI_PATH} --mode human init --yes --no-skills` + .cwd(projectDir) + .env({ + CLERK_CONFIG_DIR: configDir, + CLERK_PLATFORM_API_KEY: platformAPIKey, + }) + .quiet() + .nothrow(); + + assertSuccess("clerk init failed", result); } /** Parse env files written by clerk init into a merged Record. @@ -194,9 +181,10 @@ export async function setupFixture(name: FixtureName): Promise { try { // Git-init before linking so the profile key matches for later commands - await gitInit(projectDir); - await linkProject(projectDir, configDir); - await runClerkInit(projectDir, configDir); + await withStepTimeout("git init", 60_000, () => gitInit(projectDir)); + // clerk link/init budgets back up the CLI's own per-request fetch timeout. + await withStepTimeout("clerk link", 60_000, () => linkProject(projectDir, configDir)); + await withStepTimeout("clerk init", 90_000, () => runClerkInit(projectDir, configDir)); const envVars = await parseEnvFiles(projectDir); @@ -212,11 +200,14 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error(`${secretKeyName} not found in env files written by clerk init.`); } - await runStep( - "npm ci", - ["npm", "ci", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"], - { cwd: projectDir, env: process.env, timeoutMs: 240_000 }, + // --no-audit/--no-fund drop npm's advisory network round-trips during `ci`. + const install = await withStepTimeout("npm ci", 240_000, () => + Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + .cwd(projectDir) + .quiet() + .nothrow(), ); + assertSuccess("npm ci failed", install); } catch (err) { await safeRm(projectDir); await safeRm(configDir); From e3bcc0071bca3dcadefea1dae84ed236cc7ad119 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:08:22 -0300 Subject: [PATCH 04/42] fix(e2e): drop per-step setup timeouts (revert Bun.spawn deadlock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bun.spawn `runStep` rewrite (5ce158a) regressed CI. `clerk init` runs an internal `npm install` with inherited stderr (init/heuristics.ts installSdk), so when the per-step AbortSignal SIGKILLed the CLI, the npm grandchild survived holding the stderr pipe open — `new Response(proc.stderr).text()` never EOF'd, the timeout never threw, and the 300s beforeAll fired instead. 3 fixtures hung. Root realization: `clerk init` and `npm ci` do package installs whose duration scales with CI contention, so any fixed per-step budget false-fails under load (clerk init blew past its 90s budget in the failing run). You can't fix contention-driven flakiness by capping variable-duration install work tighter. Fix: remove per-step timeouts entirely. The real root-cause fix — the 60s loggedFetch timeout — still bounds the only thing that can truly hang (network calls); `--parallel=4` cuts contention; the 300s beforeAll is the backstop. Setup steps return to plain Bun.$ (as on main). Removes runStep and its test. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- test/e2e/lib/fixture-setup.ts | 40 +++++++++++------------------------ 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 7da1ef6c..b98345bf 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -56,23 +56,6 @@ async function safeRm(path: string): Promise { } } -/** - * Run a setup step under a hard timeout so a stall fails fast with a labeled - * error instead of silently burning the whole 300s `beforeAll` budget. The - * subprocess is left to settle on timeout — `beforeAll` is never retried. - */ -async function withStepTimeout(label: string, ms: number, run: () => Promise): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - try { - return await Promise.race([run(), timeout]); - } 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 @@ -180,11 +163,14 @@ export async function setupFixture(name: FixtureName): Promise { let secretKey = ""; try { - // Git-init before linking so the profile key matches for later commands - await withStepTimeout("git init", 60_000, () => gitInit(projectDir)); - // clerk link/init budgets back up the CLI's own per-request fetch timeout. - await withStepTimeout("clerk link", 60_000, () => linkProject(projectDir, configDir)); - await withStepTimeout("clerk init", 90_000, () => runClerkInit(projectDir, configDir)); + // Git-init before linking so the profile key matches for later commands. + // These steps install packages (clerk init / npm ci) whose duration scales + // with CI load, so they're not given fixed per-step timeouts; the loggedFetch + // request timeout bounds the only thing that can truly hang (the network + // calls), and the 300s beforeAll is the outer backstop. + await gitInit(projectDir); + await linkProject(projectDir, configDir); + await runClerkInit(projectDir, configDir); const envVars = await parseEnvFiles(projectDir); @@ -201,12 +187,10 @@ export async function setupFixture(name: FixtureName): Promise { } // --no-audit/--no-fund drop npm's advisory network round-trips during `ci`. - const install = await withStepTimeout("npm ci", 240_000, () => - Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` - .cwd(projectDir) - .quiet() - .nothrow(), - ); + const install = await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` + .cwd(projectDir) + .quiet() + .nothrow(); assertSuccess("npm ci failed", install); } catch (err) { await safeRm(projectDir); From 5cd16363ac77a1c370a1e3b4a666b6f4f7a29187 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:24:25 -0300 Subject: [PATCH 05/42] fix(e2e): bound npm ci fetch timeout to stop 300s setup hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining flake is npm, not the CLI. `npm ci`'s default `fetch-timeout` is 300000ms — identical to the test's 300s beforeAll budget — so a single stalled npm registry connection hangs setup until the hook times out. (clerk init's installSdk skips here because the isolated env has no PATH, so npm ci is the only unbounded npm install.) - npm ci: add --fetch-timeout=60000 --fetch-retries=5 so a stalled fetch aborts at 60s and retries, mirroring the CLI's loggedFetch timeout. - Restore the debug-gated git/link/init/npm step markers so any residual hang names the exact step instead of an opaque "hook timed out". Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- test/e2e/lib/fixture-setup.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index b98345bf..36e6190f 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -158,19 +158,21 @@ export async function setupFixture(name: FixtureName): Promise { const projectDir = await mkdtemp(join(tmp, `clerk-e2e-${name}-`)); const configDir = await mkdtemp(join(tmp, "clerk-e2e-config-")); await copyFixture(fixtureDir, projectDir); + log("fixture copied"); let publishableKey = ""; let secretKey = ""; try { // Git-init before linking so the profile key matches for later commands. - // These steps install packages (clerk init / npm ci) whose duration scales - // with CI load, so they're not given fixed per-step timeouts; the loggedFetch - // request timeout bounds the only thing that can truly hang (the network - // calls), and the 300s beforeAll is the outer backstop. + // Step markers are debug-gated (CLERK_E2E_DEBUG) and pinpoint which step + // stalls if setup ever hits the 300s beforeAll budget. await gitInit(projectDir); + log("git init done"); await linkProject(projectDir, configDir); + log("clerk link done"); await runClerkInit(projectDir, configDir); + log("clerk init done"); const envVars = await parseEnvFiles(projectDir); @@ -186,12 +188,17 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error(`${secretKeyName} not found in env files written by clerk init.`); } - // --no-audit/--no-fund drop npm's advisory network round-trips during `ci`. - const install = await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` - .cwd(projectDir) - .quiet() - .nothrow(); + // npm's default fetch-timeout is 300s — the same as the beforeAll budget — + // so a single stalled registry connection hangs setup until the test times + // out. Bound each fetch to 60s and retry, mirroring the CLI's own request + // timeout. --no-audit/--no-fund drop npm's advisory network round-trips. + const install = + await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund --fetch-timeout=60000 --fetch-retries=5` + .cwd(projectDir) + .quiet() + .nothrow(); assertSuccess("npm ci failed", install); + log("npm ci done"); } catch (err) { await safeRm(projectDir); await safeRm(configDir); From beac04379eb514f71779cbad31f02f3f5022e560 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:27:04 -0300 Subject: [PATCH 06/42] fix(e2e): cap npm fetch-timeout so a stalled install can't hang setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent 300s beforeAll hang was npm, not the CLI. npm's default fetch-timeout is 300000ms, so one stalled registry connection during either npm operation in setup blocks until the test budget expires. The previous commit bounded `npm ci` but missed the other one: `clerk init` runs an internal `npm install @clerk/` (installSdk), which was still unbounded — that's what hung the Vue fixture at 300007ms. Write a project `.npmrc` (fetch-timeout=30s, fetch-retries=3) before any npm runs. Both `clerk init`'s install and `npm ci` use projectDir as cwd, so it covers both: a stalled fetch now aborts in 30s and retries on a fresh connection instead of waiting 5 minutes. Worst case ~120s, safely under the 300s budget. Drops the redundant per-command npm flags. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- test/e2e/lib/fixture-setup.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 36e6190f..8529a3a1 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=30000\nfetch-retries=3\nfetch-retry-mintimeout=1000\nfetch-retry-maxtimeout=10000\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 @@ -158,6 +172,7 @@ export async function setupFixture(name: FixtureName): Promise { 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 = ""; @@ -188,15 +203,12 @@ export async function setupFixture(name: FixtureName): Promise { throw new Error(`${secretKeyName} not found in env files written by clerk init.`); } - // npm's default fetch-timeout is 300s — the same as the beforeAll budget — - // so a single stalled registry connection hangs setup until the test times - // out. Bound each fetch to 60s and retry, mirroring the CLI's own request - // timeout. --no-audit/--no-fund drop npm's advisory network round-trips. - const install = - await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund --fetch-timeout=60000 --fetch-retries=5` - .cwd(projectDir) - .quiet() - .nothrow(); + // fetch-timeout/retries come from the project .npmrc (writeNpmrc); --no-audit + // and --no-fund drop npm's advisory network round-trips during `ci`. + 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) { From a2fca892d68cac996af0528ce84bccb7bfe99b00 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:40:24 -0300 Subject: [PATCH 07/42] fix(e2e): serialize fixtures to stop Bun.$ subprocess stalls under load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Across four CI runs the 300s beforeAll hang moved randomly between fixtures AND steps — including `git init`, a local, near-instant, near-silent command. That rules out npm, the network, loggedFetch and the earlier Bun.spawn pipe deadlock: the only thing that explains a trivial `git` subprocess hanging 300s intermittently and only under `--parallel` is Bun.$ subprocess spawning/reaping stalling under high concurrent load (each of 4 workers spawns git + 2 `bun` CLIs + npm + a dev server + chromium at once). Run fixtures serially (`--parallel=1`, still isolated) so at most one fixture's subprocesses run at a time. Bump the E2E job timeout 30->45m for the slower serial run. Keeps the .npmrc fetch-timeout and loggedFetch fixes. Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- .github/workflows/ci.yml | 4 +++- package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e06e6b97..52e57513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,9 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 container: image: mcr.microsoft.com/playwright:v1.60.0-noble - timeout-minutes: 30 + # Fixtures run serially (test:e2e --parallel=1) to avoid Bun.$ subprocess + # stalls under concurrency, so allow more wall-clock than the parallel run. + timeout-minutes: 45 steps: - name: Install unzip (required by setup-bun) run: apt-get update && apt-get install -y unzip diff --git a/package.json b/package.json index 2656a0a2..57af27f6 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=4 --only-failures", + "test:e2e": "bun test 'test/e2e/' --retry 1 --parallel=1 --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", From cef0d3c94e9498d193376f54bd20ee98115d3fcc Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 20:53:36 -0300 Subject: [PATCH 08/42] fix(e2e): retry clerk link/init on non-fetch stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializing fixtures fixed the contention-driven setup hangs, but exposed a second, independent flake: `clerk link` (and `init`) intermittently hang ~300s in a non-fetch path the CLI's loggedFetch timeout can't bound — in human mode they shell out to git and can stall on a git subprocess or prompt. It lands on a different fixture each run, so it's transient, not deterministic. Wrap both CLI steps in withRetry: a stall trips a hard timeout (90s/120s, above loggedFetch's 60s so genuinely-slow API calls aren't pre-empted) and the retry runs a fresh subprocess. Promise.race abandons the hung process (no stream deadlock); beforeAll isn't retried so the orphan can't cascade. --- test/e2e/lib/fixture-setup.ts | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 8529a3a1..a3a82309 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -70,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 @@ -184,9 +214,11 @@ export async function setupFixture(name: FixtureName): Promise { // stalls if setup ever hits the 300s beforeAll budget. await gitInit(projectDir); log("git init done"); - 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"); const envVars = await parseEnvFiles(projectDir); From aa14b3e3a96a563cf84e887cf2ab7f2d57e69abd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 21:38:24 -0300 Subject: [PATCH 09/42] fix(e2e): wrap all setup steps in retry; restore parallel run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the setup against the intermittent Bun.$ subprocess stall (a spawned git/clerk/npm step occasionally never resolves — verified a Promise.race timeout still fires during the hang, so a retry recovers it). - withRetry now wraps every step: git init, clerk link, clerk init, npm ci. A hung attempt is abandoned at its budget and a fresh subprocess retried. - Tighten the project .npmrc (fetch-timeout 30s->20s, retries 3->2) so a real npm stall resolves well under the step budgets and can't false-trip them. - Restore --parallel=4 (retry absorbs the higher hang frequency) and revert the E2E job timeout to 30m. Keeps the loggedFetch 60s request timeout (bounds the CLI's own API calls). Claude-Session: https://claude.ai/code/session_01V1YkHZ2Ad1okwkX9bxTYsd --- .github/workflows/ci.yml | 4 +--- package.json | 2 +- test/e2e/lib/fixture-setup.ts | 22 ++++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52e57513..e06e6b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,9 +99,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 container: image: mcr.microsoft.com/playwright:v1.60.0-noble - # Fixtures run serially (test:e2e --parallel=1) to avoid Bun.$ subprocess - # stalls under concurrency, so allow more wall-clock than the parallel run. - timeout-minutes: 45 + timeout-minutes: 30 steps: - name: Install unzip (required by setup-bun) run: apt-get update && apt-get install -y unzip diff --git a/package.json b/package.json index 57af27f6..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=1 --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/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index a3a82309..0c849312 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -45,14 +45,14 @@ async function copyFixture(fixtureDir: string, projectDir: string): Promise { await Bun.write( join(projectDir, ".npmrc"), - "fetch-timeout=30000\nfetch-retries=3\nfetch-retry-mintimeout=1000\nfetch-retry-maxtimeout=10000\n", + "fetch-timeout=20000\nfetch-retries=2\nfetch-retry-mintimeout=1000\nfetch-retry-maxtimeout=8000\n", ); } @@ -212,7 +212,7 @@ export async function setupFixture(name: FixtureName): Promise { // 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 gitInit(projectDir); + await withRetry("git init", 30_000, () => gitInit(projectDir)); log("git init done"); // 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. @@ -237,11 +237,13 @@ export async function setupFixture(name: FixtureName): Promise { // fetch-timeout/retries come from the project .npmrc (writeNpmrc); --no-audit // and --no-fund drop npm's advisory network round-trips during `ci`. - const install = await Bun.$`npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund` - .cwd(projectDir) - .quiet() - .nothrow(); - assertSuccess("npm ci failed", install); + 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); From d0bf0ebe0f6c291dbff191c290a14cff74ed9d92 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 23 Jun 2026 13:09:58 -0300 Subject: [PATCH 10/42] fix(e2e): link in agent mode so the retry is idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry on `clerk link` was making things worse: attempt 1 writes the profile then the process intermittently hangs (a lingering handle after setProfile, not a fetch — confirmed AbortSignal.timeout is unref'd), so withRetry kills it at 90s; attempt 2 then ran `clerk link --mode human` on the now-linked project, hit the interactive "re-link?" confirm prompt, and failed with "Already linked" (3/3 rerun sample failed this way). Run link in `--mode agent`: on an already-linked project it prints status and exits 0 instead of prompting, so the retry's second attempt succeeds. `clerk init` is already idempotent on re-run ("Clerk is already set up" -> exit 0). --- test/e2e/lib/fixture-setup.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 0c849312..2b06df9b 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -109,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, From f9f6e49dacc06593d267ed67b565b5b7b01d404f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:12:45 -0300 Subject: [PATCH 11/42] feat(webhooks): add webhook error codes --- packages/cli-core/src/lib/errors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..025b2fbf 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,12 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** Referenced webhook endpoint not found. */ + WEBHOOK_ENDPOINT_NOT_FOUND: "webhook_endpoint_not_found", + /** Referenced webhook message (delivery) not found. */ + WEBHOOK_MESSAGE_NOT_FOUND: "webhook_message_not_found", + /** Event type is not in the instance's event-type catalog. */ + UNKNOWN_EVENT_TYPE: "unknown_event_type", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 3604b45a1376360dfc3064542ac88345a0df3983 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:13:56 -0300 Subject: [PATCH 12/42] feat(webhooks): persist per-instance relay state in CLI config --- packages/cli-core/src/lib/config.test.ts | 33 ++++++++++++++++++++++++ packages/cli-core/src/lib/config.ts | 25 +++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..49617511 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -16,6 +16,8 @@ const { resolveInstanceId, resolveAppContext, resolveFetchedApplicationInstance, + getRelayEntry, + setRelayEntry, _setConfigDir, } = await import("./config.ts"); type Profile = @@ -71,6 +73,37 @@ describe("config", () => { expect(result.auth).toEqual({ production: { userId: "user_legacy" } }); }); + test("getRelayEntry returns undefined when nothing is persisted", async () => { + expect(await getRelayEntry("ins_123")).toBeUndefined(); + }); + + test("setRelayEntry and getRelayEntry roundtrip per instance", async () => { + await setRelayEntry("ins_a", { token: "Ab12Cd34Ef" }); + await setRelayEntry("ins_b", { token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "Ab12Cd34Ef" }); + expect(await getRelayEntry("ins_b")).toEqual({ token: "Zz98Yy76Xx", endpoint_id: "ep_1" }); + }); + + test("setRelayEntry overwrites only the targeted instance", async () => { + await setRelayEntry("ins_a", { token: "tokenAAAAA" }); + await setRelayEntry("ins_a", { token: "tokenAAAAA", endpoint_id: "ep_2" }); + + expect(await getRelayEntry("ins_a")).toEqual({ token: "tokenAAAAA", endpoint_id: "ep_2" }); + }); + + test("readConfig preserves relay through the legacy-auth migration", async () => { + const legacyConfig = { + auth: { userId: "user_legacy" }, + profiles: {}, + relay: { ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }, + }; + await Bun.write(`${tempDir}/config.json`, JSON.stringify(legacyConfig)); + + const result = await readConfig(); + expect(result.relay).toEqual({ ins_123: { token: "Ab12Cd34Ef", endpoint_id: "ep_9" } }); + }); + test("setAuth and getAuth", async () => { expect(await getAuth()).toBeUndefined(); await setAuth({ userId: "user_456" }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..b36729ac 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -45,10 +45,17 @@ export function profileLabel(profile: Profile): string { return profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId; } +/** Persisted Svix relay state for `clerk webhooks listen`, keyed by instance ID. */ +interface RelayEntry { + token: string; + endpoint_id?: string; +} + interface ClerkConfig { environment?: string; auth?: Record; profiles: Record; + relay?: Record; } function defaultConfig(): ClerkConfig { @@ -65,6 +72,10 @@ function migrateRawConfig(raw: Record): ClerkConfig { profiles: (raw.profiles as Record) ?? {}, }; + if (raw.relay && typeof raw.relay === "object") { + config.relay = raw.relay as Record; + } + if (raw.auth && typeof raw.auth === "object") { const auth = raw.auth as Record; if (typeof auth.userId === "string") { @@ -169,6 +180,18 @@ export async function moveProfile(oldKey: string, newKey: string): Promise await writeConfig(config); } +export async function getRelayEntry(instanceId: string): Promise { + const config = await readConfig(); + return config.relay?.[instanceId]; +} + +export async function setRelayEntry(instanceId: string, entry: RelayEntry): Promise { + const config = await readConfig(); + if (!config.relay) config.relay = {}; + config.relay[instanceId] = entry; + await writeConfig(config); +} + export async function listProfiles(): Promise> { const config = await readConfig(); return config.profiles; @@ -362,4 +385,4 @@ export async function resolveAppContext( }; } -export type { Auth, Profile, ClerkConfig, AppContextOptions }; +export type { Auth, Profile, ClerkConfig, AppContextOptions, RelayEntry }; From b46e411080a758bbc69dae66912ea21edc53689f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:16:07 -0300 Subject: [PATCH 13/42] feat(webhooks): add typed PLAPI client functions for the 13 webhook routes --- packages/cli-core/src/lib/plapi.test.ts | 235 ++++++++++++++++++++++++ packages/cli-core/src/lib/plapi.ts | 229 +++++++++++++++++++++++ 2 files changed, 464 insertions(+) diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8..b205084a 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,6 +19,19 @@ const { getApplicationDomainStatus, triggerApplicationDomainDNSCheck, listApplicationDomains, + listWebhookEndpoints, + getWebhookEndpoint, + createWebhookEndpoint, + updateWebhookEndpoint, + deleteWebhookEndpoint, + getWebhookEndpointSecret, + rotateWebhookEndpointSecret, + listWebhookEventTypes, + listWebhookMessages, + resendWebhookMessage, + recoverWebhookMessages, + sendWebhookExample, + getWebhookPortalUrl, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -563,3 +576,225 @@ describe("plapi", () => { }); }); }); + +describe("plapi webhooks", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + type CapturedRequest = { + url: URL; + method: string; + body: string | undefined; + contentType: string | null; + }; + + let captured: CapturedRequest[]; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "test_key_123"; + captured = []; + stubFetch(async (input, init) => { + captured.push({ + url: new URL(input.toString()), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : undefined, + contentType: new Headers(init?.headers).get("Content-Type"), + }); + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + const PREFIX = "/v1/platform/applications/app_1/instances/ins_1/webhooks"; + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1"), + method: "GET", + path: PREFIX, + body: undefined as string | undefined, + }, + { + name: "getWebhookEndpoint", + call: () => getWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "createWebhookEndpoint", + call: () => + createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }), + method: "POST", + path: PREFIX, + body: '{"url":"https://example.com/webhooks","version":1}', + }, + { + name: "updateWebhookEndpoint", + call: () => updateWebhookEndpoint("app_1", "ins_1", "ep_1", { description: "d" }), + method: "PATCH", + path: `${PREFIX}/ep_1`, + body: '{"description":"d"}', + }, + { + name: "deleteWebhookEndpoint", + call: () => deleteWebhookEndpoint("app_1", "ins_1", "ep_1"), + method: "DELETE", + path: `${PREFIX}/ep_1`, + body: undefined, + }, + { + name: "getWebhookEndpointSecret", + call: () => getWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/secret`, + body: undefined, + }, + { + name: "rotateWebhookEndpointSecret", + call: () => rotateWebhookEndpointSecret("app_1", "ins_1", "ep_1"), + method: "POST", + path: `${PREFIX}/ep_1/secret/rotate`, + body: undefined, + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1"), + method: "GET", + path: `${PREFIX}/event_types`, + body: undefined, + }, + { + name: "listWebhookMessages", + call: () => listWebhookMessages("app_1", "ins_1", "ep_1"), + method: "GET", + path: `${PREFIX}/ep_1/messages`, + body: undefined, + }, + { + name: "resendWebhookMessage", + call: () => resendWebhookMessage("app_1", "ins_1", "ep_1", "msg_1"), + method: "POST", + path: `${PREFIX}/ep_1/messages/msg_1/resend`, + body: undefined, + }, + { + name: "recoverWebhookMessages", + call: () => + recoverWebhookMessages("app_1", "ins_1", "ep_1", { since: "2026-05-01T00:00:00Z" }), + method: "POST", + path: `${PREFIX}/ep_1/recover`, + body: '{"since":"2026-05-01T00:00:00Z"}', + }, + { + name: "sendWebhookExample", + call: () => sendWebhookExample("app_1", "ins_1", "ep_1", "user.created"), + method: "POST", + path: `${PREFIX}/ep_1/send_example`, + body: '{"event_type":"user.created"}', + }, + { + name: "getWebhookPortalUrl", + call: () => getWebhookPortalUrl("app_1", "ins_1"), + method: "POST", + path: `${PREFIX}/url`, + body: "{}", + }, + ])("$name sends $method $path", async ({ call, method, path, body }) => { + await call(); + + expect(captured).toHaveLength(1); + const request = captured[0]!; + expect(request.method).toBe(method); + expect(request.url.pathname).toBe(path); + expect(request.body).toBe(body); + expect(request.contentType).toBe(body === undefined ? null : "application/json"); + }); + + test.each([ + { + name: "listWebhookEndpoints", + call: () => listWebhookEndpoints("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookEventTypes", + call: () => listWebhookEventTypes("app_1", "ins_1", { limit: 50, iterator: "iter_abc" }), + }, + { + name: "listWebhookMessages", + call: () => + listWebhookMessages("app_1", "ins_1", "ep_1", { limit: 50, iterator: "iter_abc" }), + }, + ])("$name translates --iterator to the starting_after query param", async ({ call }) => { + await call(); + + const url = captured[0]!.url; + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.get("starting_after")).toBe("iter_abc"); + expect(url.searchParams.has("iterator")).toBe(false); + }); + + test("list functions omit pagination params when not provided", async () => { + await listWebhookEndpoints("app_1", "ins_1"); + + const url = captured[0]!.url; + expect(url.search).toBe(""); + }); + + test("listWebhookMessages forwards the status filter", async () => { + await listWebhookMessages("app_1", "ins_1", "ep_1", { status: "fail" }); + + expect(captured[0]!.url.searchParams.get("status")).toBe("fail"); + }); + + test("recoverWebhookMessages includes until only when provided", async () => { + await recoverWebhookMessages("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + + expect(captured[0]!.body).toBe( + '{"since":"2026-05-01T00:00:00Z","until":"2026-05-01T01:00:00Z"}', + ); + }); + + test("createWebhookEndpoint serializes optional fields", async () => { + await createWebhookEndpoint("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + + expect(JSON.parse(captured[0]!.body!)).toEqual({ + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created"], + channels: ["project-123"], + }); + }); + + test("throws PlapiError on non-ok responses", async () => { + stubFetch( + async () => new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { status: 404 }), + ); + + await expect(getWebhookEndpoint("app_1", "ins_1", "ep_missing")).rejects.toBeInstanceOf( + PlapiError, + ); + }); +}); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 53f78ba2..1a5db643 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -331,3 +331,232 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +// ── Webhooks (instance-scoped /webhooks routes) ────────────────────────── + +export type WebhookEndpoint = { + id: string; + url: string; + version: number; + description?: string; + disabled: boolean; + filter_types?: string[] | null; + channels?: string[] | null; + created_at: string; + updated_at: string; +}; + +export type WebhookCursor = { + starting_after: string | null; + ending_before: string | null; + has_next_page: boolean; +}; + +export type WebhookEndpointList = { + data: WebhookEndpoint[]; + cursor: WebhookCursor; +}; + +export type WebhookEventType = { + name: string; + description?: string; + archived: boolean; + created_at: string; + updated_at: string; +}; + +export type WebhookEventTypeList = { + data: WebhookEventType[]; + cursor: WebhookCursor; +}; + +export const WEBHOOK_MESSAGE_STATUSES = ["success", "pending", "fail", "sending"] as const; +export type WebhookMessageStatus = (typeof WEBHOOK_MESSAGE_STATUSES)[number]; + +export type WebhookMessage = { + id: string; + event_type: string; + status: WebhookMessageStatus; + next_attempt: string | null; + payload: unknown; + created_at: string; +}; + +export type WebhookMessageList = { + data: WebhookMessage[]; + cursor: WebhookCursor; +}; + +export type CreateWebhookEndpointParams = { + url: string; + version: 1; + description?: string; + disabled?: boolean; + filter_types?: string[]; + channels?: string[]; +}; + +export type UpdateWebhookEndpointParams = Partial; + +export type WebhookPageParams = { + limit?: number; + iterator?: string; +}; + +function webhooksUrl(applicationId: string, instanceId: string, path = ""): URL { + return new URL( + `/v1/platform/applications/${applicationId}/instances/${instanceId}/webhooks${path}`, + getPlapiBaseUrl(), + ); +} + +/** + * The CLI flag is `--iterator` (the Svix pagination concept); the wire query + * param is Clerk's cursor convention `starting_after`. The translation lives + * here so commands never see the wire name. + */ +function appendPageParams(url: URL, params?: WebhookPageParams): void { + if (typeof params?.limit === "number") { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.iterator) { + url.searchParams.set("starting_after", params.iterator); + } +} + +export async function listWebhookEndpoints( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function getWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createWebhookEndpoint( + applicationId: string, + instanceId: string, + params: CreateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId); + const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function updateWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, + params: UpdateWebhookEndpointParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + const response = await plapiFetch("PATCH", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function deleteWebhookEndpoint( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}`); + await plapiFetch("DELETE", url); +} + +export async function getWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise<{ secret: string }> { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret`); + const response = await plapiFetch("GET", url); + return response.json() as Promise<{ secret: string }>; +} + +export async function rotateWebhookEndpointSecret( + applicationId: string, + instanceId: string, + endpointId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/secret/rotate`); + await plapiFetch("POST", url); +} + +export async function listWebhookEventTypes( + applicationId: string, + instanceId: string, + params?: WebhookPageParams, +): Promise { + const url = webhooksUrl(applicationId, instanceId, "/event_types"); + appendPageParams(url, params); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function listWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + params?: WebhookPageParams & { status?: WebhookMessageStatus }, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages`); + appendPageParams(url, params); + if (params?.status) { + url.searchParams.set("status", params.status); + } + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function resendWebhookMessage( + applicationId: string, + instanceId: string, + endpointId: string, + messageId: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages/${messageId}/resend`); + await plapiFetch("POST", url); +} + +export async function recoverWebhookMessages( + applicationId: string, + instanceId: string, + endpointId: string, + window: { since: string; until?: string }, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/recover`); + const body: { since: string; until?: string } = { since: window.since }; + if (window.until) body.until = window.until; + await plapiFetch("POST", url, { body: JSON.stringify(body) }); +} + +export async function sendWebhookExample( + applicationId: string, + instanceId: string, + endpointId: string, + eventType: string, +): Promise { + const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/send_example`); + await plapiFetch("POST", url, { body: JSON.stringify({ event_type: eventType }) }); +} + +export async function getWebhookPortalUrl( + applicationId: string, + instanceId: string, +): Promise<{ url: string }> { + const url = webhooksUrl(applicationId, instanceId, "/url"); + const response = await plapiFetch("POST", url, { body: JSON.stringify({}) }); + return response.json() as Promise<{ url: string }>; +} From 84bb0c3c0745db2691f750d9559d5d449ab2bdbe Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:16:40 -0300 Subject: [PATCH 14/42] feat(webhooks): register webhooks command group with auth preAction gate --- packages/cli-core/src/cli-program.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..164bd070 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 { getAuthToken } from "./lib/plapi.ts"; import { registerExtras } from "@clerk/cli-extras"; /** @@ -143,6 +144,29 @@ export function createProgram(): Program { register(program); } + const webhooks = program + .command("webhooks") + .description("Manage webhook endpoints and deliveries") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk webhooks list", description: "List webhook endpoints" }, + { + command: "clerk webhooks create --url https://example.com/api/webhooks", + description: "Create an endpoint and print its signing secret", + }, + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + ]); + + webhooks.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.name() === "verify") return; // pure offline HMAC, no auth gate + await getAuthToken(); + }); + return program; } From 88e0c343a84316ef51642e169f4579917a458bad Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:19:00 -0300 Subject: [PATCH 15/42] feat(webhooks): add 'webhooks list' command --- packages/cli-core/src/cli-program.ts | 19 +++ .../cli-core/src/commands/webhooks/README.md | 44 +++++ .../cli-core/src/commands/webhooks/index.ts | 5 + .../src/commands/webhooks/list.test.ts | 154 ++++++++++++++++++ .../cli-core/src/commands/webhooks/list.ts | 69 ++++++++ .../cli-core/src/commands/webhooks/shared.ts | 109 +++++++++++++ 6 files changed, 400 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/README.md create mode 100644 packages/cli-core/src/commands/webhooks/index.ts create mode 100644 packages/cli-core/src/commands/webhooks/list.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/list.ts create mode 100644 packages/cli-core/src/commands/webhooks/shared.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 164bd070..f44cb61d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -42,6 +42,7 @@ import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { getAuthToken } from "./lib/plapi.ts"; +import { webhooks as webhooksHandlers } from "./commands/webhooks/index.ts"; import { registerExtras } from "@clerk/cli-extras"; /** @@ -167,6 +168,24 @@ export function createProgram(): Program { 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]), + ); return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md new file mode 100644 index 00000000..5d8c3c34 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -0,0 +1,44 @@ +# Webhooks Commands + +> The 13 PLAPI webhook routes these commands call are being built in parallel in `clerk_go` and may not exist yet in every environment. The CLI is built against the final spec's request/response shapes; unit tests mock the PLAPI layer. + +Manage webhook endpoints and deliveries for the linked instance: CRUD, delivery inspection, local forwarding (`listen`), replay, and offline signature verification. + +## Group-level options + +Inherited by every subcommand via `optsWithGlobals()`: + +| Option | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `--app ` | Application ID to target (works from any directory). | +| `--instance ` | Instance to target (`dev`, `prod`, or a full instance ID). | +| `--json` | Force machine output in a human TTY. Agent mode (`isAgent()`) always behaves as if `--json` were set. | + +Auth: every subcommand except `verify` is gated by a `preAction` hook calling `getAuthToken()` (accepts `ak_` keys or an OAuth session; never `sk_`). `verify` is pure offline HMAC — no auth, and it ignores `--app`/`--instance`. + +Output contract: stdout carries bare domain JSON via `log.data()` (pipeable); stderr carries human UI and, in agent mode, structured error JSON `{"error":{code,message,docsUrl?}}`. No `{ok,data,error}` envelope. Exit codes: 0 success, 1 failure, 2 usage error, 130 SIGINT. + +Pagination: list-shaped commands fetch ONE page (`--limit` 1-250, default 100). When `cursor.has_next_page` is true, the next `--iterator` value is printed as a stderr hint. The `--iterator` flag value is sent on the wire as the `starting_after` query param. + +All routes below are relative to `/v1/platform/applications/{applicationID}/instances/{envOrInsID}`. + +## `clerk webhooks list` + +Lists webhook endpoints for the instance. + +```sh +clerk webhooks list [--limit N] [--iterator C] +``` + +| Option | Description | +| ---------------- | ------------------------------------------------- | +| `--limit ` | Maximum endpoints to return (1-250, default 100). | +| `--iterator ` | Pagination cursor from the previous response. | + +Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode prints the full `{ data, cursor }` response on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------- | ---------------------------------- | +| `GET` | `/webhooks` | List webhook endpoints (one page). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts new file mode 100644 index 00000000..a500ae81 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -0,0 +1,5 @@ +import { webhooksList } from "./list.ts"; + +export const webhooks = { + list: webhooksList, +}; diff --git a/packages/cli-core/src/commands/webhooks/list.test.ts b/packages/cli-core/src/commands/webhooks/list.test.ts new file mode 100644 index 00000000..240f3717 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.test.ts @@ -0,0 +1,154 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEndpoints = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEndpoints: (...args: unknown[]) => mockListWebhookEndpoints(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksList } = await import("./list.ts"); + +const mockEndpoints = [ + { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + id: "ep_2", + url: "https://example.com/other", + version: 1, + disabled: true, + filter_types: null, + channels: null, + created_at: "2026-06-02T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", + }, +]; + +function listResponse(overrides: Partial<{ data: unknown[]; has_next_page: boolean }> = {}) { + return { + data: overrides.data ?? mockEndpoints, + cursor: { + starting_after: "iter_next", + ending_before: null, + has_next_page: overrides.has_next_page ?? false, + }, + }; +} + +describe("webhooks list", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEndpoints.mockResolvedValue(listResponse()); + }); + + afterEach(() => { + mockListWebhookEndpoints.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksList(); + + expect(mockResolveAppContext).toHaveBeenCalledWith({}); + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksList({ limit: 25, iterator: "iter_prev" }); + + expect(mockListWebhookEndpoints).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 25, + iterator: "iter_prev", + }); + }); + + test("forwards --app and --instance to context resolution", async () => { + await webhooksList({ app: "app_2", instance: "prod" }); + + expect(mockResolveAppContext).toHaveBeenCalledWith({ app: "app_2", instance: "prod" }); + }); + + test("prints a human-readable table by default", async () => { + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("disabled"); + expect(captured.err).toContain("2 endpoints returned"); + }); + + test("warns when no endpoints exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ data: [] })); + + await webhooksList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No webhook endpoints found."); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEndpoints.mockResolvedValue(listResponse({ has_next_page: true })); + + await webhooksList(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("omits the pagination hint on the last page", async () => { + await webhooksList(); + + expect(captured.err).not.toContain("--iterator"); + }); + + test("outputs the full list response as JSON with --json", async () => { + await webhooksList({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksList(); + + expect(JSON.parse(captured.out)).toEqual(listResponse()); + expect(captured.err).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/list.ts b/packages/cli-core/src/commands/webhooks/list.ts new file mode 100644 index 00000000..c9987cad --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/list.ts @@ -0,0 +1,69 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEndpoints, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksListOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +const COLUMN_PADDING = 2; + +function endpointStatus(endpoint: WebhookEndpoint): string { + return endpoint.disabled ? "disabled" : "enabled"; +} + +function endpointEvents(endpoint: WebhookEndpoint): string { + return endpoint.filter_types?.length ? endpoint.filter_types.join(",") : "all"; +} + +function formatEndpointsTable(endpoints: WebhookEndpoint[]): void { + const idWidth = Math.max("ID".length, ...endpoints.map((e) => e.id.length)) + COLUMN_PADDING; + const urlWidth = Math.max("URL".length, ...endpoints.map((e) => e.url.length)) + COLUMN_PADDING; + const statusWidth = + Math.max("STATUS".length, ...endpoints.map((e) => endpointStatus(e).length)) + COLUMN_PADDING; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("URL".padEnd(urlWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("EVENTS")}`, + ); + for (const endpoint of endpoints) { + log.info( + `${cyan(endpoint.id.padEnd(idWidth))}${endpoint.url.padEnd(urlWidth)}${endpointStatus(endpoint).padEnd(statusWidth)}${endpointEvents(endpoint)}`, + ); + } +} + +export async function webhooksList(options: WebhooksListOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEndpoints(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook endpoints", + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No webhook endpoints found."); + return; + } + + formatEndpointsTable(response.data); + const count = response.data.length; + log.info(`\n${count} endpoint${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/shared.ts b/packages/cli-core/src/commands/webhooks/shared.ts new file mode 100644 index 00000000..468914a4 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -0,0 +1,109 @@ +import { getRelayEntry } from "../../lib/config.ts"; +import { + CliError, + ERROR_CODE, + PlapiError, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import type { WebhookCursor } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; + +export interface WebhooksGlobalOptions { + app?: string; + instance?: string; + json?: boolean; +} + +export const DEFAULT_PAGE_LIMIT = 100; + +export function shouldOutputJson(options: { json?: boolean }): boolean { + return Boolean(options.json) || isAgent(); +} + +/** Bare domain JSON on stdout — the only stdout writer for webhook commands. */ +export function printJson(data: unknown): void { + log.data(JSON.stringify(data, null, 2)); +} + +/** Stderr hint with the next `--iterator` value. The CLI never auto-paginates. */ +export function printIteratorHint(cursor: WebhookCursor): void { + if (cursor.has_next_page && cursor.starting_after) { + log.info(`More available — re-run with \`--iterator ${cursor.starting_after}\``); + } +} + +/** Map a PLAPI 404 on an endpoint-addressed route to a typed CLI error. */ +export async function rejectEndpointNotFound( + promise: Promise, + endpointId: string, +): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + throw new CliError(`No webhook endpoint with ID ${endpointId} was found.`, { + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + } + throw error; + } +} + +/** Map a PLAPI 404 on a message-addressed route to a typed CLI error. */ +export async function rejectMessageNotFound(promise: Promise, messageId: string): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof PlapiError && error.status === 404) { + throw new CliError(`No webhook message with ID ${messageId} was found.`, { + code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, + }); + } + throw error; + } +} + +/** + * Destructive-command gate: prompt in human mode, require `--yes` in agent + * mode. Declining the prompt aborts cleanly via UserAbortError. + */ +export async function confirmDestructive( + message: string, + options: { yes?: boolean }, +): Promise { + if (options.yes) return; + if (isAgent()) { + throwUsageError("This action requires confirmation. Pass --yes to proceed in agent mode."); + } + const { confirm } = await import("../../lib/prompts.ts"); + const proceed = await confirm({ message, default: false }); + if (!proceed) throwUserAbort(); +} + +/** + * Resolve `--endpoint`, falling back to the instance's persisted relay + * endpoint (`trigger`, `messages`, and `replay ` convenience rule). + */ +export async function resolveEndpointOrRelay( + endpointFlag: string | undefined, + instanceId: string, +): Promise { + if (endpointFlag) return endpointFlag; + const entry = await getRelayEntry(instanceId); + if (entry?.endpoint_id) return entry.endpoint_id; + return throwUsageError( + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + ); +} + +/** Split a comma-separated flag value into trimmed, non-empty entries. */ +export function splitCommaList(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return parts; +} From 9997589733af95590256ba0350054a063e460cad Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:20:03 -0300 Subject: [PATCH 16/42] feat(webhooks): add 'webhooks get' command --- packages/cli-core/src/cli-program.ts | 22 ++++ .../cli-core/src/commands/webhooks/README.md | 16 +++ .../src/commands/webhooks/get.test.ts | 107 ++++++++++++++++++ .../cli-core/src/commands/webhooks/get.ts | 46 ++++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 193 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/get.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/get.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index f44cb61d..937ab622 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -186,6 +186,28 @@ export function createProgram(): Program { .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, + }), + ); +: add 'webhooks get' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 5d8c3c34..1601e178 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -42,3 +42,19 @@ Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode pri | Method | Endpoint | Description | | ------ | ----------- | ---------------------------------- | | `GET` | `/webhooks` | List webhook endpoints (one page). | + +## `clerk webhooks get ` + +Prints one endpoint's configuration. A PLAPI 404 maps to error code `webhook_endpoint_not_found`. + +```sh +clerk webhooks get ep_2abc123 +``` + +Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------ | ------------------- | +| `GET` | `/webhooks/{endpointID}` | Fetch one endpoint. | diff --git a/packages/cli-core/src/commands/webhooks/get.test.ts b/packages/cli-core/src/commands/webhooks/get.test.ts new file mode 100644 index 00000000..1fb3f53b --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.test.ts @@ -0,0 +1,107 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksGet } = await import("./get.ts"); + +const mockEndpoint = { + id: "ep_1", + url: "https://example.com/webhooks", + version: 1, + description: "Primary", + disabled: false, + filter_types: ["user.created", "user.deleted"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", +}; + +describe("webhooks get", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpoint.mockResolvedValue(mockEndpoint); + }); + + afterEach(() => { + mockGetWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches the endpoint by ID", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + }); + + test("prints endpoint details on stderr in human mode", async () => { + await webhooksGet({ endpointId: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("https://example.com/webhooks"); + expect(captured.err).toContain("enabled"); + expect(captured.err).toContain("user.created, user.deleted"); + }); + + test("outputs the bare endpoint resource as JSON with --json", async () => { + await webhooksGet({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksGet({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(mockEndpoint); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + const promise = webhooksGet({ endpointId: "ep_missing" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(webhooksGet({ endpointId: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + message: "No webhook endpoint with ID ep_missing was found.", + }); + }); + + test("re-throws non-404 PLAPI errors untouched", async () => { + const original = new PlapiError(500, "{}"); + mockGetWebhookEndpoint.mockRejectedValue(original); + + await expect(webhooksGet({ endpointId: "ep_1" })).rejects.toBe(original); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts new file mode 100644 index 00000000..68686381 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -0,0 +1,46 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { getWebhookEndpoint, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksGetOptions extends WebhooksGlobalOptions { + endpointId: string; +} + +export function formatEndpointDetails(endpoint: WebhookEndpoint): void { + const rows: Array<[string, string]> = [ + ["ID", cyan(endpoint.id)], + ["URL", endpoint.url], + ["Status", endpoint.disabled ? "disabled" : "enabled"], + ["Description", endpoint.description || dim("(none)")], + ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], + ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], + ["Created", endpoint.created_at], + ["Updated", endpoint.updated_at], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; + for (const [label, value] of rows) { + log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); + } +} + +export async function webhooksGet(options: WebhooksGetOptions): Promise { + const ctx = await resolveAppContext(options); + const endpoint = await rejectEndpointNotFound( + getWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + formatEndpointDetails(endpoint); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index a500ae81..32669a9b 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,5 +1,7 @@ +import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; export const webhooks = { list: webhooksList, + get: webhooksGet, }; From f21be76857aac649565c0c8883869ec1e5b47a3d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:21:06 -0300 Subject: [PATCH 17/42] feat(webhooks): add 'webhooks event-types' command --- packages/cli-core/src/cli-program.ts | 21 +++ .../cli-core/src/commands/webhooks/README.md | 14 ++ .../src/commands/webhooks/event-types.test.ts | 131 ++++++++++++++++++ .../src/commands/webhooks/event-types.ts | 53 +++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 221 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/event-types.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/event-types.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 937ab622..d97bbebb 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -208,6 +208,27 @@ export function createProgram(): Program { }), ); : add 'webhooks get' command) + + 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], + ), + ); +: add 'webhooks event-types' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 1601e178..95bf4b94 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -58,3 +58,17 @@ Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpo | Method | Endpoint | Description | | ------ | ------------------------ | ------------------- | | `GET` | `/webhooks/{endpointID}` | Fetch one endpoint. | + +## `clerk webhooks event-types` + +Lists the Svix event-type catalog for the instance (`--limit`/`--iterator` as in `list`). Archived types are marked in human output. + +```sh +clerk webhooks event-types [--limit N] [--iterator C] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------- | --------------------------------------- | +| `GET` | `/webhooks/event_types` | List the event-type catalog (one page). | diff --git a/packages/cli-core/src/commands/webhooks/event-types.test.ts b/packages/cli-core/src/commands/webhooks/event-types.test.ts new file mode 100644 index 00000000..572c2d76 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.test.ts @@ -0,0 +1,131 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksEventTypes } = await import("./event-types.ts"); + +const mockEventTypes = [ + { + name: "user.created", + description: "A user was created", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + { + name: "session.removed", + description: "A session was removed", + archived: true, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, +]; + +function eventTypesResponse(hasNextPage = false) { + return { + data: mockEventTypes, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks event-types", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse()); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("fetches one page with the default limit", async () => { + await webhooksEventTypes(); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 100, + iterator: undefined, + }); + }); + + test("forwards --limit and --iterator", async () => { + await webhooksEventTypes({ limit: 5, iterator: "iter_prev" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 5, + iterator: "iter_prev", + }); + }); + + test("prints names and descriptions, marking archived types", async () => { + await webhooksEventTypes(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("A user was created"); + expect(captured.err).toContain("session.removed"); + expect(captured.err).toContain("(archived)"); + expect(captured.err).toContain("2 event types returned"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookEventTypes.mockResolvedValue(eventTypesResponse(true)); + + await webhooksEventTypes(); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("warns when the catalog is empty", async () => { + mockListWebhookEventTypes.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksEventTypes(); + + expect(captured.err).toContain("No event types found."); + }); + + test("outputs the full response as JSON with --json", async () => { + await webhooksEventTypes({ json: true }); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksEventTypes(); + + expect(JSON.parse(captured.out)).toEqual(eventTypesResponse()); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/event-types.ts b/packages/cli-core/src/commands/webhooks/event-types.ts new file mode 100644 index 00000000..6abd119a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/event-types.ts @@ -0,0 +1,53 @@ +import { cyan, dim, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, type WebhookEventType } from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksEventTypesOptions extends WebhooksGlobalOptions { + limit?: number; + iterator?: string; +} + +function formatEventTypesTable(eventTypes: WebhookEventType[]): void { + const nameWidth = Math.max("NAME".length, ...eventTypes.map((t) => t.name.length)) + 2; + + log.info(`${dim("NAME".padEnd(nameWidth))}${dim("DESCRIPTION")}`); + for (const eventType of eventTypes) { + const archived = eventType.archived ? ` ${yellow("(archived)")}` : ""; + log.info(`${cyan(eventType.name.padEnd(nameWidth))}${eventType.description ?? ""}${archived}`); + } +} + +export async function webhooksEventTypes(options: WebhooksEventTypesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const response = await withApiContext( + listWebhookEventTypes(ctx.appId, ctx.instanceId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + }), + "Failed to list webhook event types", + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn("No event types found."); + return; + } + + formatEventTypesTable(response.data); + const count = response.data.length; + log.info(`\n${count} event type${count === 1 ? "" : "s"} returned`); + printIteratorHint(response.cursor); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 32669a9b..622aa647 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,7 +1,9 @@ +import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; export const webhooks = { list: webhooksList, get: webhooksGet, + eventTypes: webhooksEventTypes, }; From 8a07affb082089a18e2eb71f95e096ba1ac51c60 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:22:17 -0300 Subject: [PATCH 18/42] feat(webhooks): add 'webhooks secret' command with --rotate --- packages/cli-core/src/cli-program.ts | 31 +++++ .../cli-core/src/commands/webhooks/README.md | 17 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/secret.test.ts | 124 ++++++++++++++++++ .../cli-core/src/commands/webhooks/secret.ts | 50 +++++++ 5 files changed, 224 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/secret.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/secret.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index d97bbebb..938f4603 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -229,6 +229,37 @@ export function createProgram(): Program { ), ); : add 'webhooks event-types' command) + + 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, + }), + ); +: add 'webhooks secret' command with --rotate) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 95bf4b94..2c041012 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -72,3 +72,20 @@ clerk webhooks event-types [--limit N] [--iterator C] | Method | Endpoint | Description | | ------ | ----------------------- | --------------------------------------- | | `GET` | `/webhooks/event_types` | List the event-type catalog (one page). | + +## `clerk webhooks secret ` + +Prints the endpoint's current signing secret. With `--rotate`, rotates first (prompts in human mode; requires `--yes` in agent mode), then prints the new secret. After rotation Svix dual-signs with old+new keys for 24h — the `svix-signature` header carries multiple space-separated entries during the grace window. + +```sh +clerk webhooks secret ep_2abc123 [--rotate [--yes]] +``` + +Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `export CLERK_WEBHOOK_SIGNING_SECRET=$(clerk webhooks secret ep_...)`), with all banners on stderr. JSON/agent mode prints `{ "secret": "whsec_..." }`. Plain `secret ` never prompts; `--yes` is only meaningful with `--rotate`. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | -------------------------------------- | -------------------------------------------- | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the signing secret. | +| `POST` | `/webhooks/{endpointID}/secret/rotate` | Rotate the signing secret (`--rotate` only). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 622aa647..7629ed0a 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,9 +1,11 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksSecret } from "./secret.ts"; export const webhooks = { list: webhooksList, get: webhooksGet, eventTypes: webhooksEventTypes, + secret: webhooksSecret, }; diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts new file mode 100644 index 00000000..2f7bd0fd --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -0,0 +1,124 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookEndpointSecret = mock(); +const mockRotateWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), + rotateWebhookEndpointSecret: (...args: unknown[]) => mockRotateWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksSecret } = await import("./secret.ts"); + +describe("webhooks secret", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_abc123" }); + mockRotateWebhookEndpointSecret.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockGetWebhookEndpointSecret.mockReset(); + mockRotateWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prints the bare secret on stdout in human mode", async () => { + await webhooksSecret({ endpointId: "ep_1" }); + + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("Signing secret for"); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("outputs { secret } as JSON with --json", async () => { + await webhooksSecret({ endpointId: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + expect(captured.err).toBe(""); + }); + + test("outputs { secret } in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual({ secret: "whsec_abc123" }); + }); + + test("--rotate prompts, rotates, then fetches the new secret", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.stdout).toEqual(["whsec_abc123"]); + expect(captured.err).toContain("dual-signs"); + }); + + test("--rotate --yes skips the prompt", async () => { + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + + test("--rotate aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toBeInstanceOf( + UserAbortError, + ); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + }); + + test("--rotate in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksSecret({ endpointId: "ep_1", rotate: true })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + await expect(webhooksSecret({ endpointId: "ep_missing" })).rejects.toBeInstanceOf(CliError); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/secret.ts b/packages/cli-core/src/commands/webhooks/secret.ts new file mode 100644 index 00000000..de9d0978 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -0,0 +1,50 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { getWebhookEndpointSecret, rotateWebhookEndpointSecret } from "../../lib/plapi.ts"; +import { + confirmDestructive, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksSecretOptions extends WebhooksGlobalOptions { + endpointId: string; + rotate?: boolean; + yes?: boolean; +} + +export async function webhooksSecret(options: WebhooksSecretOptions): Promise { + const ctx = await resolveAppContext(options); + + if (options.rotate) { + await confirmDestructive( + `Rotate the signing secret for ${options.endpointId}? The old key keeps verifying for 24h (dual-signing grace).`, + options, + ); + await rejectEndpointNotFound( + rotateWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + } + + const { secret } = await rejectEndpointNotFound( + getWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson({ secret }); + return; + } + + if (options.rotate) { + log.success( + `Signing secret rotated. The previous key remains valid for 24 hours while Svix dual-signs.`, + ); + } + log.info(`Signing secret for \`${options.endpointId}\`:`); + // Bare secret on stdout so $(clerk webhooks secret ep_...) is eval-friendly. + log.data(secret); +} From b80a5eced3b1745efd48360ec98cf995087305a1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:23:20 -0300 Subject: [PATCH 19/42] feat(webhooks): add 'webhooks delete' command --- packages/cli-core/src/cli-program.ts | 23 ++++ .../cli-core/src/commands/webhooks/README.md | 14 +++ .../src/commands/webhooks/delete.test.ts | 101 ++++++++++++++++++ .../cli-core/src/commands/webhooks/delete.ts | 29 +++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 169 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/delete.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/delete.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 938f4603..28a72e12 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -260,6 +260,29 @@ export function createProgram(): Program { }), ); : add 'webhooks secret' command with --rotate) + + 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, + }), + ); +: add 'webhooks delete' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 2c041012..245a81de 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -89,3 +89,17 @@ Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `ex | ------ | -------------------------------------- | -------------------------------------------- | | `GET` | `/webhooks/{endpointID}/secret` | Fetch the signing secret. | | `POST` | `/webhooks/{endpointID}/secret/rotate` | Rotate the signing secret (`--rotate` only). | + +## `clerk webhooks delete ` + +Hard-deletes an endpoint (Svix delete is hard; no shadow table). Prompts in human mode; agent mode requires `--yes` or fails with a usage error (exit 2). Declining the prompt exits cleanly. Success prints a stderr confirmation; stdout stays empty (the route returns `200 {}`). + +```sh +clerk webhooks delete ep_2abc123 [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| -------- | ------------------------ | --------------------------------------- | +| `DELETE` | `/webhooks/{endpointID}` | Delete the endpoint (returns `200 {}`). | diff --git a/packages/cli-core/src/commands/webhooks/delete.test.ts b/packages/cli-core/src/commands/webhooks/delete.test.ts new file mode 100644 index 00000000..928ab6ae --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.test.ts @@ -0,0 +1,101 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockDeleteWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + deleteWebhookEndpoint: (...args: unknown[]) => mockDeleteWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksDelete } = await import("./delete.ts"); + +describe("webhooks delete", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockDeleteWebhookEndpoint.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockDeleteWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test("prompts before deleting in human mode", async () => { + await webhooksDelete({ endpointId: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1"); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Deleted webhook endpoint"); + }); + + test("--yes skips the prompt", async () => { + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toBeInstanceOf(UserAbortError); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(webhooksDelete({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("agent mode with --yes deletes without prompting", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksDelete({ endpointId: "ep_1", yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDeleteWebhookEndpoint).toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockDeleteWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksDelete({ endpointId: "ep_missing", yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts new file mode 100644 index 00000000..61c0480e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -0,0 +1,29 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { deleteWebhookEndpoint } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksDeleteOptions extends WebhooksGlobalOptions { + endpointId: string; + yes?: boolean; +} + +export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { + const ctx = await resolveAppContext(options); + + await confirmDestructive( + `Permanently delete webhook endpoint ${options.endpointId}? This cannot be undone.`, + options, + ); + + await rejectEndpointNotFound( + deleteWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + options.endpointId, + ); + + log.success(`Deleted webhook endpoint \`${options.endpointId}\``); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 7629ed0a..1f50699f 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,3 +1,4 @@ +import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; @@ -8,4 +9,5 @@ export const webhooks = { get: webhooksGet, eventTypes: webhooksEventTypes, secret: webhooksSecret, + delete: webhooksDelete, }; From af44ad6d09e464ca8a4da633e3a27678a05c3ceb Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:24:38 -0300 Subject: [PATCH 20/42] feat(webhooks): add 'webhooks update' command --- packages/cli-core/src/cli-program.ts | 38 +++++ .../cli-core/src/commands/webhooks/README.md | 16 +++ .../cli-core/src/commands/webhooks/get.ts | 22 +-- .../cli-core/src/commands/webhooks/index.ts | 2 + .../cli-core/src/commands/webhooks/shared.ts | 21 ++- .../src/commands/webhooks/update.test.ts | 130 ++++++++++++++++++ .../cli-core/src/commands/webhooks/update.ts | 63 +++++++++ 7 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 packages/cli-core/src/commands/webhooks/update.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/update.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 28a72e12..eac25cf5 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -283,6 +283,44 @@ export function createProgram(): Program { }), ); : add 'webhooks delete' command) + + webhooks + .command("update") + .description("Update a webhook endpoint's configuration") + .argument("", "Webhook endpoint ID (ep_...)") + .option("--url ", "New destination URL") + .option( + "--events ", + "Comma-separated event types to filter on (e.g. user.created,user.deleted)", + ) + .option("--description ", "New description") + .option("--channels ", "Comma-separated channels") + .option("--enable", "Re-enable a disabled endpoint") + .option("--disable", "Disable the endpoint") + .setExamples([ + { + command: "clerk webhooks update ep_2abc123 --url https://example.com/api/webhooks", + description: "Point the endpoint at a new URL", + }, + { + command: "clerk webhooks update ep_2abc123 --events user.created,user.deleted", + description: "Replace the event-type filter", + }, + { + command: "clerk webhooks update ep_2abc123 --enable", + description: "Re-enable an endpoint", + }, + ]) + .action((endpointId, _opts, cmd) => + webhooksHandlers.update({ + ...(cmd.optsWithGlobals() as Omit< + Parameters[0], + "endpointId" + >), + endpointId, + }), + ); +: add 'webhooks update' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 245a81de..72e5d36e 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -103,3 +103,19 @@ clerk webhooks delete ep_2abc123 [--yes] | Method | Endpoint | Description | | -------- | ------------------------ | --------------------------------------- | | `DELETE` | `/webhooks/{endpointID}` | Delete the endpoint (returns `200 {}`). | + +## `clerk webhooks update ` + +Patches endpoint fields. Only the flags you pass are sent; everything else is omitted from the PATCH body. `--enable` maps to `{disabled: false}`, `--disable` to `{disabled: true}` (mutually exclusive; `--disabled` exists only on `create`). Passing no update flags is a usage error. + +```sh +clerk webhooks update ep_2abc123 [--url ...] [--events a,b] [--description ] [--channels a,b] [--enable | --disable] +``` + +Human mode prints the updated endpoint's details on stderr. JSON mode prints the updated endpoint resource on stdout. + +### API endpoints + +| Method | Endpoint | Description | +| ------- | ------------------------ | ---------------------- | +| `PATCH` | `/webhooks/{endpointID}` | Patch endpoint fields. | diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts index 68686381..c364346a 100644 --- a/packages/cli-core/src/commands/webhooks/get.ts +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -1,8 +1,7 @@ -import { cyan, dim } from "../../lib/color.ts"; import { resolveAppContext } from "../../lib/config.ts"; -import { log } from "../../lib/log.ts"; -import { getWebhookEndpoint, type WebhookEndpoint } from "../../lib/plapi.ts"; +import { getWebhookEndpoint } from "../../lib/plapi.ts"; import { + formatEndpointDetails, printJson, rejectEndpointNotFound, shouldOutputJson, @@ -13,23 +12,6 @@ export interface WebhooksGetOptions extends WebhooksGlobalOptions { endpointId: string; } -export function formatEndpointDetails(endpoint: WebhookEndpoint): void { - const rows: Array<[string, string]> = [ - ["ID", cyan(endpoint.id)], - ["URL", endpoint.url], - ["Status", endpoint.disabled ? "disabled" : "enabled"], - ["Description", endpoint.description || dim("(none)")], - ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], - ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], - ["Created", endpoint.created_at], - ["Updated", endpoint.updated_at], - ]; - const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; - for (const [label, value] of rows) { - log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); - } -} - export async function webhooksGet(options: WebhooksGetOptions): Promise { const ctx = await resolveAppContext(options); const endpoint = await rejectEndpointNotFound( diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 1f50699f..17141e66 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksSecret } from "./secret.ts"; +import { webhooksUpdate } from "./update.ts"; export const webhooks = { list: webhooksList, @@ -10,4 +11,5 @@ export const webhooks = { eventTypes: webhooksEventTypes, secret: webhooksSecret, delete: webhooksDelete, + update: webhooksUpdate, }; diff --git a/packages/cli-core/src/commands/webhooks/shared.ts b/packages/cli-core/src/commands/webhooks/shared.ts index 468914a4..881853f2 100644 --- a/packages/cli-core/src/commands/webhooks/shared.ts +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -1,3 +1,4 @@ +import { cyan, dim } from "../../lib/color.ts"; import { getRelayEntry } from "../../lib/config.ts"; import { CliError, @@ -7,7 +8,7 @@ import { throwUserAbort, } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import type { WebhookCursor } from "../../lib/plapi.ts"; +import type { WebhookCursor, WebhookEndpoint } from "../../lib/plapi.ts"; import { isAgent } from "../../mode.ts"; export interface WebhooksGlobalOptions { @@ -98,6 +99,24 @@ export async function resolveEndpointOrRelay( ); } +/** Labeled key/value detail rows for one endpoint, on stderr. */ +export function formatEndpointDetails(endpoint: WebhookEndpoint): void { + const rows: Array<[string, string]> = [ + ["ID", cyan(endpoint.id)], + ["URL", endpoint.url], + ["Status", endpoint.disabled ? "disabled" : "enabled"], + ["Description", endpoint.description || dim("(none)")], + ["Events", endpoint.filter_types?.length ? endpoint.filter_types.join(", ") : "all"], + ["Channels", endpoint.channels?.length ? endpoint.channels.join(", ") : dim("(none)")], + ["Created", endpoint.created_at], + ["Updated", endpoint.updated_at], + ]; + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + 2; + for (const [label, value] of rows) { + log.info(`${dim(`${label}:`.padEnd(labelWidth + 1))}${value}`); + } +} + /** Split a comma-separated flag value into trimmed, non-empty entries. */ export function splitCommaList(value: string | undefined): string[] | undefined { if (value === undefined) return undefined; diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts new file mode 100644 index 00000000..ef983d5d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -0,0 +1,130 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockUpdateWebhookEndpoint = mock(); +mock.module("../../lib/plapi.ts", () => ({ + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksUpdate } = await import("./update.ts"); + +const updatedEndpoint = { + id: "ep_1", + url: "https://example.com/new", + version: 1, + description: "Updated", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks update", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockUpdateWebhookEndpoint.mockResolvedValue(updatedEndpoint); + }); + + afterEach(() => { + mockUpdateWebhookEndpoint.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test.each([ + { + label: "--url", + options: { url: "https://example.com/new" }, + expected: { url: "https://example.com/new" }, + }, + { + label: "--description", + options: { description: "Updated" }, + expected: { description: "Updated" }, + }, + { + label: "--events (comma-separated)", + options: { events: "user.created, user.deleted" }, + expected: { filter_types: ["user.created", "user.deleted"] }, + }, + { + label: "--channels (comma-separated)", + options: { channels: "a,b" }, + expected: { channels: ["a", "b"] }, + }, + { label: "--enable", options: { enable: true }, expected: { disabled: false } }, + { label: "--disable", options: { disable: true }, expected: { disabled: true } }, + ])("$label maps to the PATCH body", async ({ options, expected }) => { + await webhooksUpdate({ endpointId: "ep_1", ...options }); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", expected); + }); + + test("omits disabled from the PATCH body when neither --enable nor --disable is set", async () => { + await webhooksUpdate({ endpointId: "ep_1", url: "https://example.com/new" }); + + const params = mockUpdateWebhookEndpoint.mock.calls[0]?.[3] as Record; + expect("disabled" in params).toBe(false); + }); + + test("--enable with --disable is a usage error", async () => { + await expect( + webhooksUpdate({ endpointId: "ep_1", enable: true, disable: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("no update flags at all is a usage error", async () => { + await expect(webhooksUpdate({ endpointId: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("prints the updated endpoint in human mode", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Updated webhook endpoint"); + expect(captured.err).toContain("https://example.com/new"); + }); + + test("outputs the updated endpoint resource as JSON with --json", async () => { + await webhooksUpdate({ endpointId: "ep_1", description: "Updated", json: true }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + expect(captured.err).toBe(""); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockUpdateWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksUpdate({ endpointId: "ep_missing", description: "x" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/update.ts b/packages/cli-core/src/commands/webhooks/update.ts new file mode 100644 index 00000000..ba970c6c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/update.ts @@ -0,0 +1,63 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { updateWebhookEndpoint, type UpdateWebhookEndpointParams } from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + rejectEndpointNotFound, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksUpdateOptions extends WebhooksGlobalOptions { + endpointId: string; + url?: string; + events?: string; + description?: string; + channels?: string; + enable?: boolean; + disable?: boolean; +} + +export function buildUpdateParams(options: WebhooksUpdateOptions): UpdateWebhookEndpointParams { + if (options.enable && options.disable) { + throwUsageError("--enable and --disable are mutually exclusive."); + } + + const params: UpdateWebhookEndpointParams = {}; + if (options.url !== undefined) params.url = options.url; + if (options.description !== undefined) params.description = options.description; + const filterTypes = splitCommaList(options.events); + if (filterTypes !== undefined) params.filter_types = filterTypes; + const channels = splitCommaList(options.channels); + if (channels !== undefined) params.channels = channels; + if (options.enable) params.disabled = false; + if (options.disable) params.disabled = true; + + if (Object.keys(params).length === 0) { + throwUsageError( + "Nothing to update. Pass at least one of --url, --events, --description, --channels, --enable, or --disable.", + ); + } + return params; +} + +export async function webhooksUpdate(options: WebhooksUpdateOptions): Promise { + const params = buildUpdateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await rejectEndpointNotFound( + updateWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId, params), + options.endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(endpoint); + return; + } + + log.success(`Updated webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); +} From 72e3ebbf4a61d42ed9b1c3f605ba7490f53e80e7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:27:17 -0300 Subject: [PATCH 21/42] feat(webhooks): add 'webhooks create' command --- packages/cli-core/src/cli-program.ts | 33 +++++ .../cli-core/src/commands/webhooks/README.md | 19 +++ .../src/commands/webhooks/create.test.ts | 131 ++++++++++++++++++ .../cli-core/src/commands/webhooks/create.ts | 66 +++++++++ .../cli-core/src/commands/webhooks/index.ts | 2 + 5 files changed, 251 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/create.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/create.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index eac25cf5..8298fc70 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -321,6 +321,39 @@ export function createProgram(): Program { }), ); : add 'webhooks update' command) + + 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], + ), + ); +: add 'webhooks create' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 72e5d36e..424b6eb3 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -119,3 +119,22 @@ Human mode prints the updated endpoint's details on stderr. JSON mode prints the | Method | Endpoint | Description | | ------- | ------------------------ | ---------------------- | | `PATCH` | `/webhooks/{endpointID}` | Patch endpoint fields. | + +## `clerk webhooks create` + +Creates an endpoint (always `version: 1`), then fetches and prints its signing secret. The backend lazily provisions the Svix app on the first create. Two network calls, client-orchestrated. + +```sh +clerk webhooks create --url [--events user.created,...] [--description ] [--channels a,b] [--disabled] +``` + +JSON mode emits the endpoint resource FLAT with one extra field: `signing_secret`. Human mode prints the details plus the unmasked secret on stderr. + +Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the command exits 1 with `Endpoint created (id: ep_...) but the signing secret could not be fetched. Run 'clerk webhooks secret ep_...' to retrieve it.` — no silent orphan. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------------------------------------- | +| `POST` | `/webhooks` | Create the endpoint (lazily provisions Svix app). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the new endpoint's signing secret. | diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts new file mode 100644 index 00000000..a9392c9a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -0,0 +1,131 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockCreateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksCreate } = await import("./create.ts"); + +const createdEndpoint = { + id: "ep_new", + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: false, + filter_types: ["user.created"], + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", +}; + +describe("webhooks create", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockCreateWebhookEndpoint.mockResolvedValue(createdEndpoint); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: "whsec_new123" }); + }); + + afterEach(() => { + mockCreateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + }); + + test("missing --url is a usage error", async () => { + await expect(webhooksCreate({})).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + }); + + test("sends url and version 1 by default", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + }); + }); + + test("maps optional flags to the create body", async () => { + await webhooksCreate({ + url: "https://example.com/webhooks", + events: "user.created, user.deleted", + description: "My endpoint", + channels: "a,b", + disabled: true, + }); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: "https://example.com/webhooks", + version: 1, + description: "My endpoint", + disabled: true, + filter_types: ["user.created", "user.deleted"], + channels: ["a", "b"], + }); + }); + + test("fetches the signing secret after creating", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(mockGetWebhookEndpointSecret).toHaveBeenCalledWith("app_1", "ins_1", "ep_new"); + }); + + test("emits the endpoint flat with signing_secret in JSON mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks", json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + expect(captured.err).toBe(""); + }); + + test("prints details and the unmasked secret in human mode", async () => { + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Created webhook endpoint"); + expect(captured.err).toContain("ep_new"); + expect(captured.err).toContain("whsec_new123"); + }); + + test("partial failure: secret fetch error exits 1 with the recovery command", async () => { + mockGetWebhookEndpointSecret.mockRejectedValue(new PlapiError(500, "{}")); + + const promise = webhooksCreate({ url: "https://example.com/webhooks" }); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(webhooksCreate({ url: "https://example.com/webhooks" })).rejects.toThrow( + "Endpoint created (id: ep_new) but the signing secret could not be fetched. " + + "Run 'clerk webhooks secret ep_new' to retrieve it.", + ); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/create.ts b/packages/cli-core/src/commands/webhooks/create.ts new file mode 100644 index 00000000..8ab4ceac --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/create.ts @@ -0,0 +1,66 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpointSecret, + type CreateWebhookEndpointParams, +} from "../../lib/plapi.ts"; +import { + formatEndpointDetails, + printJson, + shouldOutputJson, + splitCommaList, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksCreateOptions extends WebhooksGlobalOptions { + url?: string; + events?: string; + description?: string; + channels?: string; + disabled?: boolean; +} + +function buildCreateParams(options: WebhooksCreateOptions): CreateWebhookEndpointParams { + if (!options.url) { + throwUsageError("Missing required --url ."); + } + + const params: CreateWebhookEndpointParams = { url: options.url, version: 1 }; + if (options.description !== undefined) params.description = options.description; + if (options.disabled) params.disabled = true; + const filterTypes = splitCommaList(options.events); + if (filterTypes?.length) params.filter_types = filterTypes; + const channels = splitCommaList(options.channels); + if (channels?.length) params.channels = channels; + return params; +} + +export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promise { + const params = buildCreateParams(options); + const ctx = await resolveAppContext(options); + + const endpoint = await createWebhookEndpoint(ctx.appId, ctx.instanceId, params); + + let secret: string; + try { + ({ secret } = await getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id)); + } catch { + // Create is atomic; the secret fetch is a second call. Never leave a + // silent orphan — surface the new ID and the exact recovery command. + throw new CliError( + `Endpoint created (id: ${endpoint.id}) but the signing secret could not be fetched. ` + + `Run 'clerk webhooks secret ${endpoint.id}' to retrieve it.`, + ); + } + + if (shouldOutputJson(options)) { + printJson({ ...endpoint, signing_secret: secret }); + return; + } + + log.success(`Created webhook endpoint \`${endpoint.id}\``); + formatEndpointDetails(endpoint); + log.info(`Signing secret: ${secret}`); +} diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 17141e66..7c234700 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,3 +1,4 @@ +import { webhooksCreate } from "./create.ts"; import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; @@ -12,4 +13,5 @@ export const webhooks = { secret: webhooksSecret, delete: webhooksDelete, update: webhooksUpdate, + create: webhooksCreate, }; From f43e802e7fa9e161c86ad4cf54535beeda2c1939 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:28:43 -0300 Subject: [PATCH 22/42] feat(webhooks): add 'webhooks messages' command --- packages/cli-core/src/cli-program.ts | 36 ++++ .../cli-core/src/commands/webhooks/README.md | 16 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/messages.test.ts | 169 ++++++++++++++++++ .../src/commands/webhooks/messages.ts | 81 +++++++++ 5 files changed, 304 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/messages.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/messages.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8298fc70..7b6c3484 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -354,6 +354,42 @@ export function createProgram(): Program { ), ); : add 'webhooks create' command) + + 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], + ), + ); +: add 'webhooks messages' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 424b6eb3..ea32629e 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -138,3 +138,19 @@ Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the co | ------ | ------------------------------- | ------------------------------------------------- | | `POST` | `/webhooks` | Create the endpoint (lazily provisions Svix app). | | `GET` | `/webhooks/{endpointID}/secret` | Fetch the new endpoint's signing secret. | + +## `clerk webhooks messages` + +Lists recent deliveries (msg IDs, event type, status, full payload) for an endpoint — the discovery feed for `replay `. `--endpoint` defaults to the instance's persisted relay endpoint; without either, it's a usage error. + +```sh +clerk webhooks messages [--endpoint ] [--status success|pending|fail|sending] [--limit N] [--iterator C] +``` + +Human mode prints an `ID / EVENT TYPE / STATUS / CREATED` table on stderr (payloads only in JSON mode). JSON mode prints the full `{ data, cursor }` response, payloads included. + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------------------------- | ------------------------------------------------------ | +| `GET` | `/webhooks/{endpointID}/messages` | List attempted deliveries (one page, optional status). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 7c234700..bcde4137 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksMessages } from "./messages.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksUpdate } from "./update.ts"; @@ -14,4 +15,5 @@ export const webhooks = { delete: webhooksDelete, update: webhooksUpdate, create: webhooksCreate, + messages: webhooksMessages, }; diff --git a/packages/cli-core/src/commands/webhooks/messages.test.ts b/packages/cli-core/src/commands/webhooks/messages.test.ts new file mode 100644 index 00000000..d250c68c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.test.ts @@ -0,0 +1,169 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookMessages: (...args: unknown[]) => mockListWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksMessages } = await import("./messages.ts"); + +const mockMessages = [ + { + id: "msg_1", + event_type: "user.created", + status: "success", + next_attempt: null, + payload: { object: "event" }, + created_at: "2026-06-09T12:00:00Z", + }, + { + id: "msg_2", + event_type: "user.deleted", + status: "fail", + next_attempt: "2026-06-09T12:05:00Z", + payload: { object: "event" }, + created_at: "2026-06-09T12:01:00Z", + }, +]; + +function messagesResponse(hasNextPage = false) { + return { + data: mockMessages, + cursor: { starting_after: "iter_next", ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks messages", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookMessages.mockResolvedValue(messagesResponse()); + }); + + afterEach(() => { + mockListWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("lists deliveries for an explicit --endpoint", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 100, + iterator: undefined, + status: undefined, + }); + }); + + test("defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksMessages(); + + expect(mockGetRelayEntry).toHaveBeenCalledWith("ins_1"); + expect(mockListWebhookMessages).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + expect.anything(), + ); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + await expect(webhooksMessages()).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + message: + "No relay endpoint found for this instance. Run 'clerk webhooks listen' first, or pass --endpoint .", + }); + expect(mockListWebhookMessages).not.toHaveBeenCalled(); + }); + + test("forwards --status, --limit, and --iterator", async () => { + await webhooksMessages({ endpoint: "ep_1", status: "fail", limit: 10, iterator: "iter_x" }); + + expect(mockListWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + limit: 10, + iterator: "iter_x", + status: "fail", + }); + }); + + test("prints a delivery table in human mode", async () => { + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("msg_1"); + expect(captured.err).toContain("user.created"); + expect(captured.err).toContain("fail"); + expect(captured.err).toContain("2 deliveries returned"); + }); + + test("warns when the endpoint has no deliveries", async () => { + mockListWebhookMessages.mockResolvedValue({ + data: [], + cursor: { starting_after: null, ending_before: null, has_next_page: false }, + }); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("No deliveries found"); + }); + + test("hints at the next --iterator value when more pages exist", async () => { + mockListWebhookMessages.mockResolvedValue(messagesResponse(true)); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(captured.err).toContain("--iterator iter_next"); + }); + + test("outputs the full response (including payloads) as JSON with --json", async () => { + await webhooksMessages({ endpoint: "ep_1", json: true }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksMessages({ endpoint: "ep_1" }); + + expect(JSON.parse(captured.out)).toEqual(messagesResponse()); + }); + + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockListWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksMessages({ endpoint: "ep_missing" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/messages.ts b/packages/cli-core/src/commands/webhooks/messages.ts new file mode 100644 index 00000000..ab07ad93 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/messages.ts @@ -0,0 +1,81 @@ +import { cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { log } from "../../lib/log.ts"; +import { + listWebhookMessages, + type WebhookMessage, + type WebhookMessageStatus, +} from "../../lib/plapi.ts"; +import { + DEFAULT_PAGE_LIMIT, + printIteratorHint, + printJson, + rejectEndpointNotFound, + resolveEndpointOrRelay, + shouldOutputJson, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksMessagesOptions extends WebhooksGlobalOptions { + endpoint?: string; + status?: WebhookMessageStatus; + limit?: number; + iterator?: string; +} + +// Pad before coloring so ANSI codes don't skew the column width. +function paddedStatus(status: WebhookMessage["status"], width: number): string { + const padded = status.padEnd(width); + switch (status) { + case "success": + return green(padded); + case "fail": + return red(padded); + default: + return yellow(padded); + } +} + +function formatMessagesTable(messages: WebhookMessage[]): void { + const idWidth = Math.max("ID".length, ...messages.map((m) => m.id.length)) + 2; + const eventWidth = Math.max("EVENT TYPE".length, ...messages.map((m) => m.event_type.length)) + 2; + const statusWidth = Math.max("STATUS".length, ...messages.map((m) => m.status.length)) + 2; + + log.info( + `${dim("ID".padEnd(idWidth))}${dim("EVENT TYPE".padEnd(eventWidth))}${dim("STATUS".padEnd(statusWidth))}${dim("CREATED")}`, + ); + for (const message of messages) { + log.info( + `${cyan(message.id.padEnd(idWidth))}${message.event_type.padEnd(eventWidth)}${paddedStatus(message.status, statusWidth)}${message.created_at}`, + ); + } +} + +export async function webhooksMessages(options: WebhooksMessagesOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + const response = await rejectEndpointNotFound( + listWebhookMessages(ctx.appId, ctx.instanceId, endpointId, { + limit: options.limit ?? DEFAULT_PAGE_LIMIT, + iterator: options.iterator, + status: options.status, + }), + endpointId, + ); + + if (shouldOutputJson(options)) { + printJson(response); + return; + } + + if (response.data.length === 0) { + log.warn(`No deliveries found for \`${endpointId}\`.`); + return; + } + + formatMessagesTable(response.data); + const count = response.data.length; + log.info(`\n${count} deliver${count === 1 ? "y" : "ies"} returned for \`${endpointId}\``); + printIteratorHint(response.cursor); +} From 03c54ef53f167e574efabecf5be393216d4eb31f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:30:03 -0300 Subject: [PATCH 23/42] feat(webhooks): add 'webhooks replay' command --- packages/cli-core/src/cli-program.ts | 34 ++++ .../cli-core/src/commands/webhooks/README.md | 20 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/replay.test.ts | 169 ++++++++++++++++++ .../cli-core/src/commands/webhooks/replay.ts | 78 ++++++++ 5 files changed, 303 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/replay.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/replay.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 7b6c3484..8800bf8e 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -390,6 +390,40 @@ export function createProgram(): Program { ), ); : add 'webhooks messages' command) + + 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, + }), + ); +: add 'webhooks replay' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index ea32629e..edc0d389 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -154,3 +154,23 @@ Human mode prints an `ID / EVENT TYPE / STATUS / CREATED` table on stderr (paylo | Method | Endpoint | Description | | ------ | --------------------------------- | ------------------------------------------------------ | | `GET` | `/webhooks/{endpointID}/messages` | List attempted deliveries (one page, optional status). | + +## `clerk webhooks replay` + +Dual-mode: + +- `replay ` resends one delivery (same `svix-id`). `--endpoint` defaults to the relay endpoint. No prompt — a single targeted resend is not destructive. +- `replay --since [--until ]` bulk-recovers failed deliveries in a window. `--endpoint` is **required** (bulk recovery never guesses), and it prompts unless `--yes` (agent mode requires `--yes`). + +`` and `--since` are mutually exclusive; passing both or neither is a usage error, as is `--until` without `--since`. Both operations are async on the Svix side — success means queued (`200 {}`), stdout stays empty. + +```sh +clerk webhooks replay [] [--endpoint ] [--since [--until ]] [--yes] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ---------------------------------------------------- | ------------------------------------------------------------ | +| `POST` | `/webhooks/{endpointID}/messages/{messageID}/resend` | Resend one delivery (`` mode). | +| `POST` | `/webhooks/{endpointID}/recover` | Recover a window: body `{ since, until? }` (`--since` mode). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index bcde4137..28bb5d64 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -4,6 +4,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; +import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksUpdate } from "./update.ts"; @@ -16,4 +17,5 @@ export const webhooks = { update: webhooksUpdate, create: webhooksCreate, messages: webhooksMessages, + replay: webhooksReplay, }; diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts new file mode 100644 index 00000000..06bcf1f6 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -0,0 +1,169 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockResendWebhookMessage = mock(); +const mockRecoverWebhookMessages = mock(); +mock.module("../../lib/plapi.ts", () => ({ + resendWebhookMessage: (...args: unknown[]) => mockResendWebhookMessage(...args), + recoverWebhookMessages: (...args: unknown[]) => mockRecoverWebhookMessages(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +const { webhooksReplay } = await import("./replay.ts"); + +describe("webhooks replay", () => { + useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValue(true); + mockGetRelayEntry.mockResolvedValue(undefined); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockResendWebhookMessage.mockResolvedValue(undefined); + mockRecoverWebhookMessages.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockResendWebhookMessage.mockReset(); + mockRecoverWebhookMessages.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + mockConfirm.mockReset(); + }); + + test.each([ + { + label: "both and --since", + options: { msgId: "msg_1", since: "2026-05-01T00:00:00Z" }, + }, + { label: "neither nor --since", options: {} }, + { + label: "--until without --since", + options: { msgId: "msg_1", until: "2026-05-01T00:00:00Z" }, + }, + { + label: "--since without --endpoint", + options: { since: "2026-05-01T00:00:00Z" }, + }, + { + label: "invalid --since timestamp", + options: { since: "not-a-date", endpoint: "ep_1" }, + }, + { + label: "invalid --until timestamp", + options: { since: "2026-05-01T00:00:00Z", until: "nope", endpoint: "ep_1" }, + }, + ])("$label is a usage error", async ({ options }) => { + await expect(webhooksReplay(options)).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResendWebhookMessage).not.toHaveBeenCalled(); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("resends one message to an explicit --endpoint without prompting", async () => { + await webhooksReplay({ msgId: "msg_1", endpoint: "ep_1" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "msg_1"); + }); + + test("resend defaults --endpoint to the persisted relay endpoint", async () => { + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + + await webhooksReplay({ msgId: "msg_1" }); + + expect(mockResendWebhookMessage).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", "msg_1"); + }); + + test("resend without --endpoint or a relay endpoint is a usage error", async () => { + await expect(webhooksReplay({ msgId: "msg_1" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("resend maps a PLAPI 404 to webhook_message_not_found", async () => { + mockResendWebhookMessage.mockRejectedValue(new PlapiError(404, "{}")); + + await expect(webhooksReplay({ msgId: "msg_gone", endpoint: "ep_1" })).rejects.toMatchObject({ + code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, + message: "No webhook message with ID msg_gone was found.", + }); + }); + + test("--since prompts, then recovers the window", async () => { + await webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: undefined, + }); + }); + + test("--since --until bounds the recovery window", async () => { + await webhooksReplay({ + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + endpoint: "ep_1", + yes: true, + }); + + expect(mockRecoverWebhookMessages).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", { + since: "2026-05-01T00:00:00Z", + until: "2026-05-01T01:00:00Z", + }); + }); + + test("--since aborts cleanly when the prompt is declined", async () => { + mockConfirm.mockResolvedValue(false); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toBeInstanceOf(UserAbortError); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--since in agent mode without --yes is a usage error", async () => { + mockIsAgent.mockReturnValue(true); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + }); + + test("--since maps a PLAPI 404 to webhook_endpoint_not_found", async () => { + mockRecoverWebhookMessages.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_missing", yes: true }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts new file mode 100644 index 00000000..a4538f6c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -0,0 +1,78 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { recoverWebhookMessages, resendWebhookMessage } from "../../lib/plapi.ts"; +import { + confirmDestructive, + rejectEndpointNotFound, + rejectMessageNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksReplayOptions extends WebhooksGlobalOptions { + msgId?: string; + endpoint?: string; + since?: string; + until?: string; + yes?: boolean; +} + +function assertRfc3339(value: string, flag: string): void { + if (Number.isNaN(Date.parse(value))) { + throwUsageError(`Invalid ${flag} value "${value}". Must be an RFC 3339 timestamp.`); + } +} + +function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover" { + if (options.msgId && options.since) { + throwUsageError("Pass either a or --since, not both."); + } + if (!options.msgId && !options.since) { + throwUsageError("Pass a to resend one delivery, or --since to bulk-recover."); + } + if (options.until && !options.since) { + throwUsageError("--until requires --since."); + } + if (options.since) { + assertRfc3339(options.since, "--since"); + if (options.until) assertRfc3339(options.until, "--until"); + if (!options.endpoint) { + throwUsageError("--endpoint is required with --since. Bulk recovery never guesses a target."); + } + return "recover"; + } + return "resend"; +} + +export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promise { + const mode = validateReplayMode(options); + const ctx = await resolveAppContext(options); + + if (mode === "resend") { + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + await rejectMessageNotFound( + resendWebhookMessage(ctx.appId, ctx.instanceId, endpointId, options.msgId!), + options.msgId!, + ); + log.success(`Queued replay of \`${options.msgId}\` to \`${endpointId}\``); + return; + } + + const windowLabel = options.until + ? `between ${options.since} and ${options.until}` + : `since ${options.since}`; + await confirmDestructive( + `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, + options, + ); + + await rejectEndpointNotFound( + recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { + since: options.since!, + until: options.until, + }), + options.endpoint!, + ); + log.success(`Queued recovery of deliveries to \`${options.endpoint}\` ${windowLabel}`); +} From c77b8dce28c537b27bd5c3e81b1bcd11b89d5bbd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:31:21 -0300 Subject: [PATCH 24/42] feat(webhooks): add 'webhooks trigger' command --- packages/cli-core/src/cli-program.ts | 29 ++++ .../cli-core/src/commands/webhooks/README.md | 15 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/trigger.test.ts | 126 ++++++++++++++++++ .../cli-core/src/commands/webhooks/trigger.ts | 55 ++++++++ 5 files changed, 227 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/trigger.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/trigger.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8800bf8e..3014e329 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -424,6 +424,35 @@ export function createProgram(): Program { }), ); : add 'webhooks replay' command) + + 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, + }), + ); +: add 'webhooks trigger' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index edc0d389..933bec80 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -174,3 +174,18 @@ clerk webhooks replay [] [--endpoint ] [--since [--until ` mode). | | `POST` | `/webhooks/{endpointID}/recover` | Recover a window: body `{ since, until? }` (`--since` mode). | + +## `clerk webhooks trigger ` + +Sends an example event of the given type. Because `send_example` returns `200 {}` asynchronously, the CLI first validates the type against the event-type catalog (paging through it) and fails fast with error code `unknown_event_type` — otherwise an invalid type would exit 0 and deliver nothing. `--endpoint` defaults to the relay endpoint. + +```sh +clerk webhooks trigger user.created [--endpoint ] +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------------- | ------------------------------------------------ | +| `GET` | `/webhooks/event_types` | Validate the event type against the catalog. | +| `POST` | `/webhooks/{endpointID}/send_example` | Send the example event: body `{ "event_type" }`. | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 28bb5d64..9cf14866 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -6,6 +6,7 @@ import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; +import { webhooksTrigger } from "./trigger.ts"; import { webhooksUpdate } from "./update.ts"; export const webhooks = { @@ -18,4 +19,5 @@ export const webhooks = { create: webhooksCreate, messages: webhooksMessages, replay: webhooksReplay, + trigger: webhooksTrigger, }; diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts new file mode 100644 index 00000000..72c074ac --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -0,0 +1,126 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockListWebhookEventTypes = mock(); +const mockSendWebhookExample = mock(); +mock.module("../../lib/plapi.ts", () => ({ + listWebhookEventTypes: (...args: unknown[]) => mockListWebhookEventTypes(...args), + sendWebhookExample: (...args: unknown[]) => mockSendWebhookExample(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksTrigger } = await import("./trigger.ts"); + +function catalogPage(names: string[], hasNextPage = false, startingAfter: string | null = null) { + return { + data: names.map((name) => ({ + name, + description: "", + archived: false, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + })), + cursor: { starting_after: startingAfter, ending_before: null, has_next_page: hasNextPage }, + }; +} + +describe("webhooks trigger", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockListWebhookEventTypes.mockResolvedValue(catalogPage(["user.created", "user.deleted"])); + mockSendWebhookExample.mockResolvedValue(undefined); + }); + + afterEach(() => { + mockListWebhookEventTypes.mockReset(); + mockSendWebhookExample.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("validates the event type, then sends the example", async () => { + await webhooksTrigger({ eventType: "user.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledWith("app_1", "ins_1", { + limit: 250, + iterator: undefined, + }); + expect(mockSendWebhookExample).toHaveBeenCalledWith( + "app_1", + "ins_1", + "ep_relay", + "user.created", + ); + expect(captured.err).toContain("delivery is async"); + }); + + test("uses an explicit --endpoint over the relay default", async () => { + await webhooksTrigger({ eventType: "user.created", endpoint: "ep_1" }); + + expect(mockSendWebhookExample).toHaveBeenCalledWith("app_1", "ins_1", "ep_1", "user.created"); + }); + + test("no --endpoint and no relay endpoint is a usage error", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.created" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("unknown event type fails fast with unknown_event_type", async () => { + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + + test("pages through the catalog before declaring a type unknown", async () => { + mockListWebhookEventTypes + .mockResolvedValueOnce(catalogPage(["user.created"], true, "iter_2")) + .mockResolvedValueOnce(catalogPage(["organization.created"])); + + await webhooksTrigger({ eventType: "organization.created" }); + + expect(mockListWebhookEventTypes).toHaveBeenCalledTimes(2); + expect(mockListWebhookEventTypes).toHaveBeenLastCalledWith("app_1", "ins_1", { + limit: 250, + iterator: "iter_2", + }); + expect(mockSendWebhookExample).toHaveBeenCalled(); + }); + + test("maps a PLAPI 404 on send to webhook_endpoint_not_found", async () => { + mockSendWebhookExample.mockRejectedValue(new PlapiError(404, "{}")); + + await expect( + webhooksTrigger({ eventType: "user.created", endpoint: "ep_missing" }), + ).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts new file mode 100644 index 00000000..3359254c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -0,0 +1,55 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { listWebhookEventTypes, sendWebhookExample } from "../../lib/plapi.ts"; +import { + rejectEndpointNotFound, + resolveEndpointOrRelay, + type WebhooksGlobalOptions, +} from "./shared.ts"; + +export interface WebhooksTriggerOptions extends WebhooksGlobalOptions { + eventType: string; + endpoint?: string; +} + +const CATALOG_PAGE_LIMIT = 250; + +async function assertKnownEventType( + appId: string, + instanceId: string, + eventType: string, +): Promise { + let iterator: string | undefined; + do { + const page = await listWebhookEventTypes(appId, instanceId, { + limit: CATALOG_PAGE_LIMIT, + iterator, + }); + if (page.data.some((entry) => entry.name === eventType)) return; + iterator = page.cursor.has_next_page ? (page.cursor.starting_after ?? undefined) : undefined; + } while (iterator); + + throw new CliError( + `Unknown event type "${eventType}". Run \`clerk webhooks event-types\` to list available types.`, + { code: ERROR_CODE.UNKNOWN_EVENT_TYPE }, + ); +} + +export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise { + const ctx = await resolveAppContext(options); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + + // send_example returns 200 {} asynchronously — an invalid event type would + // otherwise exit 0 and deliver nothing, the silent failure trigger exists to kill. + await assertKnownEventType(ctx.appId, ctx.instanceId, options.eventType); + + await rejectEndpointNotFound( + sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), + endpointId, + ); + + log.success( + `Sent example \`${options.eventType}\` event to \`${endpointId}\` (delivery is async)`, + ); +} From 4804c62ae65e3c78e21daee584f16a0da61070a6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:32:15 -0300 Subject: [PATCH 25/42] feat(webhooks): add 'webhooks open' command --- packages/cli-core/src/cli-program.ts | 12 +++ .../cli-core/src/commands/webhooks/README.md | 14 +++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/open.test.ts | 87 +++++++++++++++++++ .../cli-core/src/commands/webhooks/open.ts | 32 +++++++ 5 files changed, 147 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/open.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/open.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 3014e329..fa987f95 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -453,6 +453,18 @@ export function createProgram(): Program { }), ); : add 'webhooks trigger' command) + + 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]), + ); +: add 'webhooks open' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 933bec80..33c30152 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -189,3 +189,17 @@ clerk webhooks trigger user.created [--endpoint ] | ------ | ------------------------------------- | ------------------------------------------------ | | `GET` | `/webhooks/event_types` | Validate the event type against the catalog. | | `POST` | `/webhooks/{endpointID}/send_example` | Send the example event: body `{ "event_type" }`. | + +## `clerk webhooks open` + +Fetches a single-use Svix portal URL and opens it in the browser via `openBrowser()` (which never throws — on failure the URL is printed as a fallback). JSON/agent mode prints `{ "url": "..." }` and does not launch a browser. Backed by the Svix `DashboardAccess` API in v0.64.1; switch to `AppPortalAccess` on SDK upgrade. + +```sh +clerk webhooks open +``` + +### API endpoints + +| Method | Endpoint | Description | +| ------ | --------------- | ----------------------------------------- | +| `POST` | `/webhooks/url` | Fetch the portal URL (request body `{}`). | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 9cf14866..3da7671e 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -4,6 +4,7 @@ import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; import { webhooksMessages } from "./messages.ts"; +import { webhooksOpen } from "./open.ts"; import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksTrigger } from "./trigger.ts"; @@ -20,4 +21,5 @@ export const webhooks = { messages: webhooksMessages, replay: webhooksReplay, trigger: webhooksTrigger, + open: webhooksOpen, }; diff --git a/packages/cli-core/src/commands/webhooks/open.test.ts b/packages/cli-core/src/commands/webhooks/open.test.ts new file mode 100644 index 00000000..1432e315 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.test.ts @@ -0,0 +1,87 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockGetWebhookPortalUrl = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookPortalUrl: (...args: unknown[]) => mockGetWebhookPortalUrl(...args), +})); + +const mockResolveAppContext = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: async () => undefined, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const { webhooksOpen } = await import("./open.ts"); + +const PORTAL_URL = "https://app.svix.com/login#key=abc"; + +describe("webhooks open", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetWebhookPortalUrl.mockResolvedValue({ url: PORTAL_URL }); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + }); + + afterEach(() => { + mockGetWebhookPortalUrl.mockReset(); + mockResolveAppContext.mockReset(); + mockIsAgent.mockReset(); + mockOpenBrowser.mockReset(); + }); + + test("fetches the portal URL and opens the browser in human mode", async () => { + await webhooksOpen(); + + expect(mockGetWebhookPortalUrl).toHaveBeenCalledWith("app_1", "ins_1"); + expect(mockOpenBrowser).toHaveBeenCalledWith(PORTAL_URL); + expect(captured.out).toBe(""); + expect(captured.err).toContain("Opening the webhook portal"); + }); + + test("prints a fallback URL when the browser cannot be opened", async () => { + mockOpenBrowser.mockResolvedValue({ ok: false, reason: "no-launcher" }); + + await webhooksOpen(); + + expect(captured.err).toContain("Could not open your browser automatically"); + expect(captured.err).toContain(PORTAL_URL); + }); + + test("outputs { url } without launching a browser with --json", async () => { + await webhooksOpen({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("outputs { url } without launching a browser in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksOpen(); + + expect(JSON.parse(captured.out)).toEqual({ url: PORTAL_URL }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/open.ts b/packages/cli-core/src/commands/webhooks/open.ts new file mode 100644 index 00000000..58518fd7 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/open.ts @@ -0,0 +1,32 @@ +import { cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { getWebhookPortalUrl } from "../../lib/plapi.ts"; +import { printJson, shouldOutputJson, type WebhooksGlobalOptions } from "./shared.ts"; + +export type WebhooksOpenOptions = WebhooksGlobalOptions; + +export async function webhooksOpen(options: WebhooksOpenOptions = {}): Promise { + const ctx = await resolveAppContext(options); + const { url } = await withApiContext( + getWebhookPortalUrl(ctx.appId, ctx.instanceId), + "Failed to fetch the webhook portal URL", + ); + + if (shouldOutputJson(options)) { + printJson({ url }); + return; + } + + log.info(`↗ Opening the webhook portal for \`${ctx.appLabel}\` (${ctx.instanceLabel})`); + log.info(` ${dim(url)}`); + + const result = await openBrowser(url); + if (!result.ok) { + log.warn( + `Could not open your browser automatically. Open this URL to continue:\n ${cyan(url)}\n${dim(`(Reason: ${result.reason})`)}`, + ); + } +} From 22d45c6825cc4bdcf92741ca128591369396c1fa Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:35:30 -0300 Subject: [PATCH 26/42] feat(webhooks): add offline 'webhooks verify' command --- packages/cli-core/src/cli-program.ts | 30 +++ .../cli-core/src/commands/webhooks/README.md | 23 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/verify.test.ts | 248 ++++++++++++++++++ .../cli-core/src/commands/webhooks/verify.ts | 186 +++++++++++++ packages/cli-core/undefined/body.json | 1 + 6 files changed, 490 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/verify.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/verify.ts create mode 100644 packages/cli-core/undefined/body.json diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index fa987f95..d9b79ccb 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -465,6 +465,36 @@ export function createProgram(): Program { webhooksHandlers.open(cmd.optsWithGlobals() as Parameters[0]), ); : add 'webhooks open' command) + + 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], + ), + ); +: add offline 'webhooks verify' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 33c30152..d4cb1850 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -203,3 +203,26 @@ clerk webhooks open | Method | Endpoint | Description | | ------ | --------------- | ----------------------------------------- | | `POST` | `/webhooks/url` | Fetch the portal URL (request body `{}`). | + +## `clerk webhooks verify` + +Verifies a Svix webhook signature **locally**: HMAC-SHA256 over `{id}.{timestamp}.{body}` with the base64-decoded `whsec_` suffix, constant-time compare, any-match across space-separated `v1,` header entries (rotation grace windows produce multiple entries). No network calls, no auth gate (`--app`/`--instance` are ignored). Exit 0 = signature matched; exit 1 = mismatch (with a humanized timestamp-skew hint when the timestamp is >5 minutes off); exit 2 = bad inputs. + +```sh +clerk webhooks verify --secret whsec_... (--delivery @event.json | --payload @body.json --id msg_... --timestamp --signature v1,...) +``` + +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------- | +| `--secret ` | Always required. A flag, never a positional — secrets must not land in argv positionals. | +| `--delivery ` | One `listen` event NDJSON line (`@file` or `-`); supplies `id`, `timestamp`, `signature`, and the body. | +| `--payload ` | Raw body as `@file` or `-` (bare inline JSON rejected; shells mangle it). | +| `--id ` | The `svix-id` header (first HMAC pre-image segment). | +| `--timestamp ` | The `svix-timestamp` header — Unix epoch seconds, integer. | +| `--signature ` | The raw `svix-signature` header value; may carry multiple space-separated `v1,` entries (any-match). | + +Explicit flags override fields parsed from `--delivery`. A `listen` event line saved to a file is directly consumable here. + +### API endpoints + +None — pure offline computation. diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 3da7671e..41b8206e 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -9,6 +9,7 @@ import { webhooksReplay } from "./replay.ts"; import { webhooksSecret } from "./secret.ts"; import { webhooksTrigger } from "./trigger.ts"; import { webhooksUpdate } from "./update.ts"; +import { webhooksVerify } from "./verify.ts"; export const webhooks = { list: webhooksList, @@ -22,4 +23,5 @@ export const webhooks = { replay: webhooksReplay, trigger: webhooksTrigger, open: webhooksOpen, + verify: webhooksVerify, }; diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts new file mode 100644 index 00000000..f5c1dde9 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -0,0 +1,248 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createHmac, randomBytes } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { + decodeWebhookSecret, + parseDeliveryLine, + verifyWebhookSignature, + webhooksVerify, +} from "./verify.ts"; + +const KEY = randomBytes(24); +const SECRET = `whsec_${KEY.toString("base64")}`; +const ID = "msg_2xyz"; +const TIMESTAMP = String(Math.floor(Date.now() / 1000)); +const PAYLOAD = '{"object":"event","type":"user.created"}'; + +function sign(id: string, timestamp: string, payload: string, key: Buffer = KEY): string { + return createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`, "utf8").digest("base64"); +} + +const VALID_SIGNATURE = `v1,${sign(ID, TIMESTAMP, PAYLOAD)}`; + +describe("decodeWebhookSecret", () => { + test.each([ + { label: "valid whsec_ secret", secret: SECRET, expected: true }, + { label: "missing whsec_ prefix", secret: KEY.toString("base64"), expected: false }, + { label: "empty suffix", secret: "whsec_", expected: false }, + { label: "empty string", secret: "", expected: false }, + ])("$label", ({ secret, expected }) => { + const key = decodeWebhookSecret(secret); + expect(key !== null).toBe(expected); + if (key) expect(key.equals(KEY)).toBe(true); + }); +}); + +describe("verifyWebhookSignature", () => { + const base = { secret: SECRET, id: ID, timestamp: TIMESTAMP, payload: PAYLOAD }; + + test("accepts a valid single signature", () => { + expect(verifyWebhookSignature({ ...base, signature: VALID_SIGNATURE })).toBe(true); + }); + + test("accepts when any space-separated entry matches (rotation grace window)", () => { + const oldKey = randomBytes(24); + const staleEntry = `v1,${sign(ID, TIMESTAMP, PAYLOAD, oldKey)}`; + expect(verifyWebhookSignature({ ...base, signature: `${staleEntry} ${VALID_SIGNATURE}` })).toBe( + true, + ); + }); + + test.each([ + { label: "tampered body", input: { ...base, payload: PAYLOAD + " " } }, + { label: "wrong timestamp", input: { ...base, timestamp: String(Number(TIMESTAMP) + 1) } }, + { label: "wrong id", input: { ...base, id: "msg_other" } }, + { + label: "wrong secret", + input: { ...base, secret: `whsec_${randomBytes(24).toString("base64")}` }, + }, + ])("rejects $label", ({ input }) => { + expect(verifyWebhookSignature({ ...input, signature: VALID_SIGNATURE })).toBe(false); + }); + + test.each([ + { label: "non-v1 version entries", signature: `v1a,${sign(ID, TIMESTAMP, PAYLOAD)}` }, + { label: "entry without a comma", signature: "v1" }, + { label: "empty header", signature: "" }, + { label: "whitespace-only header", signature: " " }, + { label: "truncated base64 signature", signature: "v1,AAAA" }, + { label: "garbage entry", signature: "v1,!!!not-base64!!!" }, + ])("rejects $label without crashing", ({ signature }) => { + expect(verifyWebhookSignature({ ...base, signature })).toBe(false); + }); + + test("rejects everything when the secret is malformed", () => { + expect( + verifyWebhookSignature({ ...base, secret: "not-a-secret", signature: VALID_SIGNATURE }), + ).toBe(false); + }); +}); + +describe("parseDeliveryLine", () => { + test("extracts the four fields from a listen event line", () => { + const line = JSON.stringify({ + type: "event", + svix_id: ID, + event_type: "user.created", + headers: { + "svix-id": ID, + "svix-timestamp": TIMESTAMP, + "svix-signature": VALID_SIGNATURE, + }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + forward_status: 200, + latency_ms: 12, + }); + + expect(parseDeliveryLine(line)).toEqual({ + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, + payload: PAYLOAD, + }); + }); + + test.each([ + { label: "invalid JSON", raw: "{nope" }, + { label: "non-object JSON", raw: '"hello"' }, + ])("throws a usage error on $label", ({ raw }) => { + expect(() => parseDeliveryLine(raw)).toThrow(CliError); + }); + + test("returns undefined fields when headers are missing", () => { + expect(parseDeliveryLine("{}")).toEqual({ + id: undefined, + timestamp: undefined, + signature: undefined, + }); + }); +}); + +describe("webhooks verify command", () => { + const captured = useCaptureLog(); + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-verify-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeTempFile(name: string, content: string): Promise { + const path = join(tempDir, name); + await writeFile(path, content); + return path; + } + + const explicitFlags = () => ({ + secret: SECRET, + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, + }); + + test("verifies with explicit flags and a payload file", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }); + + expect(captured.err).toContain("Signature verified."); + expect(captured.out).toBe(""); + }); + + test("verifies from a --delivery event file alone", async () => { + const line = JSON.stringify({ + headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const deliveryPath = await writeTempFile("event.json", `${line}\n`); + + await webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}` }); + + expect(captured.err).toContain("Signature verified."); + }); + + test("explicit flags override --delivery fields", async () => { + const line = JSON.stringify({ + headers: { + "svix-id": "msg_other", + "svix-timestamp": TIMESTAMP, + "svix-signature": VALID_SIGNATURE, + }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const deliveryPath = await writeTempFile("event.json", line); + + // The file's svix-id would fail; the explicit --id matching the signature wins. + await webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}`, id: ID }); + + expect(captured.err).toContain("Signature verified."); + }); + + test("fails with exit 1 on a signature mismatch", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD + "tampered"); + + await expect( + webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }), + ).rejects.toThrow("Signature verification failed"); + }); + + test("mismatch on a stale timestamp includes a humanized skew hint", async () => { + const staleTimestamp = String(Number(TIMESTAMP) - 3600); + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await expect( + webhooksVerify({ ...explicitFlags(), timestamp: staleTimestamp, payload: `@${payloadPath}` }), + ).rejects.toThrow("in the past"); + }); + + test.each([ + { label: "missing --secret", options: {} }, + { label: "malformed --secret", options: { secret: "sk_nope" } }, + { + label: "missing inputs (no --delivery, incomplete flags)", + options: { secret: SECRET, id: ID }, + }, + { + label: "non-integer --timestamp", + options: { + secret: SECRET, + id: ID, + timestamp: "2026-06-09T12:00:00Z", + signature: VALID_SIGNATURE, + payload: "-", + }, + }, + { + label: "inline --payload (not @file or -)", + options: { + ...{ secret: SECRET, id: ID, timestamp: TIMESTAMP, signature: VALID_SIGNATURE }, + payload: "{}", + }, + }, + ])("$label is a usage error", async ({ options }) => { + await expect(webhooksVerify(options)).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("missing --payload file maps to file_not_found", async () => { + await expect( + webhooksVerify({ ...explicitFlags(), payload: "@/definitely/not/here.json" }), + ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); + }); + + test("empty --delivery input is a usage error", async () => { + const deliveryPath = await writeTempFile("empty.json", "\n\n"); + + await expect( + webhooksVerify({ secret: SECRET, delivery: `@${deliveryPath}` }), + ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/verify.ts b/packages/cli-core/src/commands/webhooks/verify.ts new file mode 100644 index 00000000..ec5d127c --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -0,0 +1,186 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; + +export interface WebhooksVerifyOptions { + secret?: string; + delivery?: string; + payload?: string; + id?: string; + timestamp?: string; + signature?: string; + // Group-level flags are accepted but ignored: verify is pure offline HMAC. + app?: string; + instance?: string; + json?: boolean; +} + +const SECRET_PREFIX = "whsec_"; +const SKEW_HINT_THRESHOLD_SECONDS = 5 * 60; + +/** Decode the base64 key material after the `whsec_` prefix. Null when malformed. */ +export function decodeWebhookSecret(secret: string): Buffer | null { + if (!secret.startsWith(SECRET_PREFIX)) return null; + const encoded = secret.slice(SECRET_PREFIX.length); + if (!encoded) return null; + const key = Buffer.from(encoded, "base64"); + if (key.length === 0) return null; + return key; +} + +/** + * Verify a Svix signature: HMAC-SHA256 over `{id}.{timestamp}.{payload}` with + * the decoded secret, compared constant-time against every space-separated + * `v1,` entry in the header (any match wins). During the 24h rotation + * grace window the header carries multiple entries — that's why any-match matters. + */ +export function verifyWebhookSignature(input: { + secret: string; + id: string; + timestamp: string; + payload: string; + signature: string; +}): boolean { + const key = decodeWebhookSecret(input.secret); + if (!key) return false; + + const expected = createHmac("sha256", key) + .update(`${input.id}.${input.timestamp}.${input.payload}`, "utf8") + .digest(); + + return input.signature + .split(/\s+/) + .filter(Boolean) + .some((entry) => { + const commaIndex = entry.indexOf(","); + if (commaIndex === -1) return false; + const version = entry.slice(0, commaIndex); + if (version !== "v1") return false; + const candidate = Buffer.from(entry.slice(commaIndex + 1), "base64"); + return candidate.length === expected.length && timingSafeEqual(candidate, expected); + }); +} + +export interface DeliveryFields { + id?: string; + timestamp?: string; + signature?: string; + payload?: string; +} + +/** + * Parse one `listen` event NDJSON line (`headers` + `body_b64`) into the four + * verification inputs. Explicit flags override these at the call site. + */ +export function parseDeliveryLine(raw: string): DeliveryFields { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throwUsageError("--delivery is not valid JSON. Expected one `listen` event NDJSON line."); + } + if (parsed === null || typeof parsed !== "object") { + throwUsageError("--delivery must be a JSON object (one `listen` event NDJSON line)."); + } + + const record = parsed as { headers?: Record; body_b64?: string }; + const headers = record.headers ?? {}; + const fields: DeliveryFields = { + id: headers["svix-id"], + timestamp: headers["svix-timestamp"], + signature: headers["svix-signature"], + }; + if (typeof record.body_b64 === "string") { + fields.payload = Buffer.from(record.body_b64, "base64").toString("utf8"); + } + return fields; +} + +async function readFileOrStdin(value: string, flag: string): Promise { + if (value === "-") { + return await Bun.stdin.text(); + } + if (value.startsWith("@")) { + const path = value.slice(1); + const file = Bun.file(path); + if (!(await file.exists())) { + throw new CliError(`File not found: ${path}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } + return await file.text(); + } + return throwUsageError( + `${flag} takes @file or - for stdin (inline values get mangled by shells).`, + ); +} + +function humanizeSkew(deltaSeconds: number): string { + const minutes = Math.round(Math.abs(deltaSeconds) / 60); + const span = minutes >= 1 ? `${minutes} minute${minutes === 1 ? "" : "s"}` : "less than a minute"; + return deltaSeconds > 0 ? `${span} in the past` : `${span} in the future`; +} + +export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promise { + if (!options.secret) { + throwUsageError("Missing required --secret whsec_..."); + } + if (!decodeWebhookSecret(options.secret)) { + throwUsageError("Invalid --secret. Expected a whsec_-prefixed base64 signing secret."); + } + + let fields: DeliveryFields = {}; + if (options.delivery) { + const raw = await readFileOrStdin(options.delivery, "--delivery"); + const firstLine = raw.split("\n").find((line) => line.trim()); + if (!firstLine) { + throwUsageError("--delivery input is empty. Expected one `listen` event NDJSON line."); + } + fields = parseDeliveryLine(firstLine); + } + + // Explicit flags override --delivery fields. + const id = options.id ?? fields.id; + const timestamp = options.timestamp ?? fields.timestamp; + const signature = options.signature ?? fields.signature; + const hasPayload = options.payload !== undefined || fields.payload !== undefined; + + const missing = [ + !id && "--id", + !timestamp && "--timestamp", + !signature && "--signature", + !hasPayload && "--payload", + ].filter(Boolean); + if (missing.length > 0) { + throwUsageError( + `Missing ${missing.join(", ")}. Pass --delivery @event.json or all four explicit flags.`, + ); + } + + if (!/^\d+$/.test(timestamp!)) { + throwUsageError( + `Invalid --timestamp "${timestamp}". Expected Unix epoch seconds (the raw svix-timestamp header value).`, + ); + } + + const payload = options.payload + ? await readFileOrStdin(options.payload, "--payload") + : fields.payload; + + const valid = verifyWebhookSignature({ + secret: options.secret, + id: id!, + timestamp: timestamp!, + payload: payload!, + signature: signature!, + }); + + if (!valid) { + let message = "Signature verification failed: no signature entry matched."; + const deltaSeconds = Math.floor(Date.now() / 1000) - Number(timestamp); + if (Math.abs(deltaSeconds) > SKEW_HINT_THRESHOLD_SECONDS) { + message += ` Note: the timestamp is ${humanizeSkew(deltaSeconds)} — make sure it is the raw svix-timestamp header from the same delivery as the signature.`; + } + throw new CliError(message); + } + + log.success("Signature verified."); +} diff --git a/packages/cli-core/undefined/body.json b/packages/cli-core/undefined/body.json new file mode 100644 index 00000000..098657a7 --- /dev/null +++ b/packages/cli-core/undefined/body.json @@ -0,0 +1 @@ +{ "type": "user.created" } From 04cd67f695dd279fd978ac54ba68f43046055482 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:39:04 -0300 Subject: [PATCH 27/42] feat(webhooks): add pure relay protocol helpers --- .../commands/webhooks/relay-protocol.test.ts | 104 +++++++++++++++++ .../src/commands/webhooks/relay-protocol.ts | 108 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/relay-protocol.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/relay-protocol.ts diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts new file mode 100644 index 00000000..c818ce5e --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts @@ -0,0 +1,104 @@ +import { test, expect, describe } from "bun:test"; +import { + decodeEventBody, + decodeFrame, + encodeEventResponseFrame, + encodeStartFrame, + generateRelayToken, + relayReceiveUrl, +} from "./relay-protocol.ts"; + +describe("generateRelayToken", () => { + test("produces 10 base62 chars with no prefix", () => { + const token = generateRelayToken(); + expect(token).toMatch(/^[0-9A-Za-z]{10}$/); + expect(token.startsWith("c_")).toBe(false); + }); + + test("produces distinct tokens across calls", () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateRelayToken())); + expect(tokens.size).toBe(50); + }); +}); + +describe("relayReceiveUrl", () => { + test("builds the play.svix.com URL with the raw token", () => { + expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://play.svix.com/in/Ab12Cd34Ef/"); + }); +}); + +describe("encodeStartFrame", () => { + test("matches the svix-cli handshake shape", () => { + expect(JSON.parse(encodeStartFrame("Ab12Cd34Ef"))).toEqual({ + type: "start", + version: 1, + data: { token: "Ab12Cd34Ef" }, + }); + }); +}); + +describe("decodeFrame", () => { + const eventFrame = JSON.stringify({ + type: "event", + version: 1, + data: { + id: "frame_1", + method: "POST", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body: Buffer.from('{"type":"user.created"}', "utf8").toString("base64"), + }, + }); + + test("decodes an event frame", () => { + const decoded = decodeFrame(eventFrame); + expect(decoded.type).toBe("event"); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.id).toBe("frame_1"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers["svix-id"]).toBe("msg_1"); + expect(decodeEventBody(decoded.event)).toBe('{"type":"user.created"}'); + }); + + test("round-trips: a decoded event re-encodes into a valid response frame", () => { + const decoded = decodeFrame(eventFrame); + if (decoded.type !== "event") throw new Error("unreachable"); + + const reply = encodeEventResponseFrame({ + id: decoded.event.id, + status: 200, + headers: { "content-type": "application/json" }, + bodyB64: Buffer.from("{}", "utf8").toString("base64"), + }); + + expect(JSON.parse(reply)).toEqual({ + type: "event", + version: 1, + data: { + id: "frame_1", + status: 200, + headers: { "content-type": "application/json" }, + body: "e30=", + }, + }); + }); + + test.each([ + { label: "invalid JSON", raw: "{nope" }, + { label: "non-object JSON", raw: '"hello"' }, + { label: "null", raw: "null" }, + { label: "unknown frame type", raw: '{"type":"server-error","version":1,"data":{}}' }, + { label: "event frame without data", raw: '{"type":"event","version":1}' }, + { label: "event frame without an id", raw: '{"type":"event","version":1,"data":{}}' }, + ])("returns unknown for $label", ({ raw }) => { + expect(decodeFrame(raw)).toEqual({ type: "unknown" }); + }); + + test("defaults method to POST and headers/body to empty", () => { + const decoded = decodeFrame('{"type":"event","version":1,"data":{"id":"frame_2"}}'); + if (decoded.type !== "event") throw new Error("unreachable"); + expect(decoded.event.method).toBe("POST"); + expect(decoded.event.headers).toEqual({}); + expect(decoded.event.bodyB64).toBe(""); + expect(decodeEventBody(decoded.event)).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.ts new file mode 100644 index 00000000..075c235a --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.ts @@ -0,0 +1,108 @@ +/** + * Pure Svix relay protocol helpers: token generation, URLs, and frame + * encoding/decoding. Frame field names verified against the svix-cli source. + * No I/O here — everything is unit-testable without a socket. + */ + +export const RELAY_WS_URL = "wss://api.relay.svix.com/api/v1/listen/"; + +/** Close code the relay sends when another listener holds the same token. */ +export const RELAY_CLOSE_TOKEN_COLLISION = 1008; + +/** + * The relay server pings ~every 21s, but Bun's client WebSocket auto-pongs + * below the JS API (no ping/pong events). After this much silence we actively + * probe with a client ping — writes to a dead link surface as error/close, + * which triggers the same-token redial. + */ +export const RELAY_SILENCE_TIMEOUT_MS = 30_000; + +export const RELAY_RECONNECT_DELAY_MS = 1_000; + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const TOKEN_LENGTH = 10; +// Largest multiple of 62 below 256; bytes at or above it would bias the modulo. +const UNBIASED_BYTE_LIMIT = 248; + +/** 10 random base62 chars, raw — no `c_` prefix on the wire or in the URL. */ +export function generateRelayToken(): string { + let token = ""; + while (token.length < TOKEN_LENGTH) { + const bytes = new Uint8Array(TOKEN_LENGTH * 2); + crypto.getRandomValues(bytes); + for (const byte of bytes) { + if (byte >= UNBIASED_BYTE_LIMIT) continue; + token += BASE62[byte % 62]; + if (token.length === TOKEN_LENGTH) break; + } + } + return token; +} + +export function relayReceiveUrl(token: string): string { + return `https://play.svix.com/in/${token}/`; +} + +export function encodeStartFrame(token: string): string { + return JSON.stringify({ type: "start", version: 1, data: { token } }); +} + +export interface RelayEventFrame { + /** Relay-internal frame ID, echoed back in the response frame. */ + id: string; + method: string; + headers: Record; + /** Base64-encoded request body, exactly as received. */ + bodyB64: string; +} + +export type DecodedFrame = { type: "event"; event: RelayEventFrame } | { type: "unknown" }; + +export function decodeFrame(raw: string): DecodedFrame { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { type: "unknown" }; + } + if (parsed === null || typeof parsed !== "object") return { type: "unknown" }; + + const frame = parsed as { + type?: string; + data?: { id?: string; method?: string; headers?: Record; body?: string }; + }; + if (frame.type !== "event" || !frame.data || typeof frame.data.id !== "string") { + return { type: "unknown" }; + } + + return { + type: "event", + event: { + id: frame.data.id, + method: frame.data.method ?? "POST", + headers: frame.data.headers ?? {}, + bodyB64: frame.data.body ?? "", + }, + }; +} + +export function decodeEventBody(event: RelayEventFrame): string { + return Buffer.from(event.bodyB64, "base64").toString("utf8"); +} + +/** + * Frame a forward response back to the relay so Svix-side delivery telemetry + * stays honest (status, headers, and body of the local handler's response). + */ +export function encodeEventResponseFrame(reply: { + id: string; + status: number; + headers: Record; + bodyB64: string; +}): string { + return JSON.stringify({ + type: "event", + version: 1, + data: { id: reply.id, status: reply.status, headers: reply.headers, body: reply.bodyB64 }, + }); +} From 68f9fce03db946ae94c02fa5b931860dc6d956ee Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:43:27 -0300 Subject: [PATCH 28/42] feat(webhooks): add relay client, forwarder, and listen rendering --- .../src/commands/webhooks/forward.test.ts | 146 +++++++++++++++++ .../cli-core/src/commands/webhooks/forward.ts | 88 ++++++++++ .../src/commands/webhooks/relay-client.ts | 129 +++++++++++++++ .../src/commands/webhooks/render.test.ts | 155 ++++++++++++++++++ .../cli-core/src/commands/webhooks/render.ts | 136 +++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/forward.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/forward.ts create mode 100644 packages/cli-core/src/commands/webhooks/relay-client.ts create mode 100644 packages/cli-core/src/commands/webhooks/render.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/render.ts diff --git a/packages/cli-core/src/commands/webhooks/forward.test.ts b/packages/cli-core/src/commands/webhooks/forward.test.ts new file mode 100644 index 00000000..802a4d9f --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.test.ts @@ -0,0 +1,146 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { CliError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { buildForwardHeaders, forwardDelivery, parseHeaderPairs } from "./forward.ts"; + +const originalFetch = globalThis.fetch; + +describe("parseHeaderPairs", () => { + const parseCases: Array<{ + label: string; + value: string | undefined; + expected: Record; + }> = [ + { label: "undefined", value: undefined, expected: {} }, + { label: "empty string", value: "", expected: {} }, + { label: "single pair", value: "x-env:dev", expected: { "x-env": "dev" } }, + { + label: "multiple pairs with whitespace", + value: " x-env : dev , x-team:core ", + expected: { "x-env": "dev", "x-team": "core" }, + }, + { + label: "value containing colons (split on FIRST colon)", + value: "authorization:Bearer abc:def", + expected: { authorization: "Bearer abc:def" }, + }, + { label: "trailing comma", value: "x-env:dev,", expected: { "x-env": "dev" } }, + { label: "empty value", value: "x-empty:", expected: { "x-empty": "" } }, + ]; + + test.each(parseCases)("parses $label", ({ value, expected }) => { + expect(parseHeaderPairs(value)).toEqual(expected); + }); + + test.each([ + { label: "pair without a colon", value: "not-a-pair" }, + { label: "pair with an empty key", value: ":value" }, + ])("throws a usage error on $label", ({ value }) => { + expect(() => parseHeaderPairs(value)).toThrow(CliError); + }); +}); + +describe("buildForwardHeaders", () => { + const eventHeaders = { + "svix-id": "msg_1", + "svix-timestamp": "1717935000", + "svix-signature": "v1,abc", + "content-type": "application/json", + }; + + test("preserves delivery headers and adds extras", () => { + const headers = buildForwardHeaders(eventHeaders, { "x-env": "dev" }); + + expect(headers.get("svix-id")).toBe("msg_1"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-env")).toBe("dev"); + }); + + test("extras may override non-svix delivery headers", () => { + const headers = buildForwardHeaders(eventHeaders, { "Content-Type": "text/plain" }); + + expect(headers.get("content-type")).toBe("text/plain"); + }); + + test.each([ + { label: "lowercase", key: "svix-signature" }, + { label: "uppercase", key: "SVIX-SIGNATURE" }, + { label: "mixed case", key: "Svix-Signature" }, + ])("extras can never override svix-* headers ($label)", ({ key }) => { + const headers = buildForwardHeaders(eventHeaders, { [key]: "v1,forged" }); + + expect(headers.get("svix-signature")).toBe("v1,abc"); + }); +}); + +describe("forwardDelivery", () => { + useCaptureLog(); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs the body with headers and captures the response", async () => { + let captured: { url: string; method: string; body: string; headers: Headers } | undefined; + stubFetch(async (input, init) => { + captured = { + url: input.toString(), + method: init?.method ?? "GET", + body: String(init?.body), + headers: new Headers(init?.headers), + }; + return new Response("ok body", { status: 200, headers: { "x-served-by": "test" } }); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: buildForwardHeaders({ "svix-id": "msg_1" }, {}), + body: '{"type":"user.created"}', + }); + + expect(captured?.url).toBe("http://localhost:3000/api/webhooks"); + expect(captured?.method).toBe("POST"); + expect(captured?.body).toBe('{"type":"user.created"}'); + expect(captured?.headers.get("svix-id")).toBe("msg_1"); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(200); + expect(outcome.bodyText).toBe("ok body"); + expect(outcome.bodyB64).toBe(Buffer.from("ok body", "utf8").toString("base64")); + expect(outcome.headers["x-served-by"]).toBe("test"); + expect(outcome.latencyMs).toBeGreaterThanOrEqual(0); + }); + + test("returns a synthetic 502 when the local handler is unreachable", async () => { + stubFetch(async () => { + throw new Error("connection refused"); + }); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:9/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(true); + expect(outcome.status).toBe(502); + expect(outcome.bodyText).toContain("connection refused"); + }); + + test("non-2xx handler responses are captured, not thrown", async () => { + stubFetch(async () => new Response("boom", { status: 500 })); + + const outcome = await forwardDelivery({ + forwardTo: "http://localhost:3000/api/webhooks", + method: "POST", + headers: new Headers(), + body: "{}", + }); + + expect(outcome.failed).toBe(false); + expect(outcome.status).toBe(500); + expect(outcome.bodyText).toBe("boom"); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/forward.ts b/packages/cli-core/src/commands/webhooks/forward.ts new file mode 100644 index 00000000..19cc9ece --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/forward.ts @@ -0,0 +1,88 @@ +import { errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; + +export interface ForwardOutcome { + status: number; + headers: Record; + bodyText: string; + bodyB64: string; + latencyMs: number; + /** True when the local handler was unreachable (status is a synthetic 502). */ + failed: boolean; +} + +/** Comma-separated `k:v` pairs, split on the FIRST colon, whitespace trimmed. */ +export function parseHeaderPairs(value: string | undefined): Record { + if (!value) return {}; + const headers: Record = {}; + for (const pair of value.split(",")) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const colonIndex = trimmed.indexOf(":"); + const key = colonIndex === -1 ? "" : trimmed.slice(0, colonIndex).trim(); + if (!key) { + throwUsageError(`Invalid --headers pair "${trimmed}". Expected key:value.`); + } + headers[key] = trimmed.slice(colonIndex + 1).trim(); + } + return headers; +} + +/** + * Delivery headers plus `--headers` extras. Extras may override non-svix + * delivery headers, but the delivery's `svix-*` headers always win — they are + * what `verify` (and the user's handler) authenticate against. + */ +export function buildForwardHeaders( + eventHeaders: Record, + extraHeaders: Record, +): Headers { + const headers = new Headers(eventHeaders); + for (const [key, value] of Object.entries(extraHeaders)) { + if (key.toLowerCase().startsWith("svix-")) continue; + headers.set(key, value); + } + return headers; +} + +export async function forwardDelivery(args: { + forwardTo: string; + method: string; + headers: Headers; + body: string; +}): Promise { + const startedAt = performance.now(); + try { + const response = await loggedFetch(args.forwardTo, { + tag: "relay", + method: args.method, + headers: args.headers, + body: args.body, + }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { + status: response.status, + headers, + bodyText, + bodyB64: Buffer.from(bodyText, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: false, + }; + } catch (error) { + // Local handler unreachable. Frame a synthetic 502 back so Svix-side + // delivery telemetry records the failure instead of a hung attempt. + const message = errorMessage(error); + return { + status: 502, + headers: {}, + bodyText: message, + bodyB64: Buffer.from(message, "utf8").toString("base64"), + latencyMs: Math.round(performance.now() - startedAt), + failed: true, + }; + } +} diff --git a/packages/cli-core/src/commands/webhooks/relay-client.ts b/packages/cli-core/src/commands/webhooks/relay-client.ts new file mode 100644 index 00000000..586edb66 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -0,0 +1,129 @@ +import { log } from "../../lib/log.ts"; +import { + RELAY_CLOSE_TOKEN_COLLISION, + RELAY_RECONNECT_DELAY_MS, + RELAY_SILENCE_TIMEOUT_MS, + RELAY_WS_URL, + decodeFrame, + encodeStartFrame, + generateRelayToken, + type RelayEventFrame, +} from "./relay-protocol.ts"; + +export interface RelayClientOptions { + token: string; + /** Called per inbound delivery; `reply` sends a response frame back. */ + onEvent: (event: RelayEventFrame, reply: (frame: string) => void) => void; + /** 1008 collision → a fresh token was generated; persist it (and re-point the endpoint). */ + onTokenRotated: (token: string) => Promise; + /** Connection dropped; redialing with the same token. */ + onReconnect: () => void; + /** Test/env override for the relay WebSocket URL. */ + url?: string; +} + +/** + * Long-lived relay WebSocket using Bun's built-in client. Reconnects with the + * same token (the relay URL — and therefore the registered endpoint — never + * changes across reconnects); rotates the token only on close code 1008. + */ +export class RelayClient { + token: string; + + private ws: WebSocket | undefined; + private stopped = false; + private probeTimer: ReturnType | undefined; + private lastActivityAt = Date.now(); + private resolveFirstOpen: (() => void) | undefined; + + constructor(private readonly options: RelayClientOptions) { + this.token = options.token; + } + + /** Dial and resolve once the first connection is open and handshaken. */ + start(): Promise { + const opened = new Promise((resolve) => { + this.resolveFirstOpen = resolve; + }); + this.connect(); + return opened; + } + + /** Close the socket and stop reconnecting. Never deletes the relay endpoint. */ + stop(): void { + this.stopped = true; + this.clearProbe(); + this.ws?.close(1000); + } + + private connect(): void { + if (this.stopped) return; + + const ws = new WebSocket(this.options.url ?? RELAY_WS_URL); + this.ws = ws; + + ws.onopen = () => { + log.debug(`relay: connected, sending start frame (token=${this.token})`); + ws.send(encodeStartFrame(this.token)); + this.lastActivityAt = Date.now(); + this.startProbe(ws); + this.resolveFirstOpen?.(); + this.resolveFirstOpen = undefined; + }; + + ws.onmessage = (message) => { + this.lastActivityAt = Date.now(); + const raw = typeof message.data === "string" ? message.data : String(message.data); + const decoded = decodeFrame(raw); + if (decoded.type !== "event") { + log.debug(`relay: ignoring non-event frame: ${raw.slice(0, 200)}`); + return; + } + this.options.onEvent(decoded.event, (frame) => { + if (ws.readyState === WebSocket.OPEN) ws.send(frame); + }); + }; + + ws.onerror = () => { + log.debug("relay: socket error"); + }; + + ws.onclose = (event) => { + this.clearProbe(); + if (this.stopped) return; + + if (event.code === RELAY_CLOSE_TOKEN_COLLISION) { + // Another listener holds this token: rotate, persist, redial. + this.token = generateRelayToken(); + log.debug("relay: token collision (1008), rotating token"); + void this.options.onTokenRotated(this.token).then(() => this.connect()); + return; + } + + log.debug(`relay: connection closed (code=${event.code}), reconnecting`); + this.options.onReconnect(); + setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); + }; + } + + private startProbe(ws: WebSocket): void { + this.clearProbe(); + // Bun's client WebSocket auto-pongs server pings below the JS API, so + // silence is unobservable directly. After RELAY_SILENCE_TIMEOUT_MS without + // any message we send a client ping: writes to a dead link fail and fire + // close/error, which triggers the same-token redial above. + this.probeTimer = setInterval(() => { + if (Date.now() - this.lastActivityAt < RELAY_SILENCE_TIMEOUT_MS) return; + try { + ws.ping(); + } catch { + ws.close(); + } + }, RELAY_SILENCE_TIMEOUT_MS / 2); + } + + private clearProbe(): void { + if (this.probeTimer) clearInterval(this.probeTimer); + this.probeTimer = undefined; + } +} diff --git a/packages/cli-core/src/commands/webhooks/render.test.ts b/packages/cli-core/src/commands/webhooks/render.test.ts new file mode 100644 index 00000000..a36ca168 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -0,0 +1,155 @@ +import { test, expect, describe } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import type { ForwardOutcome } from "./forward.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; + +function outcome(overrides: Partial = {}): ForwardOutcome { + return { + status: 200, + headers: {}, + bodyText: "", + bodyB64: "", + latencyMs: 12, + failed: false, + ...overrides, + }; +} + +describe("buildReadyLine", () => { + test("matches the agent-mode ready contract", () => { + const line = buildReadyLine({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: ["user.created"], + forwardTo: "http://localhost:3000/api/webhooks", + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + signing_secret: "whsec_abc", + endpoint_id: "ep_1", + events_filter: ["user.created"], + }); + }); +}); + +describe("buildEventLine", () => { + test("matches the agent-mode event contract", () => { + const line = buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + bodyB64: "e30=", + forwardStatus: 200, + latencyMs: 12, + }); + + expect(line).not.toContain("\n"); + expect(JSON.parse(line)).toEqual({ + type: "event", + svix_id: "msg_1", + event_type: "user.created", + headers: { "svix-id": "msg_1", "svix-timestamp": "1717935000", "svix-signature": "v1,abc" }, + body_b64: "e30=", + forward_status: 200, + latency_ms: 12, + }); + }); + + test("forward_status is null when not forwarding", () => { + const parsed = JSON.parse( + buildEventLine({ + svixId: "msg_1", + eventType: "user.created", + headers: {}, + bodyB64: "", + forwardStatus: null, + latencyMs: 0, + }), + ) as { forward_status: number | null }; + + expect(parsed.forward_status).toBeNull(); + }); +}); + +describe("human rendering", () => { + const captured = useCaptureLog(); + + test("ready banner shows the secret, relay URL, and endpoint", () => { + renderReadyBanner({ + relayUrl: "https://play.svix.com/in/Ab12Cd34Ef/", + signingSecret: "whsec_abc", + endpointId: "ep_1", + eventsFilter: null, + forwardTo: null, + }); + + expect(captured.err).toContain("whsec_abc"); + expect(captured.err).toContain("https://play.svix.com/in/Ab12Cd34Ef/"); + expect(captured.err).toContain("ep_1"); + expect(captured.err).toContain("NOT your Dashboard endpoint secret"); + expect(captured.err).toContain("not forwarding"); + expect(captured.out).toBe(""); + }); + + test("arrival and result lines follow the time --> / <-- format", () => { + renderArrival("user.created", "msg_1"); + renderForwardResult(outcome({ status: 200 }), "POST", "/api/webhooks"); + + const plain = captured.err.replace(/\x1b\[[0-9;]*m/g, ""); + expect(plain).toMatch(/\d{2}:\d{2}:\d{2} --> user\.created msg_1\n/); + expect(plain).toMatch(/\d{2}:\d{2}:\d{2} <-- 200 POST \/api\/webhooks 12ms\n/); + }); + + test("verification warning names the delivery", () => { + renderVerificationWarning("msg_1"); + + expect(captured.err).toContain("signature verification failed for msg_1"); + }); + + test.each([ + { + label: "401 → middleware hint", + forward: outcome({ status: 401 }), + expected: "createRouteMatcher(['/api/webhooks(.*)'])", + }, + { + label: "400 → raw-body hint", + forward: outcome({ status: 400 }), + expected: "RAW request body", + }, + { + label: "unreachable handler → dev-server hint", + forward: outcome({ status: 502, failed: true, bodyText: "connection refused" }), + expected: "Is your dev server running", + }, + ])("$label", ({ forward, expected }) => { + renderForwardDiagnostics(forward, "msg_1"); + + expect(captured.err).toContain(expected); + }); + + test("5xx diagnostics include the response body and the replay command", () => { + renderForwardDiagnostics(outcome({ status: 500, bodyText: "stack trace here" }), "msg_9"); + + expect(captured.err).toContain("stack trace here"); + expect(captured.err).toContain("clerk webhooks replay msg_9"); + }); + + test("2xx responses produce no diagnostics", () => { + renderForwardDiagnostics(outcome({ status: 204 }), "msg_1"); + + expect(captured.err).toBe(""); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/render.ts b/packages/cli-core/src/commands/webhooks/render.ts new file mode 100644 index 00000000..f53d3a8d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/render.ts @@ -0,0 +1,136 @@ +/** + * Rendering for `webhooks listen`. Per-delivery lines go through + * `log.ui(line + "\n")` — every other stderr channel shares a 5-then-suppress + * throttle per 1s window that would eat delivery bursts. + */ + +import { bold, cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import type { ForwardOutcome } from "./forward.ts"; + +export interface ReadyInfo { + relayUrl: string; + signingSecret: string; + endpointId: string; + eventsFilter: string[] | null; + forwardTo: string | null; +} + +/** NDJSON ready line (stdout in agent/--json mode). */ +export function buildReadyLine(info: ReadyInfo): string { + return JSON.stringify({ + type: "ready", + relay_url: info.relayUrl, + signing_secret: info.signingSecret, + endpoint_id: info.endpointId, + events_filter: info.eventsFilter, + }); +} + +/** NDJSON per-delivery line; saved to a file it feeds `verify --delivery`. */ +export function buildEventLine(args: { + svixId: string; + eventType: string; + headers: Record; + bodyB64: string; + forwardStatus: number | null; + latencyMs: number; +}): string { + return JSON.stringify({ + type: "event", + svix_id: args.svixId, + event_type: args.eventType, + headers: args.headers, + body_b64: args.bodyB64, + forward_status: args.forwardStatus, + latency_ms: args.latencyMs, + }); +} + +export function renderReadyBanner(info: ReadyInfo): void { + const forwarding = info.forwardTo ?? dim("(not forwarding — printing events only)"); + const events = info.eventsFilter?.length ? info.eventsFilter.join(", ") : "all"; + log.ui( + [ + "", + `${bold("Webhook relay ready")}`, + ` Endpoint: ${cyan(info.endpointId)}`, + ` Relay URL: ${info.relayUrl}`, + ` Signing secret: ${info.signingSecret}`, + ` ${dim("(local relay endpoint secret, NOT your Dashboard endpoint secret)")}`, + ` Forwarding to: ${forwarding}`, + ` Events: ${events}`, + "", + ` ${dim("Press Ctrl+C to stop. The relay endpoint and secret persist across restarts.")}`, + "", + "", + ].join("\n"), + ); +} + +function timeOfDay(): string { + return new Date().toTimeString().slice(0, 8); +} + +export function renderArrival(eventType: string, svixId: string): void { + log.ui(`${dim(timeOfDay())} ${cyan("-->")} ${eventType} ${dim(svixId)}\n`); +} + +export function renderForwardResult(outcome: ForwardOutcome, method: string, path: string): void { + const color = outcome.status >= 500 ? red : outcome.status >= 400 ? yellow : green; + log.ui( + `${dim(timeOfDay())} ${color(`<-- ${outcome.status}`)} ${method} ${path} ${dim(`${outcome.latencyMs}ms`)}\n`, + ); +} + +export function renderVerificationWarning(svixId: string): void { + log.ui( + yellow( + ` ! signature verification failed for ${svixId} — the relay secret does not match this delivery. Forwarding anyway; pass --skip-verify to silence.\n`, + ), + ); +} + +const BODY_PREVIEW_LIMIT = 500; + +export function renderForwardDiagnostics(outcome: ForwardOutcome, svixId: string): void { + if (outcome.failed) { + log.ui( + yellow(` ! could not reach the local handler: ${outcome.bodyText}\n`) + + dim(" Is your dev server running on the --forward-to URL?\n"), + ); + return; + } + + if (outcome.status === 401) { + log.ui( + yellow(" ! 401 from your handler — middleware is likely protecting the webhook route.\n") + + dim( + " In clerkMiddleware(), allow it with createRouteMatcher(['/api/webhooks(.*)']) as a public route.\n", + ), + ); + return; + } + + if (outcome.status === 400) { + log.ui( + yellow(" ! 400 from your handler — usually a signature check on a parsed body.\n") + + dim( + " Pass the RAW request body to verifyWebhook(); read it before any JSON body parsing.\n", + ), + ); + return; + } + + if (outcome.status >= 500) { + const preview = + outcome.bodyText.length > BODY_PREVIEW_LIMIT + ? `${outcome.bodyText.slice(0, BODY_PREVIEW_LIMIT)}...` + : outcome.bodyText; + log.ui( + yellow(` ! ${outcome.status} from your handler. Response body:\n`) + + (preview ? ` ${preview}\n` : dim(" (empty)\n")) + + dim(` Fix the handler, then resend this delivery: clerk webhooks replay ${svixId}\n`), + ); + } +} From ccbd2aa7d9333f9ef266ac30719e94b55498937e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:46:48 -0300 Subject: [PATCH 29/42] feat(webhooks): add 'webhooks listen' command --- packages/cli-core/src/cli-program.ts | 34 ++ .../cli-core/src/commands/webhooks/README.md | 33 ++ .../cli-core/src/commands/webhooks/index.ts | 2 + .../src/commands/webhooks/listen.test.ts | 338 ++++++++++++++++++ .../cli-core/src/commands/webhooks/listen.ts | 271 ++++++++++++++ 5 files changed, 678 insertions(+) create mode 100644 packages/cli-core/src/commands/webhooks/listen.test.ts create mode 100644 packages/cli-core/src/commands/webhooks/listen.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index d9b79ccb..8ef674c5 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -495,6 +495,40 @@ export function createProgram(): Program { ), ); : add offline 'webhooks verify' command) + + webhooks + .command("listen") + .description("Stream instance events to your terminal and forward them to a local handler") + .option("--forward-to ", "Local URL to POST deliveries to (omit to just print events)") + .option( + "--events ", + "Comma-separated event types to filter on (PATCHes the shared relay endpoint's filter)", + ) + .option("--skip-verify", "Skip HMAC verification of incoming deliveries") + .option( + "--headers ", + "Extra headers for the forwarded request, comma-separated k:v pairs (svix-* cannot be overridden)", + ) + .setExamples([ + { + command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", + description: "Forward instance events to a local handler", + }, + { + command: "clerk webhooks listen --events user.created,user.deleted", + description: "Only receive specific event types", + }, + { + command: "clerk webhooks listen --json", + description: "Emit NDJSON event lines (pipe into a file for `webhooks verify --delivery`)", + }, + ]) + .action((_opts, cmd) => + webhooksHandlers.listen( + cmd.optsWithGlobals() as Parameters[0], + ), + ); +: add 'webhooks listen' command) return program; } diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index d4cb1850..2ae70354 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -226,3 +226,36 @@ Explicit flags override fields parsed from `--delivery`. A `listen` event line s ### API endpoints None — pure offline computation. + +## `clerk webhooks listen` + +Dials the Svix relay (`wss://api.relay.svix.com/api/v1/listen/`), registers a **persistent** per-instance relay endpoint pointing at `https://play.svix.com/in//`, and forwards incoming deliveries to a local handler. + +```sh +clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [--headers k:v,...] +``` + +| Option | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--forward-to ` | Local URL to POST deliveries to. Omitted: events are received, verified, and printed with `forward_status: null`. | +| `--events ` | Sets `filter_types` on the relay endpoint. If the persisted endpoint has different filters it is PATCHed — with a warning, since other `listen` sessions share this instance's relay endpoint. | +| `--skip-verify` | Skip per-delivery HMAC verification. | +| `--headers ` | Comma-separated `k:v` extras on the forwarded POST (split on the FIRST colon). The delivery's `svix-*` headers always win. | + +Behavior notes: + +- **Relay token**: 10 random base62 chars, raw on the wire (no `c_` prefix), persisted per instance in the CLI config (`relay..token`). Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. +- **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). After 30s of silence the client sends an active `ws.ping()` probe — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. +- **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay ` line; unreachable handler → synthetic **502** framed back to the relay. +- **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events). +- **Agent/`--json` mode**: NDJSON on stdout — one `ready` line (`relay_url`, `signing_secret`, `endpoint_id`, `events_filter`), then one `event` line per delivery (`svix_id`, `event_type`, `headers`, `body_b64`, `forward_status`, `latency_ms`). An event line saved to a file is directly consumable by `verify --delivery @file`. +- **SIGINT**: `listen` replaces the global cleanup-free handler before opening the socket: close socket, drain in-flight forwards, exit 130. The relay endpoint is **never** deleted on exit — its URL and `whsec_` stay stable across restarts. `listen` never exits 0. + +### API endpoints + +| Method | Endpoint | Description | +| ------- | ------------------------------- | ---------------------------------------------------------- | +| `GET` | `/webhooks/{endpointID}` | Reuse check for the persisted relay endpoint. | +| `PATCH` | `/webhooks/{endpointID}` | Re-point URL after token rotation / update `filter_types`. | +| `POST` | `/webhooks` | Create the relay endpoint on first run (or after a 404). | +| `GET` | `/webhooks/{endpointID}/secret` | Fetch the relay endpoint's signing secret at startup. | diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index 41b8206e..f71e2ec3 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -3,6 +3,7 @@ import { webhooksDelete } from "./delete.ts"; import { webhooksEventTypes } from "./event-types.ts"; import { webhooksGet } from "./get.ts"; import { webhooksList } from "./list.ts"; +import { webhooksListen } from "./listen.ts"; import { webhooksMessages } from "./messages.ts"; import { webhooksOpen } from "./open.ts"; import { webhooksReplay } from "./replay.ts"; @@ -24,4 +25,5 @@ export const webhooks = { trigger: webhooksTrigger, open: webhooksOpen, verify: webhooksVerify, + listen: webhooksListen, }; diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts new file mode 100644 index 00000000..1091cbb9 --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -0,0 +1,338 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { createHmac, randomBytes } from "node:crypto"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import type { RelayEventFrame } from "./relay-protocol.ts"; + +type EventHandler = (event: RelayEventFrame, reply: (frame: string) => void) => void; + +interface FakeClientOptions { + token: string; + onEvent: EventHandler; + onTokenRotated: (token: string) => Promise; + onReconnect: () => void; +} + +let lastClient: FakeRelayClient | undefined; + +class FakeRelayClient { + token: string; + started = false; + stopped = false; + + constructor(readonly options: FakeClientOptions) { + this.token = options.token; + lastClient = this; + } + + start(): Promise { + this.started = true; + return Promise.resolve(); + } + + stop(): void { + this.stopped = true; + } +} + +mock.module("./relay-client.ts", () => ({ RelayClient: FakeRelayClient })); + +const mockGetWebhookEndpoint = mock(); +const mockCreateWebhookEndpoint = mock(); +const mockUpdateWebhookEndpoint = mock(); +const mockGetWebhookEndpointSecret = mock(); +mock.module("../../lib/plapi.ts", () => ({ + getWebhookEndpoint: (...args: unknown[]) => mockGetWebhookEndpoint(...args), + createWebhookEndpoint: (...args: unknown[]) => mockCreateWebhookEndpoint(...args), + updateWebhookEndpoint: (...args: unknown[]) => mockUpdateWebhookEndpoint(...args), + getWebhookEndpointSecret: (...args: unknown[]) => mockGetWebhookEndpointSecret(...args), +})); + +const mockResolveAppContext = mock(); +const mockGetRelayEntry = mock(); +const mockSetRelayEntry = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + getRelayEntry: (...args: unknown[]) => mockGetRelayEntry(...args), + setRelayEntry: (...args: unknown[]) => mockSetRelayEntry(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { webhooksListen } = await import("./listen.ts"); + +const KEY = randomBytes(24); +const SECRET = `whsec_${KEY.toString("base64")}`; + +const relayEndpoint = (overrides: Record = {}) => ({ + id: "ep_relay", + url: "https://play.svix.com/in/Ab12Cd34Ef/", + version: 1, + disabled: false, + filter_types: null, + channels: null, + created_at: "2026-06-09T00:00:00Z", + updated_at: "2026-06-09T00:00:00Z", + ...overrides, +}); + +function signedEvent(body: string, overrides: Partial = {}): RelayEventFrame { + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = `v1,${createHmac("sha256", KEY).update(`msg_1.${timestamp}.${body}`, "utf8").digest("base64")}`; + return { + id: "frame_1", + method: "POST", + headers: { + "svix-id": "msg_1", + "svix-timestamp": timestamp, + "svix-signature": signature, + "content-type": "application/json", + }, + bodyB64: Buffer.from(body, "utf8").toString("base64"), + ...overrides, + }; +} + +/** listen never resolves; run it and wait until the ready output lands. */ +async function startListen( + options: Parameters[0], + captured: { out: string; err: string }, +): Promise { + const run = webhooksListen(options); + run.catch(() => {}); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (captured.out.includes('"ready"') || captured.err.includes("Webhook relay ready")) return; + } + throw new Error("listen never became ready"); +} + +describe("webhooks listen", () => { + const captured = useCaptureLog(); + const originalFetch = globalThis.fetch; + let savedSigintListeners: NodeJS.SignalsListener[] = []; + + beforeEach(() => { + savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; + mockIsAgent.mockReturnValue(false); + lastClient = undefined; + mockResolveAppContext.mockResolvedValue({ + appId: "app_1", + appLabel: "My App", + instanceId: "ins_1", + instanceLabel: "development", + }); + mockGetRelayEntry.mockResolvedValue({ token: "Ab12Cd34Ef", endpoint_id: "ep_relay" }); + mockSetRelayEntry.mockResolvedValue(undefined); + mockGetWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockCreateWebhookEndpoint.mockResolvedValue(relayEndpoint()); + mockUpdateWebhookEndpoint.mockImplementation( + async (_app: string, _ins: string, _ep: string, patch: Record) => + relayEndpoint(patch), + ); + mockGetWebhookEndpointSecret.mockResolvedValue({ secret: SECRET }); + }); + + afterEach(() => { + process.removeAllListeners("SIGINT"); + for (const listener of savedSigintListeners) process.on("SIGINT", listener); + globalThis.fetch = originalFetch; + mockGetWebhookEndpoint.mockReset(); + mockCreateWebhookEndpoint.mockReset(); + mockUpdateWebhookEndpoint.mockReset(); + mockGetWebhookEndpointSecret.mockReset(); + mockResolveAppContext.mockReset(); + mockGetRelayEntry.mockReset(); + mockSetRelayEntry.mockReset(); + mockIsAgent.mockReset(); + }); + + test("invalid --headers is a usage error before any network call", async () => { + await expect(webhooksListen({ headers: "not-a-pair" })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockResolveAppContext).not.toHaveBeenCalled(); + }); + + test("first run generates and persists a 10-char base62 token, then creates the endpoint", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await startListen({}, captured); + + const persistedToken = (mockSetRelayEntry.mock.calls[0]?.[1] as { token: string }).token; + expect(mockSetRelayEntry.mock.calls[0]?.[0]).toBe("ins_1"); + expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { + url: `https://play.svix.com/in/${persistedToken}/`, + version: 1, + }); + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: persistedToken, + endpoint_id: "ep_relay", + }); + }); + + test("reuses the persisted endpoint without patching when nothing changed", async () => { + await startListen({}, captured); + + expect(mockGetWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay"); + expect(mockCreateWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + expect(captured.err).toContain("Webhook relay ready"); + expect(captured.err).toContain(SECRET); + }); + + test("PATCHes filter_types (with a warning) when --events differs", async () => { + await startListen({ events: "user.created,user.deleted" }, captured); + + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + filter_types: ["user.created", "user.deleted"], + }); + expect(captured.err).toContain("affects any other"); + }); + + test("recreates the endpoint when the persisted one returns 404", async () => { + mockGetWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); + + await startListen({}, captured); + + expect(mockCreateWebhookEndpoint).toHaveBeenCalled(); + }); + + test("emits the NDJSON ready line in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ forwardTo: "http://localhost:3000/api/webhooks" }, captured); + + const ready = JSON.parse(captured.out) as Record; + expect(ready).toEqual({ + type: "ready", + relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", + signing_secret: SECRET, + endpoint_id: "ep_relay", + events_filter: null, + }); + }); + + test("registers its own SIGINT handler before the socket opens", async () => { + await startListen({}, captured); + + expect(process.listenerCount("SIGINT")).toBe(1); + expect(lastClient?.started).toBe(true); + }); + + test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const replies: string[] = []; + lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(JSON.parse(replies[0]!)).toEqual({ + type: "event", + version: 1, + data: { id: "frame_1", status: 200, headers: {}, body: "" }, + }); + + const line = JSON.parse(captured.out) as Record; + expect(line.type).toBe("event"); + expect(line.svix_id).toBe("msg_1"); + expect(line.event_type).toBe("user.created"); + expect(line.forward_status).toBeNull(); + expect(captured.err).toBe(""); // no verification warning for a valid signature + }); + + test("delivery with --forward-to POSTs to the handler and frames the response back", async () => { + mockIsAgent.mockReturnValue(true); + let forwarded: { url: string; headers: Headers; body: string } | undefined; + stubFetch(async (input, init) => { + forwarded = { + url: input.toString(), + headers: new Headers(init?.headers), + body: String(init?.body), + }; + return new Response("handled", { status: 201 }); + }); + + await startListen( + { forwardTo: "http://localhost:3000/api/webhooks", headers: "x-env:dev" }, + captured, + ); + captured.clear(); + + const replies: string[] = []; + lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + replies.push(frame), + ); + for (let i = 0; i < 20 && replies.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(forwarded?.url).toBe("http://localhost:3000/api/webhooks"); + expect(forwarded?.headers.get("svix-id")).toBe("msg_1"); + expect(forwarded?.headers.get("x-env")).toBe("dev"); + expect(forwarded?.body).toBe('{"type":"user.created"}'); + + const reply = JSON.parse(replies[0]!) as { data: { status: number; body: string } }; + expect(reply.data.status).toBe(201); + expect(Buffer.from(reply.data.body, "base64").toString("utf8")).toBe("handled"); + + const line = JSON.parse(captured.out) as { forward_status: number }; + expect(line.forward_status).toBe(201); + }); + + test("warns on an invalid signature but still forwards", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({}, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toContain("signature verification failed for msg_1"); + expect(captured.out).toContain('"type":"event"'); + }); + + test("--skip-verify suppresses the signature warning", async () => { + mockIsAgent.mockReturnValue(true); + + await startListen({ skipVerify: true }, captured); + captured.clear(); + + const event = signedEvent('{"type":"user.created"}'); + event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; + lastClient!.options.onEvent(event, () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.err).toBe(""); + }); + + test("token rotation persists the new token and re-points the endpoint URL", async () => { + await startListen({}, captured); + + await lastClient!.options.onTokenRotated("Zz98Yy76Xx"); + + expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { + token: "Zz98Yy76Xx", + endpoint_id: "ep_relay", + }); + expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { + url: "https://play.svix.com/in/Zz98Yy76Xx/", + }); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts new file mode 100644 index 00000000..30279f3d --- /dev/null +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -0,0 +1,271 @@ +import { getRelayEntry, resolveAppContext, setRelayEntry } from "../../lib/config.ts"; +import { EXIT_CODE, PlapiError, errorMessage } from "../../lib/errors.ts"; +import { dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { + createWebhookEndpoint, + getWebhookEndpoint, + getWebhookEndpointSecret, + updateWebhookEndpoint, + type UpdateWebhookEndpointParams, + type WebhookEndpoint, +} from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { + buildForwardHeaders, + forwardDelivery, + parseHeaderPairs, + type ForwardOutcome, +} from "./forward.ts"; +import { RelayClient } from "./relay-client.ts"; +import { + decodeEventBody, + encodeEventResponseFrame, + generateRelayToken, + relayReceiveUrl, + type RelayEventFrame, +} from "./relay-protocol.ts"; +import { + buildEventLine, + buildReadyLine, + renderArrival, + renderForwardDiagnostics, + renderForwardResult, + renderReadyBanner, + renderVerificationWarning, +} from "./render.ts"; +import { splitCommaList, type WebhooksGlobalOptions } from "./shared.ts"; +import { verifyWebhookSignature } from "./verify.ts"; + +export interface WebhooksListenOptions extends WebhooksGlobalOptions { + forwardTo?: string; + events?: string; + skipVerify?: boolean; + headers?: string; +} + +interface ListenContext { + appId: string; + instanceId: string; +} + +function sameFilter(current: string[] | null | undefined, next: string[]): boolean { + const a = [...(current ?? [])].sort(); + const b = [...next].sort(); + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * Find-or-create is CLIENT-side: reuse the persisted endpoint ID, re-pointing + * its URL if the token rotated and PATCHing `filter_types` when `--events` + * differs. On 404 (or first run) create and persist. The backend does no + * URL-uniqueness matching. + */ +async function ensureRelayEndpoint( + ctx: ListenContext, + token: string, + eventsFilter: string[] | undefined, +): Promise { + const relayUrl = relayReceiveUrl(token); + const entry = await getRelayEntry(ctx.instanceId); + + if (entry?.endpoint_id) { + try { + let endpoint = await getWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id); + const patch: UpdateWebhookEndpointParams = {}; + if (endpoint.url !== relayUrl) patch.url = relayUrl; + if (eventsFilter && !sameFilter(endpoint.filter_types, eventsFilter)) { + log.warn( + "Updating the relay endpoint's event filter — this affects any other `listen` session sharing this instance's relay endpoint.", + ); + patch.filter_types = eventsFilter; + } + if (Object.keys(patch).length > 0) { + endpoint = await updateWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id, patch); + } + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; + } catch (error) { + if (!(error instanceof PlapiError && error.status === 404)) throw error; + // The persisted endpoint was deleted out from under us — recreate. + } + } + + const endpoint = await createWebhookEndpoint(ctx.appId, ctx.instanceId, { + url: relayUrl, + version: 1, + ...(eventsFilter ? { filter_types: eventsFilter } : {}), + }); + await setRelayEntry(ctx.instanceId, { token, endpoint_id: endpoint.id }); + return endpoint; +} + +function extractEventType(body: string): string { + try { + const parsed = JSON.parse(body) as { type?: unknown }; + if (typeof parsed.type === "string" && parsed.type) return parsed.type; + } catch { + // Non-JSON bodies still render; the type is just unknown. + } + return "unknown"; +} + +function forwardPath(forwardTo: string): string { + try { + return new URL(forwardTo).pathname; + } catch { + return forwardTo; + } +} + +export async function webhooksListen(options: WebhooksListenOptions = {}): Promise { + const ndjson = Boolean(options.json) || isAgent(); + const extraHeaders = parseHeaderPairs(options.headers); + const rawFilter = splitCommaList(options.events); + const eventsFilter = rawFilter?.length ? rawFilter : undefined; + + const ctx = await resolveAppContext(options); + + const entry = await getRelayEntry(ctx.instanceId); + let token = entry?.token; + if (!token) { + token = generateRelayToken(); + await setRelayEntry(ctx.instanceId, { ...entry, token }); + } + + const inFlight = new Set>(); + let client: RelayClient | undefined; + let endpointSecret = ""; + + // Own SIGINT handling, registered BEFORE the socket opens. The global + // handler (cli.ts) is a cleanup-free exit(130) and would fire first, so it + // has to go: close the socket, drain in-flight forwards, then exit 130. + // The relay endpoint is never deleted — its URL and secret stay stable. + process.removeAllListeners("SIGINT"); + process.on("SIGINT", () => { + void (async () => { + client?.stop(); + await Promise.allSettled([...inFlight]); + process.exit(EXIT_CODE.SIGINT); + })(); + }); + + async function processDelivery( + event: RelayEventFrame, + reply: (frame: string) => void, + ): Promise { + const body = decodeEventBody(event); + const svixId = event.headers["svix-id"] ?? event.id; + const eventType = extractEventType(body); + + if (!options.skipVerify) { + const verified = verifyWebhookSignature({ + secret: endpointSecret, + id: svixId, + timestamp: event.headers["svix-timestamp"] ?? "", + payload: body, + signature: event.headers["svix-signature"] ?? "", + }); + if (!verified) renderVerificationWarning(svixId); + } + + if (!ndjson) renderArrival(eventType, svixId); + + let outcome: ForwardOutcome | null = null; + if (options.forwardTo) { + outcome = await forwardDelivery({ + forwardTo: options.forwardTo, + method: event.method, + headers: buildForwardHeaders(event.headers, extraHeaders), + body, + }); + reply( + encodeEventResponseFrame({ + id: event.id, + status: outcome.status, + headers: outcome.headers, + bodyB64: outcome.bodyB64, + }), + ); + } else { + // No local handler: frame a synthetic 200 so Svix-side delivery + // telemetry records a completed attempt instead of a hang. + reply(encodeEventResponseFrame({ id: event.id, status: 200, headers: {}, bodyB64: "" })); + } + + if (ndjson) { + log.data( + buildEventLine({ + svixId, + eventType, + headers: event.headers, + bodyB64: event.bodyB64, + forwardStatus: outcome ? outcome.status : null, + latencyMs: outcome?.latencyMs ?? 0, + }), + ); + return; + } + + if (outcome) { + renderForwardResult(outcome, event.method, forwardPath(options.forwardTo!)); + renderForwardDiagnostics(outcome, svixId); + } + } + + client = new RelayClient({ + token, + onEvent: (event, reply) => { + const task = processDelivery(event, reply).catch((error) => { + log.debug(`relay: delivery handling failed: ${errorMessage(error)}`); + }); + inFlight.add(task); + void task.finally(() => inFlight.delete(task)); + }, + onTokenRotated: async (newToken) => { + const current = await getRelayEntry(ctx.instanceId); + await setRelayEntry(ctx.instanceId, { ...current, token: newToken }); + // The registered endpoint must follow the new relay URL or deliveries + // land in the old (now foreign) inbox. + if (current?.endpoint_id) { + try { + await updateWebhookEndpoint(ctx.appId, ctx.instanceId, current.endpoint_id, { + url: relayReceiveUrl(newToken), + }); + } catch (error) { + log.warn( + `Could not re-point the relay endpoint after a token rotation: ${errorMessage(error)}`, + ); + } + } + }, + onReconnect: () => { + log.ui(dim("relay connection lost — reconnecting…\n")); + }, + }); + + await client.start(); + + const endpoint = await ensureRelayEndpoint(ctx, client.token, eventsFilter); + ({ secret: endpointSecret } = await getWebhookEndpointSecret( + ctx.appId, + ctx.instanceId, + endpoint.id, + )); + + const readyInfo = { + relayUrl: relayReceiveUrl(client.token), + signingSecret: endpointSecret, + endpointId: endpoint.id, + eventsFilter: eventsFilter ?? null, + forwardTo: options.forwardTo ?? null, + }; + if (ndjson) { + log.data(buildReadyLine(readyInfo)); + } else { + renderReadyBanner(readyInfo); + } + + // listen never exits 0: it ends via SIGINT (130) or an unrecoverable error (1). + await new Promise(() => {}); +} From fc7b2f870f7da7238a7fe64d66444c096a85ae80 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:48:36 -0300 Subject: [PATCH 30/42] docs(webhooks): sync root README help output and add agent-mode output tests --- README.md | 1 + .../cli-core/src/commands/webhooks/create.test.ts | 11 +++++++++++ .../cli-core/src/commands/webhooks/update.test.ts | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index e372cef3..831f5791 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Commands: open Open Clerk resources in your browser apps Manage your Clerk applications users [options] Manage Clerk users + webhooks [options] Manage webhook endpoints and deliveries env Manage environment variables config Manage instance configuration enable Enable Clerk features on the linked instance diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts index a9392c9a..66f98182 100644 --- a/packages/cli-core/src/commands/webhooks/create.test.ts +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -108,6 +108,17 @@ describe("webhooks create", () => { expect(captured.err).toBe(""); }); + test("emits the same flat JSON in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksCreate({ url: "https://example.com/webhooks" }); + + expect(JSON.parse(captured.out)).toEqual({ + ...createdEndpoint, + signing_secret: "whsec_new123", + }); + }); + test("prints details and the unmasked secret in human mode", async () => { await webhooksCreate({ url: "https://example.com/webhooks" }); diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts index ef983d5d..c5799029 100644 --- a/packages/cli-core/src/commands/webhooks/update.test.ts +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -120,6 +120,14 @@ describe("webhooks update", () => { expect(captured.err).toBe(""); }); + test("outputs the updated endpoint resource in agent mode without --json", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); + + expect(JSON.parse(captured.out)).toEqual(updatedEndpoint); + }); + test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { mockUpdateWebhookEndpoint.mockRejectedValue(new PlapiError(404, "{}")); From cf9a77d5bdf95b0c51a2234484906a3f56739e0c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 16:48:37 -0300 Subject: [PATCH 31/42] docs(changeset): add the clerk webhooks command group --- .changeset/webhooks.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/webhooks.md diff --git a/.changeset/webhooks.md b/.changeset/webhooks.md new file mode 100644 index 00000000..9dd245cd --- /dev/null +++ b/.changeset/webhooks.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add the `clerk webhooks` command group for managing webhook endpoints and deliveries from the terminal: `list`, `get`, `create`, `update`, `delete`, `secret [--rotate]`, `event-types`, `messages`, `replay`, `listen`, `trigger`, `verify`, and `open`. From 94caee87648fe7c9fdc01118aa368f96098ba21a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 17:29:09 -0300 Subject: [PATCH 32/42] style(webhooks): resolve all oxlint warnings in the webhooks group --- .../src/commands/webhooks/listen.test.ts | 27 +++++++++++-------- .../cli-core/src/commands/webhooks/listen.ts | 2 +- .../src/commands/webhooks/render.test.ts | 2 +- .../src/commands/webhooks/verify.test.ts | 5 +++- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index 1091cbb9..99f2c898 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -13,7 +13,8 @@ interface FakeClientOptions { onReconnect: () => void; } -let lastClient: FakeRelayClient | undefined; +const relayClients: FakeRelayClient[] = []; +const lastClient = () => relayClients.at(-1); class FakeRelayClient { token: string; @@ -22,7 +23,7 @@ class FakeRelayClient { constructor(readonly options: FakeClientOptions) { this.token = options.token; - lastClient = this; + relayClients.push(this); } start(): Promise { @@ -121,7 +122,7 @@ describe("webhooks listen", () => { beforeEach(() => { savedSigintListeners = process.listeners("SIGINT") as NodeJS.SignalsListener[]; mockIsAgent.mockReturnValue(false); - lastClient = undefined; + relayClients.length = 0; mockResolveAppContext.mockResolvedValue({ appId: "app_1", appLabel: "My App", @@ -165,8 +166,12 @@ describe("webhooks listen", () => { await startListen({}, captured); - const persistedToken = (mockSetRelayEntry.mock.calls[0]?.[1] as { token: string }).token; - expect(mockSetRelayEntry.mock.calls[0]?.[0]).toBe("ins_1"); + const [firstInstanceId, firstEntry] = mockSetRelayEntry.mock.calls[0] as [ + string, + { token: string }, + ]; + const persistedToken = firstEntry.token; + expect(firstInstanceId).toBe("ins_1"); expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { @@ -225,7 +230,7 @@ describe("webhooks listen", () => { await startListen({}, captured); expect(process.listenerCount("SIGINT")).toBe(1); - expect(lastClient?.started).toBe(true); + expect(lastClient()?.started).toBe(true); }); test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { @@ -235,7 +240,7 @@ describe("webhooks listen", () => { captured.clear(); const replies: string[] = []; - lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => replies.push(frame), ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -273,7 +278,7 @@ describe("webhooks listen", () => { captured.clear(); const replies: string[] = []; - lastClient!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), (frame) => replies.push(frame), ); for (let i = 0; i < 20 && replies.length === 0; i++) { @@ -301,7 +306,7 @@ describe("webhooks listen", () => { const event = signedEvent('{"type":"user.created"}'); event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; - lastClient!.options.onEvent(event, () => {}); + lastClient()!.options.onEvent(event, () => {}); await new Promise((resolve) => setTimeout(resolve, 0)); expect(captured.err).toContain("signature verification failed for msg_1"); @@ -316,7 +321,7 @@ describe("webhooks listen", () => { const event = signedEvent('{"type":"user.created"}'); event.headers["svix-signature"] = "v1,Zm9yZ2VkIHNpZ25hdHVyZQ=="; - lastClient!.options.onEvent(event, () => {}); + lastClient()!.options.onEvent(event, () => {}); await new Promise((resolve) => setTimeout(resolve, 0)); expect(captured.err).toBe(""); @@ -325,7 +330,7 @@ describe("webhooks listen", () => { test("token rotation persists the new token and re-points the endpoint URL", async () => { await startListen({}, captured); - await lastClient!.options.onTokenRotated("Zz98Yy76Xx"); + await lastClient()!.options.onTokenRotated("Zz98Yy76Xx"); expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { token: "Zz98Yy76Xx", diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts index 30279f3d..76c5de6e 100644 --- a/packages/cli-core/src/commands/webhooks/listen.ts +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -145,7 +145,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi process.on("SIGINT", () => { void (async () => { client?.stop(); - await Promise.allSettled([...inFlight]); + await Promise.allSettled(inFlight); process.exit(EXIT_CODE.SIGINT); })(); }); diff --git a/packages/cli-core/src/commands/webhooks/render.test.ts b/packages/cli-core/src/commands/webhooks/render.test.ts index a36ca168..19e6a2ad 100644 --- a/packages/cli-core/src/commands/webhooks/render.test.ts +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -107,7 +107,7 @@ describe("human rendering", () => { renderArrival("user.created", "msg_1"); renderForwardResult(outcome({ status: 200 }), "POST", "/api/webhooks"); - const plain = captured.err.replace(/\x1b\[[0-9;]*m/g, ""); + const plain = Bun.stripANSI(captured.err); expect(plain).toMatch(/\d{2}:\d{2}:\d{2} --> user\.created msg_1\n/); expect(plain).toMatch(/\d{2}:\d{2}:\d{2} <-- 200 POST \/api\/webhooks 12ms\n/); }); diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index f5c1dde9..15d4c7a9 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -222,7 +222,10 @@ describe("webhooks verify command", () => { { label: "inline --payload (not @file or -)", options: { - ...{ secret: SECRET, id: ID, timestamp: TIMESTAMP, signature: VALID_SIGNATURE }, + secret: SECRET, + id: ID, + timestamp: TIMESTAMP, + signature: VALID_SIGNATURE, payload: "{}", }, }, From f7fdc6be83f7da6811f1a22170e1b8af04a307e2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 17:52:51 -0300 Subject: [PATCH 33/42] fix(webhooks): validate trigger event type before endpoint resolution; gate listen deliveries until setup completes --- .../src/commands/webhooks/create.test.ts | 2 +- .../src/commands/webhooks/listen.test.ts | 33 +++++++++++++++++++ .../cli-core/src/commands/webhooks/listen.ts | 16 +++++++++ .../src/commands/webhooks/secret.test.ts | 9 +++++ .../src/commands/webhooks/trigger.test.ts | 9 +++++ .../cli-core/src/commands/webhooks/trigger.ts | 7 ++-- .../src/commands/webhooks/verify.test.ts | 32 +++++++++++++++++- 7 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts index 66f98182..71884100 100644 --- a/packages/cli-core/src/commands/webhooks/create.test.ts +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -134,7 +134,7 @@ describe("webhooks create", () => { const promise = webhooksCreate({ url: "https://example.com/webhooks" }); await expect(promise).rejects.toBeInstanceOf(CliError); - await expect(webhooksCreate({ url: "https://example.com/webhooks" })).rejects.toThrow( + await expect(promise).rejects.toThrow( "Endpoint created (id: ep_new) but the signing secret could not be fetched. " + "Run 'clerk webhooks secret ep_new' to retrieve it.", ); diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index 99f2c898..ad12d256 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -233,6 +233,39 @@ describe("webhooks listen", () => { expect(lastClient()?.started).toBe(true); }); + test("deliveries arriving before the secret fetch wait for setup to finish", async () => { + mockIsAgent.mockReturnValue(true); + let releaseSecret!: (value: { secret: string }) => void; + mockGetWebhookEndpointSecret.mockReturnValue( + new Promise((resolve) => { + releaseSecret = resolve; + }), + ); + + const run = webhooksListen({}); + run.catch(() => {}); + for (let i = 0; i < 50 && !lastClient(); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + lastClient()!.options.onEvent(signedEvent('{"type":"user.created"}'), () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(captured.out).not.toContain('"type":"event"'); + expect(captured.err).not.toContain("signature verification failed"); + + releaseSecret({ secret: SECRET }); + for (let i = 0; i < 50 && !captured.out.includes('"type":"event"'); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const readyIndex = captured.out.indexOf('"ready"'); + const eventIndex = captured.out.indexOf('"type":"event"'); + expect(readyIndex).toBeGreaterThanOrEqual(0); + expect(eventIndex).toBeGreaterThan(readyIndex); + expect(captured.err).not.toContain("signature verification failed"); + }); + test("delivery without --forward-to replies a synthetic 200 and emits forward_status null", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/webhooks/listen.ts b/packages/cli-core/src/commands/webhooks/listen.ts index 76c5de6e..83095360 100644 --- a/packages/cli-core/src/commands/webhooks/listen.ts +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -136,6 +136,16 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi const inFlight = new Set>(); let client: RelayClient | undefined; let endpointSecret = ""; + let shuttingDown = false; + + // Deliveries can arrive as soon as the relay handshake completes (flow step + // 2), but the signing secret only lands after the endpoint is resolved (step + // 5) — verifying against the empty secret would warn falsely, so processing + // waits on this gate until the ready line is out. + let releaseSetupGate!: () => void; + const setupGate = new Promise((resolve) => { + releaseSetupGate = resolve; + }); // Own SIGINT handling, registered BEFORE the socket opens. The global // handler (cli.ts) is a cleanup-free exit(130) and would fire first, so it @@ -144,6 +154,8 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi process.removeAllListeners("SIGINT"); process.on("SIGINT", () => { void (async () => { + shuttingDown = true; + releaseSetupGate(); // gated deliveries must settle or the drain hangs client?.stop(); await Promise.allSettled(inFlight); process.exit(EXIT_CODE.SIGINT); @@ -154,6 +166,9 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi event: RelayEventFrame, reply: (frame: string) => void, ): Promise { + await setupGate; + if (shuttingDown) return; + const body = decodeEventBody(event); const svixId = event.headers["svix-id"] ?? event.id; const eventType = extractEventType(body); @@ -265,6 +280,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi } else { renderReadyBanner(readyInfo); } + releaseSetupGate(); // listen never exits 0: it ends via SIGINT (130) or an unrecoverable error (1). await new Promise(() => {}); diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts index 2f7bd0fd..b1eb11b6 100644 --- a/packages/cli-core/src/commands/webhooks/secret.test.ts +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -104,6 +104,15 @@ describe("webhooks secret", () => { expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); }); + test("--rotate --yes in agent mode skips the prompt and rotates", async () => { + mockIsAgent.mockReturnValue(true); + + await webhooksSecret({ endpointId: "ep_1", rotate: true, yes: true }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRotateWebhookEndpointSecret).toHaveBeenCalled(); + }); + test("--rotate in agent mode without --yes is a usage error", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts index 72c074ac..a33eb441 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.test.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -101,6 +101,15 @@ describe("webhooks trigger", () => { expect(mockSendWebhookExample).not.toHaveBeenCalled(); }); + test("unknown event type wins over a missing relay endpoint (fail fast)", async () => { + mockGetRelayEntry.mockResolvedValue(undefined); + + await expect(webhooksTrigger({ eventType: "user.exploded" })).rejects.toMatchObject({ + code: ERROR_CODE.UNKNOWN_EVENT_TYPE, + }); + expect(mockSendWebhookExample).not.toHaveBeenCalled(); + }); + test("pages through the catalog before declaring a type unknown", async () => { mockListWebhookEventTypes .mockResolvedValueOnce(catalogPage(["user.created"], true, "iter_2")) diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts index 3359254c..d62382a3 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -38,12 +38,15 @@ async function assertKnownEventType( export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise { const ctx = await resolveAppContext(options); - const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); // send_example returns 200 {} asynchronously — an invalid event type would - // otherwise exit 0 and deliver nothing, the silent failure trigger exists to kill. + // otherwise exit 0 and deliver nothing, the silent failure trigger exists to + // kill. Validated first so agents get unknown_event_type even when no relay + // endpoint is configured. await assertKnownEventType(ctx.appId, ctx.instanceId, options.eventType); + const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); + await rejectEndpointNotFound( sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), endpointId, diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index 15d4c7a9..d48d5722 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { createHmac, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -241,6 +241,36 @@ describe("webhooks verify command", () => { ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); }); + test("missing --delivery file maps to file_not_found", async () => { + await expect( + webhooksVerify({ secret: SECRET, delivery: "@/definitely/not/here.json" }), + ).rejects.toMatchObject({ code: ERROR_CODE.FILE_NOT_FOUND }); + }); + + test("reads the --delivery event line from stdin with -", async () => { + const line = JSON.stringify({ + headers: { "svix-id": ID, "svix-timestamp": TIMESTAMP, "svix-signature": VALID_SIGNATURE }, + body_b64: Buffer.from(PAYLOAD, "utf8").toString("base64"), + }); + const stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(`${line}\n`); + try { + await webhooksVerify({ secret: SECRET, delivery: "-" }); + expect(captured.err).toContain("Signature verified."); + } finally { + stdinSpy.mockRestore(); + } + }); + + test("reads the --payload body from stdin with -", async () => { + const stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(PAYLOAD); + try { + await webhooksVerify({ ...explicitFlags(), payload: "-" }); + expect(captured.err).toContain("Signature verified."); + } finally { + stdinSpy.mockRestore(); + } + }); + test("empty --delivery input is a usage error", async () => { const deliveryPath = await writeTempFile("empty.json", "\n\n"); From 049ce620169c6a52984d94ad2a67e1d9d8713477 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:20:19 -0300 Subject: [PATCH 34/42] feat(webhooks): structured agent-mode output for 'webhooks verify' (spec change #26) --- .../cli-core/src/commands/webhooks/README.md | 2 ++ .../src/commands/webhooks/verify.test.ts | 34 ++++++++++++++++++- .../cli-core/src/commands/webhooks/verify.ts | 9 +++-- packages/cli-core/src/lib/errors.ts | 2 ++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 2ae70354..89d857c3 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -208,6 +208,8 @@ clerk webhooks open Verifies a Svix webhook signature **locally**: HMAC-SHA256 over `{id}.{timestamp}.{body}` with the base64-decoded `whsec_` suffix, constant-time compare, any-match across space-separated `v1,` header entries (rotation grace windows produce multiple entries). No network calls, no auth gate (`--app`/`--instance` are ignored). Exit 0 = signature matched; exit 1 = mismatch (with a humanized timestamp-skew hint when the timestamp is >5 minutes off); exit 2 = bad inputs. +Agent/`--json` mode: success prints `{ "valid": true }` on stdout; a mismatch exits 1 with error code `invalid_webhook_signature` in the structured stderr error. + ```sh clerk webhooks verify --secret whsec_... (--delivery @event.json | --payload @body.json --id msg_... --timestamp --signature v1,...) ``` diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index d48d5722..2ef0d4f8 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -1,8 +1,17 @@ -import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createHmac, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; import { @@ -127,10 +136,12 @@ describe("webhooks verify command", () => { let tempDir: string; beforeEach(async () => { + mockIsAgent.mockReturnValue(false); tempDir = await mkdtemp(join(tmpdir(), "clerk-verify-test-")); }); afterEach(async () => { + mockIsAgent.mockReset(); await rm(tempDir, { recursive: true, force: true }); }); @@ -193,6 +204,27 @@ describe("webhooks verify command", () => { ).rejects.toThrow("Signature verification failed"); }); + test("signature mismatch carries invalid_webhook_signature for agent discrimination", async () => { + const payloadPath = await writeTempFile("body.json", PAYLOAD + "tampered"); + + await expect( + webhooksVerify({ ...explicitFlags(), payload: `@${payloadPath}` }), + ).rejects.toMatchObject({ code: ERROR_CODE.INVALID_WEBHOOK_SIGNATURE }); + }); + + test.each([ + { label: "agent mode", json: undefined, agent: true }, + { label: "--json in a human TTY", json: true, agent: false }, + ])("success in $label emits {valid: true} on stdout", async ({ json, agent }) => { + mockIsAgent.mockReturnValue(agent); + const payloadPath = await writeTempFile("body.json", PAYLOAD); + + await webhooksVerify({ ...explicitFlags(), json, payload: `@${payloadPath}` }); + + expect(JSON.parse(captured.out)).toEqual({ valid: true }); + expect(captured.err).toBe(""); + }); + test("mismatch on a stale timestamp includes a humanized skew hint", async () => { const staleTimestamp = String(Number(TIMESTAMP) - 3600); const payloadPath = await writeTempFile("body.json", PAYLOAD); diff --git a/packages/cli-core/src/commands/webhooks/verify.ts b/packages/cli-core/src/commands/webhooks/verify.ts index ec5d127c..aaa04dcd 100644 --- a/packages/cli-core/src/commands/webhooks/verify.ts +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { shouldOutputJson } from "./shared.ts"; export interface WebhooksVerifyOptions { secret?: string; @@ -179,8 +180,12 @@ export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promi if (Math.abs(deltaSeconds) > SKEW_HINT_THRESHOLD_SECONDS) { message += ` Note: the timestamp is ${humanizeSkew(deltaSeconds)} — make sure it is the raw svix-timestamp header from the same delivery as the signature.`; } - throw new CliError(message); + throw new CliError(message, { code: ERROR_CODE.INVALID_WEBHOOK_SIGNATURE }); } - log.success("Signature verified."); + if (shouldOutputJson(options)) { + log.data(JSON.stringify({ valid: true })); + } else { + log.success("Signature verified."); + } } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 025b2fbf..364ece66 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -61,6 +61,8 @@ export const ERROR_CODE = { WEBHOOK_MESSAGE_NOT_FOUND: "webhook_message_not_found", /** Event type is not in the instance's event-type catalog. */ UNKNOWN_EVENT_TYPE: "unknown_event_type", + /** Offline webhook signature verification found no matching entry. */ + INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 4beca20003dc1895222310a8bf67fe44b16cd85a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:27:24 -0300 Subject: [PATCH 35/42] fix(webhooks): fail fast on confirmation gates and unblock 'verify' stdin pipes - delete / secret --rotate / replay --since now run the --yes/prompt gate before resolveAppContext, so agent mode gets the deterministic usage error without a network round-trip (and regardless of key validity) - the implicit piped-stdin --input-json expansion now stands down when a literal '-' is in argv, fixing 'verify --delivery -' / '--payload -' which previously had their stdin consumed and rejected as nested JSON --- .../src/commands/webhooks/delete.test.ts | 1 + .../cli-core/src/commands/webhooks/delete.ts | 6 +++-- .../src/commands/webhooks/replay.test.ts | 1 + .../cli-core/src/commands/webhooks/replay.ts | 22 ++++++++++++------- .../src/commands/webhooks/secret.test.ts | 1 + .../cli-core/src/commands/webhooks/secret.ts | 9 ++++++-- packages/cli-core/src/lib/input-json.test.ts | 9 ++++++++ packages/cli-core/src/lib/input-json.ts | 6 +++-- 8 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/delete.test.ts b/packages/cli-core/src/commands/webhooks/delete.test.ts index 928ab6ae..1d0c05de 100644 --- a/packages/cli-core/src/commands/webhooks/delete.test.ts +++ b/packages/cli-core/src/commands/webhooks/delete.test.ts @@ -80,6 +80,7 @@ describe("webhooks delete", () => { code: ERROR_CODE.USAGE_ERROR, }); expect(mockDeleteWebhookEndpoint).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("agent mode with --yes deletes without prompting", async () => { diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts index 61c0480e..b76bf8e5 100644 --- a/packages/cli-core/src/commands/webhooks/delete.ts +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -13,13 +13,15 @@ export interface WebhooksDeleteOptions extends WebhooksGlobalOptions { } export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { - const ctx = await resolveAppContext(options); - + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. await confirmDestructive( `Permanently delete webhook endpoint ${options.endpointId}? This cannot be undone.`, options, ); + const ctx = await resolveAppContext(options); + await rejectEndpointNotFound( deleteWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), options.endpointId, diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts index 06bcf1f6..77f05587 100644 --- a/packages/cli-core/src/commands/webhooks/replay.test.ts +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -157,6 +157,7 @@ describe("webhooks replay", () => { webhooksReplay({ since: "2026-05-01T00:00:00Z", endpoint: "ep_1" }), ).rejects.toMatchObject({ code: ERROR_CODE.USAGE_ERROR }); expect(mockRecoverWebhookMessages).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("--since maps a PLAPI 404 to webhook_endpoint_not_found", async () => { diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts index a4538f6c..e85bc7a9 100644 --- a/packages/cli-core/src/commands/webhooks/replay.ts +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -47,6 +47,20 @@ function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promise { const mode = validateReplayMode(options); + + const windowLabel = options.until + ? `between ${options.since} and ${options.until}` + : `since ${options.since}`; + + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. + if (mode === "recover") { + await confirmDestructive( + `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, + options, + ); + } + const ctx = await resolveAppContext(options); if (mode === "resend") { @@ -59,14 +73,6 @@ export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promi return; } - const windowLabel = options.until - ? `between ${options.since} and ${options.until}` - : `since ${options.since}`; - await confirmDestructive( - `Bulk-recover deliveries to ${options.endpoint} ${windowLabel}? Every failed delivery in the window will be resent.`, - options, - ); - await rejectEndpointNotFound( recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { since: options.since!, diff --git a/packages/cli-core/src/commands/webhooks/secret.test.ts b/packages/cli-core/src/commands/webhooks/secret.test.ts index b1eb11b6..e7c2473e 100644 --- a/packages/cli-core/src/commands/webhooks/secret.test.ts +++ b/packages/cli-core/src/commands/webhooks/secret.test.ts @@ -120,6 +120,7 @@ describe("webhooks secret", () => { code: ERROR_CODE.USAGE_ERROR, }); expect(mockRotateWebhookEndpointSecret).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); test("maps a PLAPI 404 to webhook_endpoint_not_found", async () => { diff --git a/packages/cli-core/src/commands/webhooks/secret.ts b/packages/cli-core/src/commands/webhooks/secret.ts index de9d0978..63860f17 100644 --- a/packages/cli-core/src/commands/webhooks/secret.ts +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -16,13 +16,18 @@ export interface WebhooksSecretOptions extends WebhooksGlobalOptions { } export async function webhooksSecret(options: WebhooksSecretOptions): Promise { - const ctx = await resolveAppContext(options); - + // Before resolveAppContext: the confirmation gate is pure flag/prompt logic + // and must not cost (or be masked by) a network round-trip. if (options.rotate) { await confirmDestructive( `Rotate the signing secret for ${options.endpointId}? The old key keeps verifying for 24h (dual-signing grace).`, options, ); + } + + const ctx = await resolveAppContext(options); + + if (options.rotate) { await rejectEndpointNotFound( rotateWebhookEndpointSecret(ctx.appId, ctx.instanceId, options.endpointId), options.endpointId, diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 954ac2ee..503ffb41 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -370,5 +370,14 @@ describe("expandInputJson", () => { const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); }); + + test("auto-stdin stands down when a flag reads stdin itself (literal -)", async () => { + const argv = ["clerk", "webhooks", "verify", "--secret", "whsec_x", "--delivery", "-"]; + const result = await expandViaStdin( + argv, + '{"headers":{"svix-id":"msg_1"},"body_b64":"e30="}', + ); + expect(result.result).toEqual(argv); + }); }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index bcc37281..0938e750 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -151,8 +151,10 @@ export async function expandInputJson(argv: string[]): Promise { return argv; } - // No explicit --input-json flag — check for piped stdin - if (hasStdinPipe()) { + // No explicit --input-json flag — check for piped stdin. A literal `-` in + // argv means some flag reads stdin itself (e.g. `verify --delivery -`); the + // implicit JSON slurp must not consume the stream first. + if (hasStdinPipe() && !argv.includes(STDIN_MARKER)) { const jsonStr = await readOptionalStdin(); if (jsonStr === undefined) return argv; const parsed = parseJsonString(jsonStr); From b4d8720dbdd6bf5bf7b2ebebe01d9a0c48c66436 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:30:26 -0300 Subject: [PATCH 36/42] test(webhooks): spell the no---json agent case as an empty flags object --- packages/cli-core/src/commands/webhooks/verify.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index 2ef0d4f8..8cda7a1d 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -213,13 +213,13 @@ describe("webhooks verify command", () => { }); test.each([ - { label: "agent mode", json: undefined, agent: true }, - { label: "--json in a human TTY", json: true, agent: false }, - ])("success in $label emits {valid: true} on stdout", async ({ json, agent }) => { + { label: "agent mode without --json", flags: {}, agent: true }, + { label: "--json in a human TTY", flags: { json: true }, agent: false }, + ])("success in $label emits {valid: true} on stdout", async ({ flags, agent }) => { mockIsAgent.mockReturnValue(agent); const payloadPath = await writeTempFile("body.json", PAYLOAD); - await webhooksVerify({ ...explicitFlags(), json, payload: `@${payloadPath}` }); + await webhooksVerify({ ...explicitFlags(), ...flags, payload: `@${payloadPath}` }); expect(JSON.parse(captured.out)).toEqual({ valid: true }); expect(captured.err).toBe(""); From 9df21a6691518c166a87ad9a987009cb25de9d3a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 18:32:47 -0300 Subject: [PATCH 37/42] chore(webhooks): remove stray test fixture committed under packages/cli-core/undefined/ --- packages/cli-core/undefined/body.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/cli-core/undefined/body.json diff --git a/packages/cli-core/undefined/body.json b/packages/cli-core/undefined/body.json deleted file mode 100644 index 098657a7..00000000 --- a/packages/cli-core/undefined/body.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "user.created" } From f66e33248c07600d2703eef89ab2a9adbbd43fe8 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 10 Jun 2026 16:45:05 -0300 Subject: [PATCH 38/42] fix(webhooks): relay token carries the c_ prefix on the wire and in the inbox URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-relay verified: play.svix.com returns 400 'Invalid token' for unprefixed tokens, and the relay only registers an inbox when the start frame carries the same c_ token. With c_ in both, a POST to the inbox round-trips through the WebSocket and the reply frame is accepted — proven end-to-end against the real relay with no PLAPI involvement. Reverses spec change #12 (recorded as spec change #27). --- packages/cli-core/src/commands/webhooks/README.md | 2 +- packages/cli-core/src/commands/webhooks/listen.test.ts | 4 ++-- .../cli-core/src/commands/webhooks/relay-protocol.test.ts | 7 +++---- packages/cli-core/src/commands/webhooks/relay-protocol.ts | 8 ++++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index 89d857c3..da3bc50c 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -246,7 +246,7 @@ clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [-- Behavior notes: -- **Relay token**: 10 random base62 chars, raw on the wire (no `c_` prefix), persisted per instance in the CLI config (`relay..token`). Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. +- **Relay token**: `c_` + 10 random base62 chars — the same token goes in the start frame, the inbox URL, and the per-instance CLI config (`relay..token`). Live-relay verified: `play.svix.com` rejects unprefixed tokens, and the relay only registers an inbox when the start frame carries the `c_` token. Close code 1008 = token collision → new token generated, persisted, redialed, and the endpoint URL re-pointed. - **Keepalive**: the relay server pings ~every 21s, but Bun's client WebSocket auto-pongs below the JS API (no ping events). After 30s of silence the client sends an active `ws.ping()` probe — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. - **Per-delivery output**: human mode prints `time --> event_type msg_…` then `<-- status method path ms` via `log.ui` (bypasses the stderr throttle). Diagnostics: 401 → `clerkMiddleware` public-route hint; 400 → raw-body/`verifyWebhook()` order hint; 5xx → response body inline plus the exact `clerk webhooks replay ` line; unreachable handler → synthetic **502** framed back to the relay. - **Verification**: deliveries failing HMAC are warned about and still forwarded (the mismatch means the relay secret diverged, not that the local handler should silently miss events). diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index ad12d256..1d5ab0cb 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -161,7 +161,7 @@ describe("webhooks listen", () => { expect(mockResolveAppContext).not.toHaveBeenCalled(); }); - test("first run generates and persists a 10-char base62 token, then creates the endpoint", async () => { + test("first run generates and persists a c_-prefixed base62 token, then creates the endpoint", async () => { mockGetRelayEntry.mockResolvedValue(undefined); await startListen({}, captured); @@ -172,7 +172,7 @@ describe("webhooks listen", () => { ]; const persistedToken = firstEntry.token; expect(firstInstanceId).toBe("ins_1"); - expect(persistedToken).toMatch(/^[0-9A-Za-z]{10}$/); + expect(persistedToken).toMatch(/^c_[0-9A-Za-z]{10}$/); expect(mockCreateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", { url: `https://play.svix.com/in/${persistedToken}/`, diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts index c818ce5e..62419af2 100644 --- a/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.test.ts @@ -9,10 +9,9 @@ import { } from "./relay-protocol.ts"; describe("generateRelayToken", () => { - test("produces 10 base62 chars with no prefix", () => { + test("produces c_ + 10 base62 chars (live-relay wire format)", () => { const token = generateRelayToken(); - expect(token).toMatch(/^[0-9A-Za-z]{10}$/); - expect(token.startsWith("c_")).toBe(false); + expect(token).toMatch(/^c_[0-9A-Za-z]{10}$/); }); test("produces distinct tokens across calls", () => { @@ -22,7 +21,7 @@ describe("generateRelayToken", () => { }); describe("relayReceiveUrl", () => { - test("builds the play.svix.com URL with the raw token", () => { + test("builds the play.svix.com URL with the token verbatim", () => { expect(relayReceiveUrl("Ab12Cd34Ef")).toBe("https://play.svix.com/in/Ab12Cd34Ef/"); }); }); diff --git a/packages/cli-core/src/commands/webhooks/relay-protocol.ts b/packages/cli-core/src/commands/webhooks/relay-protocol.ts index 075c235a..be6b559b 100644 --- a/packages/cli-core/src/commands/webhooks/relay-protocol.ts +++ b/packages/cli-core/src/commands/webhooks/relay-protocol.ts @@ -23,8 +23,12 @@ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const TOKEN_LENGTH = 10; // Largest multiple of 62 below 256; bytes at or above it would bias the modulo. const UNBIASED_BYTE_LIMIT = 248; +// Live-relay verified (2026-06-10): play.svix.com rejects unprefixed tokens +// ("Invalid token"), and the relay only registers an inbox when the start +// frame carries the same c_ token. The prefix is wire format, not cosmetics. +const TOKEN_PREFIX = "c_"; -/** 10 random base62 chars, raw — no `c_` prefix on the wire or in the URL. */ +/** `c_` + 10 random base62 chars — the same token goes in the start frame, the inbox URL, and config. */ export function generateRelayToken(): string { let token = ""; while (token.length < TOKEN_LENGTH) { @@ -36,7 +40,7 @@ export function generateRelayToken(): string { if (token.length === TOKEN_LENGTH) break; } } - return token; + return TOKEN_PREFIX + token; } export function relayReceiveUrl(token: string): string { From 379e1f6605cc78088d0dcdb341f7fdf1bba2e7cb Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 15 Jun 2026 09:19:14 -0300 Subject: [PATCH 39/42] fix(webhooks): add missing imports and clean up rebase artifacts in cli-program - Import createOption from @commander-js/extra-typings (used by webhooks messages --status) - Import parseIntegerOption from lib/option-parsers (used by webhooks list and messages --limit) - Remove stray conflict-marker text fragments left by conflict resolution --- packages/cli-core/src/cli-program.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8ef674c5..327145e1 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,4 +1,4 @@ -import { Command } from "@commander-js/extra-typings"; +import { Command, createOption } from "@commander-js/extra-typings"; import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; import { setMode, type Mode } from "./mode.ts"; @@ -42,6 +42,7 @@ import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { getAuthToken } from "./lib/plapi.ts"; +import { parseIntegerOption } from "./lib/option-parsers.ts"; import { webhooks as webhooksHandlers } from "./commands/webhooks/index.ts"; import { registerExtras } from "@clerk/cli-extras"; @@ -207,7 +208,6 @@ export function createProgram(): Program { endpointId, }), ); -: add 'webhooks get' command) webhooks .command("event-types") @@ -228,7 +228,6 @@ export function createProgram(): Program { cmd.optsWithGlobals() as Parameters[0], ), ); -: add 'webhooks event-types' command) webhooks .command("secret") @@ -259,7 +258,6 @@ export function createProgram(): Program { endpointId, }), ); -: add 'webhooks secret' command with --rotate) webhooks .command("delete") @@ -282,7 +280,6 @@ export function createProgram(): Program { endpointId, }), ); -: add 'webhooks delete' command) webhooks .command("update") @@ -320,7 +317,6 @@ export function createProgram(): Program { endpointId, }), ); -: add 'webhooks update' command) webhooks .command("create") @@ -353,7 +349,6 @@ export function createProgram(): Program { cmd.optsWithGlobals() as Parameters[0], ), ); -: add 'webhooks create' command) webhooks .command("messages") @@ -389,7 +384,6 @@ export function createProgram(): Program { cmd.optsWithGlobals() as Parameters[0], ), ); -: add 'webhooks messages' command) webhooks .command("replay") @@ -423,7 +417,6 @@ export function createProgram(): Program { msgId, }), ); -: add 'webhooks replay' command) webhooks .command("trigger") @@ -452,7 +445,6 @@ export function createProgram(): Program { eventType, }), ); -: add 'webhooks trigger' command) webhooks .command("open") @@ -464,7 +456,6 @@ export function createProgram(): Program { .action((_opts, cmd) => webhooksHandlers.open(cmd.optsWithGlobals() as Parameters[0]), ); -: add 'webhooks open' command) webhooks .command("verify") @@ -494,7 +485,6 @@ export function createProgram(): Program { cmd.optsWithGlobals() as Parameters[0], ), ); -: add offline 'webhooks verify' command) webhooks .command("listen") @@ -528,7 +518,6 @@ export function createProgram(): Program { cmd.optsWithGlobals() as Parameters[0], ), ); -: add 'webhooks listen' command) return program; } From 9889ce1c78d2be16baadd3d55db7822dec955600 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 18 Jun 2026 14:53:21 -0300 Subject: [PATCH 40/42] test(webhooks): cover relay client and harden splitCommaList empty handling - splitCommaList now returns undefined for empty/whitespace-only values so callers treat them as "not provided" rather than sending an empty array - list now prints the iterator hint when paginating - add relay-client tests plus list/replay/update/verify coverage - README: clarify keepalive probe timing and JSON-mode type discriminator Claude-Session: https://claude.ai/code/session_01Mwcxk4pmfNYtmvjwWs9jUE --- .../cli-core/src/commands/webhooks/README.md | 4 +- .../src/commands/webhooks/list.test.ts | 9 + .../cli-core/src/commands/webhooks/list.ts | 1 + .../commands/webhooks/relay-client.test.ts | 257 ++++++++++++++++++ .../src/commands/webhooks/replay.test.ts | 4 + .../cli-core/src/commands/webhooks/shared.ts | 9 +- .../src/commands/webhooks/update.test.ts | 10 + .../src/commands/webhooks/verify.test.ts | 13 + 8 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 packages/cli-core/src/commands/webhooks/relay-client.test.ts diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index da3bc50c..cd045872 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -247,10 +247,10 @@ clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [-- 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). After 30s of silence the client sends an active `ws.ping()` probe — writes to a dead link surface as close/error, which redials with the same token. Reconnects never change the relay URL. +- **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). -- **Agent/`--json` mode**: NDJSON on stdout — one `ready` line (`relay_url`, `signing_secret`, `endpoint_id`, `events_filter`), then one `event` line per delivery (`svix_id`, `event_type`, `headers`, `body_b64`, `forward_status`, `latency_ms`). An event line saved to a file is directly consumable by `verify --delivery @file`. +- **Agent/`--json` mode**: NDJSON on stdout. Every line carries a `type` discriminator: one `{ "type": "ready", ... }` line (`relay_url`, `signing_secret`, `endpoint_id`, `events_filter`), 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`. - **SIGINT**: `listen` replaces the global cleanup-free handler before opening the socket: close socket, drain in-flight forwards, exit 130. The relay endpoint is **never** deleted on exit — its URL and `whsec_` stay stable across restarts. `listen` never exits 0. ### API endpoints diff --git a/packages/cli-core/src/commands/webhooks/list.test.ts b/packages/cli-core/src/commands/webhooks/list.test.ts index 240f3717..4be2f37e 100644 --- a/packages/cli-core/src/commands/webhooks/list.test.ts +++ b/packages/cli-core/src/commands/webhooks/list.test.ts @@ -130,6 +130,15 @@ describe("webhooks list", () => { 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(); diff --git a/packages/cli-core/src/commands/webhooks/list.ts b/packages/cli-core/src/commands/webhooks/list.ts index c9987cad..f6170fdf 100644 --- a/packages/cli-core/src/commands/webhooks/list.ts +++ b/packages/cli-core/src/commands/webhooks/list.ts @@ -59,6 +59,7 @@ export async function webhooksList(options: WebhooksListOptions = {}): Promise 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", 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]); + + expect(FakeWebSocket.instances).toHaveLength(2); // immediate redial, no reconnect-delay timer + 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() 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); + }); +}); diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts index 77f05587..b9afa4e9 100644 --- a/packages/cli-core/src/commands/webhooks/replay.test.ts +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -67,6 +67,10 @@ describe("webhooks replay", () => { 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" }, diff --git a/packages/cli-core/src/commands/webhooks/shared.ts b/packages/cli-core/src/commands/webhooks/shared.ts index 881853f2..dc727fe1 100644 --- a/packages/cli-core/src/commands/webhooks/shared.ts +++ b/packages/cli-core/src/commands/webhooks/shared.ts @@ -117,12 +117,17 @@ export function formatEndpointDetails(endpoint: WebhookEndpoint): void { } } -/** Split a comma-separated flag value into trimmed, non-empty entries. */ +/** + * 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; + return parts.length > 0 ? parts : undefined; } diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts index c5799029..ab2b53c3 100644 --- a/packages/cli-core/src/commands/webhooks/update.test.ts +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -105,6 +105,16 @@ describe("webhooks update", () => { expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); }); + test.each([ + { label: "--events", options: { events: "" } }, + { label: "--channels", options: { channels: " , " } }, + ])("an empty $label value does not bypass the no-flags guard", async ({ options }) => { + await expect(webhooksUpdate({ endpointId: "ep_1", ...options })).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); + }); + test("prints the updated endpoint in human mode", async () => { await webhooksUpdate({ endpointId: "ep_1", description: "Updated" }); diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index 8cda7a1d..b7a83386 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -234,6 +234,19 @@ describe("webhooks verify command", () => { ).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" } }, From a1352d31090c82ed69e950da382808908fe7fbdd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 24 Jun 2026 11:55:30 -0300 Subject: [PATCH 41/42] fix(webhooks): harden verify, create, replay, and relay edge cases Adversarial audit follow-ups on the unreleased webhooks group: - verify: reject an explicit empty --payload as a usage error instead of hashing an `undefined` pre-image and silently failing (exit 2, not 1). - create: propagate an AuthError from the post-create secret fetch instead of masking it as "secret unavailable"; tag the genuine partial-failure with the new webhook_secret_fetch_failed code for agent branching. - replay: `--until` alone now points at the missing --since rather than emitting the vaguer "pass or --since" hint. - relay-client: route the 1008 token-collision redial through the standard reconnect backoff (no zero-delay storm) and guard onopen against a stop() that races socket construction. - README: document at-least-once redelivery on reconnect (handlers must key on svix-id). Adds tests for each fix. Full suite 1934 pass / 0 fail. Claude-Session: https://claude.ai/code/session_015J6Sduw5KeHz6SxLEBfViF --- .../cli-core/src/commands/webhooks/README.md | 1 + .../src/commands/webhooks/create.test.ts | 19 ++++++++++- .../cli-core/src/commands/webhooks/create.ts | 9 ++++-- .../commands/webhooks/relay-client.test.ts | 23 +++++++++++-- .../src/commands/webhooks/relay-client.ts | 15 +++++++-- .../src/commands/webhooks/replay.test.ts | 6 ++++ .../cli-core/src/commands/webhooks/replay.ts | 8 +++-- .../src/commands/webhooks/verify.test.ts | 32 +++++++++++++++++++ .../cli-core/src/commands/webhooks/verify.ts | 10 ++++-- packages/cli-core/src/lib/errors.ts | 2 ++ 10 files changed, 112 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/webhooks/README.md b/packages/cli-core/src/commands/webhooks/README.md index cd045872..f2f16636 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -250,6 +250,7 @@ Behavior notes: - **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`, `signing_secret`, `endpoint_id`, `events_filter`), 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`. - **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. diff --git a/packages/cli-core/src/commands/webhooks/create.test.ts b/packages/cli-core/src/commands/webhooks/create.test.ts index 71884100..7ce8255f 100644 --- a/packages/cli-core/src/commands/webhooks/create.test.ts +++ b/packages/cli-core/src/commands/webhooks/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; -import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { AuthError, CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockCreateWebhookEndpoint = mock(); @@ -134,9 +134,26 @@ describe("webhooks create", () => { 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 index 8ab4ceac..69345483 100644 --- a/packages/cli-core/src/commands/webhooks/create.ts +++ b/packages/cli-core/src/commands/webhooks/create.ts @@ -1,5 +1,5 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { CliError, throwUsageError } from "../../lib/errors.ts"; +import { AuthError, CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { createWebhookEndpoint, @@ -46,12 +46,17 @@ export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promi let secret: string; try { ({ secret } = await getWebhookEndpointSecret(ctx.appId, ctx.instanceId, endpoint.id)); - } catch { + } 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 }, ); } diff --git a/packages/cli-core/src/commands/webhooks/relay-client.test.ts b/packages/cli-core/src/commands/webhooks/relay-client.test.ts index ef87cc5b..109e3658 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.test.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.test.ts @@ -219,7 +219,7 @@ describe("RelayClient", () => { expect(harness.rotated).toHaveLength(0); }); - test("a 1008 collision rotates to a fresh c_ token, persists it, and redials", async () => { + 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(); @@ -228,12 +228,31 @@ describe("RelayClient", () => { expect(harness.client.token).toMatch(/^c_[0-9A-Za-z]{10}$/); expect(harness.rotated).toEqual([harness.client.token]); - expect(FakeWebSocket.instances).toHaveLength(2); // immediate redial, no reconnect-delay timer + // 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); diff --git a/packages/cli-core/src/commands/webhooks/relay-client.ts b/packages/cli-core/src/commands/webhooks/relay-client.ts index 586edb66..0361a95b 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -63,6 +63,12 @@ export class RelayClient { 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})`); ws.send(encodeStartFrame(this.token)); this.lastActivityAt = Date.now(); @@ -93,10 +99,15 @@ export class RelayClient { if (this.stopped) return; if (event.code === RELAY_CLOSE_TOKEN_COLLISION) { - // Another listener holds this token: rotate, persist, redial. + // 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(() => this.connect()); + void this.options.onTokenRotated(this.token).then(() => { + if (this.stopped) return; + setTimeout(() => this.connect(), RELAY_RECONNECT_DELAY_MS); + }); return; } diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts index b9afa4e9..c87c0d7a 100644 --- a/packages/cli-core/src/commands/webhooks/replay.test.ts +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -91,6 +91,12 @@ describe("webhooks replay", () => { 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" }); diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts index e85bc7a9..45e1415c 100644 --- a/packages/cli-core/src/commands/webhooks/replay.ts +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -28,12 +28,14 @@ function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover if (options.msgId && options.since) { throwUsageError("Pass either a or --since, not both."); } - if (!options.msgId && !options.since) { - throwUsageError("Pass a to resend one delivery, or --since to bulk-recover."); - } + // 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"); diff --git a/packages/cli-core/src/commands/webhooks/verify.test.ts b/packages/cli-core/src/commands/webhooks/verify.test.ts index b7a83386..42cf4ea4 100644 --- a/packages/cli-core/src/commands/webhooks/verify.test.ts +++ b/packages/cli-core/src/commands/webhooks/verify.test.ts @@ -179,6 +179,26 @@ describe("webhooks verify command", () => { 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: { @@ -274,6 +294,18 @@ describe("webhooks verify command", () => { 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, diff --git a/packages/cli-core/src/commands/webhooks/verify.ts b/packages/cli-core/src/commands/webhooks/verify.ts index aaa04dcd..1e760096 100644 --- a/packages/cli-core/src/commands/webhooks/verify.ts +++ b/packages/cli-core/src/commands/webhooks/verify.ts @@ -162,9 +162,13 @@ export async function webhooksVerify(options: WebhooksVerifyOptions = {}): Promi ); } - const payload = options.payload - ? await readFileOrStdin(options.payload, "--payload") - : fields.payload; + // 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, diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 364ece66..73c2ef75 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -63,6 +63,8 @@ export const ERROR_CODE = { 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]; From dba0f5b4b4c080a79cba40d98182eef047b60826 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Thu, 25 Jun 2026 17:06:15 -0300 Subject: [PATCH 42/42] feat(webhooks): add --relay-only/--token and register via the registrant pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the local relay tunnel with no Clerk backend via `listen --relay-only` (skips PLAPI endpoint provisioning and the group auth gate, forces verification off). Persist the relay token per instance so the relay URL is stable across restarts, and add `--token ` to pin a deterministic, shareable URL. Move the webhooks command tree into `registerWebhooks(program)` exported from commands/webhooks/index.ts and wire it through cli-program's registrants array, matching the project's command-registration pattern. Add a .claude rule that documents the pattern when cli-program.ts or a command index.ts is edited. Harden the command group's edge cases (svix_app_missing handling, friendly API errors, --limit validation, header forwarding) and extract the SIGINT handler into lib/signals.ts. Claude-Session: https://claude.ai/code/session_01SYYJBsRxBQjCAuNbQiiLma --- .changeset/webhooks.md | 2 + .claude/rules/command-registration.md | 68 +++ .oxlintrc.json | 1 + packages/cli-core/src/cli-program.ts | 379 +---------------- packages/cli-core/src/cli.ts | 4 +- .../cli-core/src/commands/webhooks/README.md | 45 +- .../cli-core/src/commands/webhooks/create.ts | 13 +- .../cli-core/src/commands/webhooks/delete.ts | 6 +- .../src/commands/webhooks/event-types.test.ts | 12 + .../src/commands/webhooks/event-types.ts | 1 + .../src/commands/webhooks/forward.test.ts | 18 + .../cli-core/src/commands/webhooks/forward.ts | 1 + .../src/commands/webhooks/get.test.ts | 2 +- .../cli-core/src/commands/webhooks/get.ts | 6 +- .../cli-core/src/commands/webhooks/index.ts | 400 +++++++++++++++++- .../src/commands/webhooks/list.test.ts | 22 + .../cli-core/src/commands/webhooks/list.ts | 12 +- .../src/commands/webhooks/listen.test.ts | 104 ++++- .../cli-core/src/commands/webhooks/listen.ts | 176 +++++--- .../src/commands/webhooks/messages.test.ts | 12 + .../src/commands/webhooks/messages.ts | 1 + .../src/commands/webhooks/open.test.ts | 22 + .../cli-core/src/commands/webhooks/open.ts | 11 +- .../commands/webhooks/relay-client.test.ts | 14 + .../src/commands/webhooks/relay-client.ts | 26 +- .../src/commands/webhooks/render.test.ts | 23 +- .../cli-core/src/commands/webhooks/render.ts | 30 +- .../src/commands/webhooks/replay.test.ts | 8 + .../cli-core/src/commands/webhooks/replay.ts | 26 +- .../cli-core/src/commands/webhooks/secret.ts | 11 +- .../cli-core/src/commands/webhooks/shared.ts | 36 +- .../src/commands/webhooks/trigger.test.ts | 10 + .../cli-core/src/commands/webhooks/trigger.ts | 23 +- .../src/commands/webhooks/update.test.ts | 20 +- .../cli-core/src/commands/webhooks/update.ts | 13 +- packages/cli-core/src/lib/config.test.ts | 11 +- packages/cli-core/src/lib/config.ts | 7 +- packages/cli-core/src/lib/plapi.test.ts | 12 +- packages/cli-core/src/lib/plapi.ts | 16 +- packages/cli-core/src/lib/signals.ts | 10 + 40 files changed, 1083 insertions(+), 531 deletions(-) create mode 100644 .claude/rules/command-registration.md create mode 100644 packages/cli-core/src/lib/signals.ts diff --git a/.changeset/webhooks.md b/.changeset/webhooks.md index 9dd245cd..be5c4348 100644 --- a/.changeset/webhooks.md +++ b/.changeset/webhooks.md @@ -3,3 +3,5 @@ --- 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/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 327145e1..edcdaf84 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,4 +1,4 @@ -import { Command, createOption } from "@commander-js/extra-typings"; +import { Command } from "@commander-js/extra-typings"; import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; import { setMode, type Mode } from "./mode.ts"; @@ -41,9 +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 { getAuthToken } from "./lib/plapi.ts"; -import { parseIntegerOption } from "./lib/option-parsers.ts"; -import { webhooks as webhooksHandlers } from "./commands/webhooks/index.ts"; +import { registerWebhooks } from "./commands/webhooks/index.ts"; import { registerExtras } from "@clerk/cli-extras"; /** @@ -72,6 +70,7 @@ const registrants: CommandRegistrant[] = [ registerCompletion, registerUpdate, registerDeploy, + registerWebhooks, registerExtras, ]; @@ -146,378 +145,6 @@ export function createProgram(): Program { register(program); } - const webhooks = program - .command("webhooks") - .description("Manage webhook endpoints and deliveries") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--json", "Output as JSON") - .setExamples([ - { command: "clerk webhooks list", description: "List webhook endpoints" }, - { - command: "clerk webhooks create --url https://example.com/api/webhooks", - description: "Create an endpoint and print its signing secret", - }, - { - command: "clerk webhooks listen --forward-to http://localhost:3000/api/webhooks", - description: "Forward instance events to a local handler", - }, - ]); - - webhooks.hook("preAction", async (_thisCommand, actionCommand) => { - if (actionCommand.name() === "verify") return; // pure offline HMAC, no auth gate - await getAuthToken(); - }); - - 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)", - ) - .option("--description ", "New description") - .option("--channels ", "Comma-separated channels") - .option("--enable", "Re-enable a disabled endpoint") - .option("--disable", "Disable the endpoint") - .setExamples([ - { - command: "clerk webhooks update ep_2abc123 --url https://example.com/api/webhooks", - description: "Point the endpoint at a new URL", - }, - { - command: "clerk webhooks update ep_2abc123 --events user.created,user.deleted", - description: "Replace the event-type filter", - }, - { - command: "clerk webhooks update ep_2abc123 --enable", - description: "Re-enable an endpoint", - }, - ]) - .action((endpointId, _opts, cmd) => - webhooksHandlers.update({ - ...(cmd.optsWithGlobals() as Omit< - Parameters[0], - "endpointId" - >), - endpointId, - }), - ); - - 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( - "--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], - ), - ); return program; } 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 index f2f16636..6c7ef04a 100644 --- a/packages/cli-core/src/commands/webhooks/README.md +++ b/packages/cli-core/src/commands/webhooks/README.md @@ -18,7 +18,7 @@ Auth: every subcommand except `verify` is gated by a `preAction` hook calling `g Output contract: stdout carries bare domain JSON via `log.data()` (pipeable); stderr carries human UI and, in agent mode, structured error JSON `{"error":{code,message,docsUrl?}}`. No `{ok,data,error}` envelope. Exit codes: 0 success, 1 failure, 2 usage error, 130 SIGINT. -Pagination: list-shaped commands fetch ONE page (`--limit` 1-250, default 100). When `cursor.has_next_page` is true, the next `--iterator` value is printed as a stderr hint. The `--iterator` flag value is sent on the wire as the `starting_after` query param. +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}`. @@ -37,6 +37,8 @@ clerk webhooks list [--limit N] [--iterator C] 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 | @@ -45,7 +47,7 @@ Human mode prints an `ID / URL / STATUS / EVENTS` table on stderr. JSON mode pri ## `clerk webhooks get ` -Prints one endpoint's configuration. A PLAPI 404 maps to error code `webhook_endpoint_not_found`. +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 @@ -61,7 +63,7 @@ Human mode prints labeled detail rows on stderr. JSON mode prints the bare endpo ## `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. +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] @@ -75,7 +77,7 @@ clerk webhooks event-types [--limit N] [--iterator C] ## `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. +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]] @@ -92,7 +94,7 @@ Output: human mode prints the **bare** `whsec_...` on stdout (eval-friendly: `ex ## `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 {}`). +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] @@ -106,10 +108,18 @@ clerk webhooks delete ep_2abc123 [--yes] ## `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. +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. @@ -143,6 +153,8 @@ Partial failure: if `POST /webhooks` succeeds but the secret fetch fails, the co 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] ``` @@ -234,15 +246,17 @@ None — pure offline computation. Dials the Svix relay (`wss://api.relay.svix.com/api/v1/listen/`), registers a **persistent** per-instance relay endpoint pointing at `https://play.svix.com/in//`, and forwards incoming deliveries to a local handler. ```sh -clerk webhooks listen [--forward-to ] [--events ] [--skip-verify] [--headers k:v,...] +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. | -| `--headers ` | Comma-separated `k:v` extras on the forwarded POST (split on the FIRST colon). The delivery's `svix-*` headers always win. | +| 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: @@ -251,11 +265,14 @@ Behavior notes: - **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`, `signing_secret`, `endpoint_id`, `events_filter`), 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`. +- **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. | diff --git a/packages/cli-core/src/commands/webhooks/create.ts b/packages/cli-core/src/commands/webhooks/create.ts index 69345483..fbe699e3 100644 --- a/packages/cli-core/src/commands/webhooks/create.ts +++ b/packages/cli-core/src/commands/webhooks/create.ts @@ -1,5 +1,11 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { AuthError, CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; +import { + AuthError, + CliError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { createWebhookEndpoint, @@ -41,7 +47,10 @@ export async function webhooksCreate(options: WebhooksCreateOptions = {}): Promi const params = buildCreateParams(options); const ctx = await resolveAppContext(options); - const endpoint = await createWebhookEndpoint(ctx.appId, ctx.instanceId, params); + const endpoint = await withApiContext( + createWebhookEndpoint(ctx.appId, ctx.instanceId, params), + "Failed to create webhook endpoint", + ); let secret: string; try { diff --git a/packages/cli-core/src/commands/webhooks/delete.ts b/packages/cli-core/src/commands/webhooks/delete.ts index b76bf8e5..1e02219d 100644 --- a/packages/cli-core/src/commands/webhooks/delete.ts +++ b/packages/cli-core/src/commands/webhooks/delete.ts @@ -1,4 +1,5 @@ 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 { @@ -23,7 +24,10 @@ export async function webhooksDelete(options: WebhooksDeleteOptions): Promise { 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 }); diff --git a/packages/cli-core/src/commands/webhooks/event-types.ts b/packages/cli-core/src/commands/webhooks/event-types.ts index 6abd119a..6b320640 100644 --- a/packages/cli-core/src/commands/webhooks/event-types.ts +++ b/packages/cli-core/src/commands/webhooks/event-types.ts @@ -43,6 +43,7 @@ export async function webhooksEventTypes(options: WebhooksEventTypesOptions = {} if (response.data.length === 0) { log.warn("No event types found."); + printIteratorHint(response.cursor); return; } diff --git a/packages/cli-core/src/commands/webhooks/forward.test.ts b/packages/cli-core/src/commands/webhooks/forward.test.ts index 802a4d9f..62e59ea4 100644 --- a/packages/cli-core/src/commands/webhooks/forward.test.ts +++ b/packages/cli-core/src/commands/webhooks/forward.test.ts @@ -143,4 +143,22 @@ describe("forwardDelivery", () => { 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 index 19cc9ece..b1fa365a 100644 --- a/packages/cli-core/src/commands/webhooks/forward.ts +++ b/packages/cli-core/src/commands/webhooks/forward.ts @@ -58,6 +58,7 @@ export async function forwardDelivery(args: { method: args.method, headers: args.headers, body: args.body, + signal: AbortSignal.timeout(30_000), }); const bodyText = await response.text(); const headers: Record = {}; diff --git a/packages/cli-core/src/commands/webhooks/get.test.ts b/packages/cli-core/src/commands/webhooks/get.test.ts index 1fb3f53b..e565fc43 100644 --- a/packages/cli-core/src/commands/webhooks/get.test.ts +++ b/packages/cli-core/src/commands/webhooks/get.test.ts @@ -92,7 +92,7 @@ describe("webhooks get", () => { const promise = webhooksGet({ endpointId: "ep_missing" }); await expect(promise).rejects.toBeInstanceOf(CliError); - await expect(webhooksGet({ endpointId: "ep_missing" })).rejects.toMatchObject({ + await expect(promise).rejects.toMatchObject({ code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, message: "No webhook endpoint with ID ep_missing was found.", }); diff --git a/packages/cli-core/src/commands/webhooks/get.ts b/packages/cli-core/src/commands/webhooks/get.ts index c364346a..4662ab01 100644 --- a/packages/cli-core/src/commands/webhooks/get.ts +++ b/packages/cli-core/src/commands/webhooks/get.ts @@ -1,4 +1,5 @@ import { resolveAppContext } from "../../lib/config.ts"; +import { withApiContext } from "../../lib/errors.ts"; import { getWebhookEndpoint } from "../../lib/plapi.ts"; import { formatEndpointDetails, @@ -15,7 +16,10 @@ export interface WebhooksGetOptions extends WebhooksGlobalOptions { export async function webhooksGet(options: WebhooksGetOptions): Promise { const ctx = await resolveAppContext(options); const endpoint = await rejectEndpointNotFound( - getWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + withApiContext( + getWebhookEndpoint(ctx.appId, ctx.instanceId, options.endpointId), + "Failed to fetch webhook endpoint", + ), options.endpointId, ); diff --git a/packages/cli-core/src/commands/webhooks/index.ts b/packages/cli-core/src/commands/webhooks/index.ts index f71e2ec3..17d6762c 100644 --- a/packages/cli-core/src/commands/webhooks/index.ts +++ b/packages/cli-core/src/commands/webhooks/index.ts @@ -1,3 +1,7 @@ +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"; @@ -12,7 +16,7 @@ import { webhooksTrigger } from "./trigger.ts"; import { webhooksUpdate } from "./update.ts"; import { webhooksVerify } from "./verify.ts"; -export const webhooks = { +const webhooksHandlers = { list: webhooksList, get: webhooksGet, eventTypes: webhooksEventTypes, @@ -27,3 +31,397 @@ export const webhooks = { 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 index 4be2f37e..26017a02 100644 --- a/packages/cli-core/src/commands/webhooks/list.test.ts +++ b/packages/cli-core/src/commands/webhooks/list.test.ts @@ -1,4 +1,5 @@ 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(); @@ -160,4 +161,25 @@ describe("webhooks list", () => { 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 index f6170fdf..5e812cde 100644 --- a/packages/cli-core/src/commands/webhooks/list.ts +++ b/packages/cli-core/src/commands/webhooks/list.ts @@ -1,6 +1,6 @@ import { cyan, dim } from "../../lib/color.ts"; import { resolveAppContext } from "../../lib/config.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { PlapiError, withApiContext } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { listWebhookEndpoints, type WebhookEndpoint } from "../../lib/plapi.ts"; import { @@ -50,7 +50,15 @@ export async function webhooksList(options: WebhooksListOptions = {}): Promise { + 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); diff --git a/packages/cli-core/src/commands/webhooks/listen.test.ts b/packages/cli-core/src/commands/webhooks/listen.test.ts index 1d5ab0cb..cb0e1448 100644 --- a/packages/cli-core/src/commands/webhooks/listen.test.ts +++ b/packages/cli-core/src/commands/webhooks/listen.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +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"; @@ -211,6 +211,60 @@ describe("webhooks listen", () => { 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); @@ -220,10 +274,14 @@ describe("webhooks listen", () => { expect(ready).toEqual({ type: "ready", relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", - signing_secret: SECRET, 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 () => { @@ -363,14 +421,50 @@ describe("webhooks listen", () => { test("token rotation persists the new token and re-points the endpoint URL", async () => { await startListen({}, captured); - await lastClient()!.options.onTokenRotated("Zz98Yy76Xx"); + await lastClient()!.options.onTokenRotated("c_Zz98Yy76Xx"); expect(mockSetRelayEntry).toHaveBeenLastCalledWith("ins_1", { - token: "Zz98Yy76Xx", + token: "c_Zz98Yy76Xx", endpoint_id: "ep_relay", }); expect(mockUpdateWebhookEndpoint).toHaveBeenCalledWith("app_1", "ins_1", "ep_relay", { - url: "https://play.svix.com/in/Zz98Yy76Xx/", + 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 index 83095360..455acc63 100644 --- a/packages/cli-core/src/commands/webhooks/listen.ts +++ b/packages/cli-core/src/commands/webhooks/listen.ts @@ -1,5 +1,12 @@ import { getRelayEntry, resolveAppContext, setRelayEntry } from "../../lib/config.ts"; -import { EXIT_CODE, PlapiError, errorMessage } from "../../lib/errors.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 { @@ -42,6 +49,8 @@ export interface WebhooksListenOptions extends WebhooksGlobalOptions { events?: string; skipVerify?: boolean; headers?: string; + relayOnly?: boolean; + token?: string; } interface ListenContext { @@ -49,6 +58,19 @@ interface ListenContext { 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(); @@ -71,7 +93,10 @@ async function ensureRelayEndpoint( if (entry?.endpoint_id) { try { - let endpoint = await getWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id); + 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)) { @@ -79,23 +104,37 @@ async function ensureRelayEndpoint( "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 updateWebhookEndpoint(ctx.appId, ctx.instanceId, entry.endpoint_id, patch); + 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)) throw 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 createWebhookEndpoint(ctx.appId, ctx.instanceId, { - url: relayUrl, - version: 1, - ...(eventsFilter ? { filter_types: eventsFilter } : {}), - }); + 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; } @@ -119,45 +158,63 @@ function forwardPath(forwardTo: string): string { } 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; - const ctx = await resolveAppContext(options); + if (options.token) { + if (!relayOnly) throwUsageError("--token is only valid with --relay-only."); + assertRelayToken(options.token); + } - const entry = await getRelayEntry(ctx.instanceId); - let token = entry?.token; - if (!token) { - token = generateRelayToken(); - await setRelayEntry(ctx.instanceId, { ...entry, 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 endpointSecret = ""; let shuttingDown = false; // Deliveries can arrive as soon as the relay handshake completes (flow step // 2), but the signing secret only lands after the endpoint is resolved (step // 5) — verifying against the empty secret would warn falsely, so processing - // waits on this gate until the ready line is out. - let releaseSetupGate!: () => void; - const setupGate = new Promise((resolve) => { - releaseSetupGate = resolve; + // 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.removeAllListeners("SIGINT"); + process.removeListener("SIGINT", cliSigintHandler); process.on("SIGINT", () => { void (async () => { - shuttingDown = true; - releaseSetupGate(); // gated deliveries must settle or the drain hangs + shuttingDown = true; // MUST precede resolveSetupGate so processDelivery short-circuits + resolveSetupGate(""); // gated deliveries must settle or the drain hangs client?.stop(); - await Promise.allSettled(inFlight); + await Promise.allSettled([...inFlight, ...(tokenRotationTask ? [tokenRotationTask] : [])]); process.exit(EXIT_CODE.SIGINT); })(); }); @@ -166,14 +223,14 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi event: RelayEventFrame, reply: (frame: string) => void, ): Promise { - await setupGate; + const endpointSecret = await setupGate; if (shuttingDown) return; const body = decodeEventBody(event); const svixId = event.headers["svix-id"] ?? event.id; const eventType = extractEventType(body); - if (!options.skipVerify) { + if (verifyDeliveries) { const verified = verifyWebhookSignature({ secret: endpointSecret, id: svixId, @@ -222,8 +279,8 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi return; } - if (outcome) { - renderForwardResult(outcome, event.method, forwardPath(options.forwardTo!)); + if (outcome && options.forwardTo) { + renderForwardResult(outcome, event.method, forwardPath(options.forwardTo)); renderForwardDiagnostics(outcome, svixId); } } @@ -237,22 +294,33 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi inFlight.add(task); void task.finally(() => inFlight.delete(task)); }, - onTokenRotated: async (newToken) => { - const current = await getRelayEntry(ctx.instanceId); - await setRelayEntry(ctx.instanceId, { ...current, token: newToken }); - // The registered endpoint must follow the new relay URL or deliveries - // land in the old (now foreign) inbox. - if (current?.endpoint_id) { - try { - await updateWebhookEndpoint(ctx.appId, ctx.instanceId, current.endpoint_id, { - url: relayReceiveUrl(newToken), - }); - } catch (error) { - log.warn( - `Could not re-point the relay endpoint after a token rotation: ${errorMessage(error)}`, - ); - } + 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")); @@ -261,17 +329,21 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi await client.start(); - const endpoint = await ensureRelayEndpoint(ctx, client.token, eventsFilter); - ({ secret: endpointSecret } = await getWebhookEndpointSecret( - ctx.appId, - ctx.instanceId, - endpoint.id, - )); + 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: endpointSecret, - endpointId: endpoint.id, + signingSecret, + endpointId, eventsFilter: eventsFilter ?? null, forwardTo: options.forwardTo ?? null, }; @@ -280,7 +352,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi } else { renderReadyBanner(readyInfo); } - releaseSetupGate(); + 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 index d250c68c..f2ab49e7 100644 --- a/packages/cli-core/src/commands/webhooks/messages.test.ts +++ b/packages/cli-core/src/commands/webhooks/messages.test.ts @@ -136,6 +136,18 @@ describe("webhooks messages", () => { 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)); diff --git a/packages/cli-core/src/commands/webhooks/messages.ts b/packages/cli-core/src/commands/webhooks/messages.ts index ab07ad93..03d71bf5 100644 --- a/packages/cli-core/src/commands/webhooks/messages.ts +++ b/packages/cli-core/src/commands/webhooks/messages.ts @@ -71,6 +71,7 @@ export async function webhooksMessages(options: WebhooksMessagesOptions = {}): P if (response.data.length === 0) { log.warn(`No deliveries found for \`${endpointId}\`.`); + printIteratorHint(response.cursor); return; } diff --git a/packages/cli-core/src/commands/webhooks/open.test.ts b/packages/cli-core/src/commands/webhooks/open.test.ts index 1432e315..69d089f2 100644 --- a/packages/cli-core/src/commands/webhooks/open.test.ts +++ b/packages/cli-core/src/commands/webhooks/open.test.ts @@ -1,4 +1,5 @@ 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(); @@ -84,4 +85,25 @@ describe("webhooks open", () => { 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 index 58518fd7..561d5e8f 100644 --- a/packages/cli-core/src/commands/webhooks/open.ts +++ b/packages/cli-core/src/commands/webhooks/open.ts @@ -1,6 +1,6 @@ import { cyan, dim } from "../../lib/color.ts"; import { resolveAppContext } from "../../lib/config.ts"; -import { withApiContext } from "../../lib/errors.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"; @@ -13,7 +13,14 @@ export async function webhooksOpen(options: WebhooksOpenOptions = {}): Promise { + 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 }); diff --git a/packages/cli-core/src/commands/webhooks/relay-client.test.ts b/packages/cli-core/src/commands/webhooks/relay-client.test.ts index 109e3658..f498bcad 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.test.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.test.ts @@ -273,4 +273,18 @@ describe("RelayClient", () => { 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 index 0361a95b..21ab14db 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -20,6 +20,8 @@ export interface RelayClientOptions { 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; } /** @@ -35,6 +37,8 @@ export class RelayClient { 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; @@ -42,9 +46,23 @@ export class RelayClient { /** Dial and resolve once the first connection is open and handshaken. */ start(): Promise { - const opened = new Promise((resolve) => { - this.resolveFirstOpen = resolve; + 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; } @@ -69,12 +87,13 @@ export class RelayClient { ws.close(1000); return; } - log.debug(`relay: connected, sending start frame (token=${this.token})`); + 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) => { @@ -127,6 +146,7 @@ export class RelayClient { if (Date.now() - this.lastActivityAt < RELAY_SILENCE_TIMEOUT_MS) return; try { ws.ping(); + this.lastActivityAt = Date.now(); } catch { ws.close(); } diff --git a/packages/cli-core/src/commands/webhooks/render.test.ts b/packages/cli-core/src/commands/webhooks/render.test.ts index 19e6a2ad..4335e6c4 100644 --- a/packages/cli-core/src/commands/webhooks/render.test.ts +++ b/packages/cli-core/src/commands/webhooks/render.test.ts @@ -34,13 +34,32 @@ describe("buildReadyLine", () => { }); expect(line).not.toContain("\n"); - expect(JSON.parse(line)).toEqual({ + const parsed = JSON.parse(line) as Record; + expect(parsed).toEqual({ type: "ready", relay_url: "https://play.svix.com/in/Ab12Cd34Ef/", - signing_secret: "whsec_abc", 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"); }); }); diff --git a/packages/cli-core/src/commands/webhooks/render.ts b/packages/cli-core/src/commands/webhooks/render.ts index f53d3a8d..0e0d29ac 100644 --- a/packages/cli-core/src/commands/webhooks/render.ts +++ b/packages/cli-core/src/commands/webhooks/render.ts @@ -10,8 +10,9 @@ import type { ForwardOutcome } from "./forward.ts"; export interface ReadyInfo { relayUrl: string; - signingSecret: string; - endpointId: string; + // null in relay-only mode: no registered endpoint, no signing secret. + signingSecret: string | null; + endpointId: string | null; eventsFilter: string[] | null; forwardTo: string | null; } @@ -21,9 +22,9 @@ export function buildReadyLine(info: ReadyInfo): string { return JSON.stringify({ type: "ready", relay_url: info.relayUrl, - signing_secret: info.signingSecret, endpoint_id: info.endpointId, events_filter: info.eventsFilter, + forward_to: info.forwardTo, }); } @@ -50,6 +51,29 @@ export function buildEventLine(args: { 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( [ "", diff --git a/packages/cli-core/src/commands/webhooks/replay.test.ts b/packages/cli-core/src/commands/webhooks/replay.test.ts index c87c0d7a..425efa4f 100644 --- a/packages/cli-core/src/commands/webhooks/replay.test.ts +++ b/packages/cli-core/src/commands/webhooks/replay.test.ts @@ -83,6 +83,14 @@ describe("webhooks replay", () => { 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, diff --git a/packages/cli-core/src/commands/webhooks/replay.ts b/packages/cli-core/src/commands/webhooks/replay.ts index 45e1415c..2bf1748b 100644 --- a/packages/cli-core/src/commands/webhooks/replay.ts +++ b/packages/cli-core/src/commands/webhooks/replay.ts @@ -1,5 +1,5 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { throwUsageError } from "../../lib/errors.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { recoverWebhookMessages, resendWebhookMessage } from "../../lib/plapi.ts"; import { @@ -19,8 +19,10 @@ export interface WebhooksReplayOptions extends WebhooksGlobalOptions { } function assertRfc3339(value: string, flag: string): void { - if (Number.isNaN(Date.parse(value))) { - throwUsageError(`Invalid ${flag} value "${value}". Must be an RFC 3339 timestamp.`); + 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).`, + ); } } @@ -47,7 +49,7 @@ function validateReplayMode(options: WebhooksReplayOptions): "resend" | "recover return "resend"; } -export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promise { +export async function webhooksReplay(options: WebhooksReplayOptions): Promise { const mode = validateReplayMode(options); const windowLabel = options.until @@ -68,7 +70,10 @@ export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promi if (mode === "resend") { const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); await rejectMessageNotFound( - resendWebhookMessage(ctx.appId, ctx.instanceId, endpointId, options.msgId!), + 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}\``); @@ -76,10 +81,13 @@ export async function webhooksReplay(options: WebhooksReplayOptions = {}): Promi } await rejectEndpointNotFound( - recoverWebhookMessages(ctx.appId, ctx.instanceId, options.endpoint!, { - since: options.since!, - until: options.until, - }), + 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.ts b/packages/cli-core/src/commands/webhooks/secret.ts index 63860f17..9dc349ab 100644 --- a/packages/cli-core/src/commands/webhooks/secret.ts +++ b/packages/cli-core/src/commands/webhooks/secret.ts @@ -1,4 +1,5 @@ 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 { @@ -29,13 +30,19 @@ export async function webhooksSecret(options: WebhooksSecretOptions): Promise( +/** Map a PLAPI 404 on a resource-addressed route to a typed CLI error. */ +async function rejectNotFound( promise: Promise, - endpointId: string, + id: string, + resourceLabel: string, + code: ErrorCode, ): Promise { try { return await promise; } catch (error) { if (error instanceof PlapiError && error.status === 404) { - throw new CliError(`No webhook endpoint with ID ${endpointId} was found.`, { - code: ERROR_CODE.WEBHOOK_ENDPOINT_NOT_FOUND, - }); + throw 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 async function rejectMessageNotFound(promise: Promise, messageId: string): Promise { - try { - return await promise; - } catch (error) { - if (error instanceof PlapiError && error.status === 404) { - throw new CliError(`No webhook message with ID ${messageId} was found.`, { - code: ERROR_CODE.WEBHOOK_MESSAGE_NOT_FOUND, - }); - } - throw error; - } -} +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 diff --git a/packages/cli-core/src/commands/webhooks/trigger.test.ts b/packages/cli-core/src/commands/webhooks/trigger.test.ts index a33eb441..c0c30b22 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.test.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.test.ts @@ -125,6 +125,16 @@ describe("webhooks trigger", () => { 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, "{}")); diff --git a/packages/cli-core/src/commands/webhooks/trigger.ts b/packages/cli-core/src/commands/webhooks/trigger.ts index d62382a3..cb2a8154 100644 --- a/packages/cli-core/src/commands/webhooks/trigger.ts +++ b/packages/cli-core/src/commands/webhooks/trigger.ts @@ -1,5 +1,5 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { listWebhookEventTypes, sendWebhookExample } from "../../lib/plapi.ts"; import { @@ -22,11 +22,19 @@ async function assertKnownEventType( ): Promise { let iterator: string | undefined; do { - const page = await listWebhookEventTypes(appId, instanceId, { - limit: CATALOG_PAGE_LIMIT, - iterator, - }); + 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); @@ -48,7 +56,10 @@ export async function webhooksTrigger(options: WebhooksTriggerOptions): Promise< const endpointId = await resolveEndpointOrRelay(options.endpoint, ctx.instanceId); await rejectEndpointNotFound( - sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), + withApiContext( + sendWebhookExample(ctx.appId, ctx.instanceId, endpointId, options.eventType), + "Failed to send webhook example", + ), endpointId, ); diff --git a/packages/cli-core/src/commands/webhooks/update.test.ts b/packages/cli-core/src/commands/webhooks/update.test.ts index ab2b53c3..404f4501 100644 --- a/packages/cli-core/src/commands/webhooks/update.test.ts +++ b/packages/cli-core/src/commands/webhooks/update.test.ts @@ -105,14 +105,20 @@ describe("webhooks update", () => { expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); }); - test.each([ - { label: "--events", options: { events: "" } }, - { label: "--channels", options: { channels: " , " } }, - ])("an empty $label value does not bypass the no-flags guard", async ({ options }) => { - await expect(webhooksUpdate({ endpointId: "ep_1", ...options })).rejects.toMatchObject({ - code: ERROR_CODE.USAGE_ERROR, + 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: [], }); - expect(mockUpdateWebhookEndpoint).not.toHaveBeenCalled(); }); test("prints the updated endpoint in human mode", async () => { diff --git a/packages/cli-core/src/commands/webhooks/update.ts b/packages/cli-core/src/commands/webhooks/update.ts index ba970c6c..72ff953e 100644 --- a/packages/cli-core/src/commands/webhooks/update.ts +++ b/packages/cli-core/src/commands/webhooks/update.ts @@ -1,5 +1,5 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { throwUsageError } from "../../lib/errors.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { updateWebhookEndpoint, type UpdateWebhookEndpointParams } from "../../lib/plapi.ts"; import { @@ -29,10 +29,8 @@ export function buildUpdateParams(options: WebhooksUpdateOptions): UpdateWebhook const params: UpdateWebhookEndpointParams = {}; if (options.url !== undefined) params.url = options.url; if (options.description !== undefined) params.description = options.description; - const filterTypes = splitCommaList(options.events); - if (filterTypes !== undefined) params.filter_types = filterTypes; - const channels = splitCommaList(options.channels); - if (channels !== undefined) params.channels = channels; + if (options.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; @@ -49,7 +47,10 @@ export async function webhooksUpdate(options: WebhooksUpdateOptions): Promise { 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 = { diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index b36729ac..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"; @@ -113,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 }, ); } diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index b205084a..6afec2be 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -744,11 +744,13 @@ describe("plapi webhooks", () => { expect(url.searchParams.has("iterator")).toBe(false); }); - test("list functions omit pagination params when not provided", async () => { - await listWebhookEndpoints("app_1", "ins_1"); - - const url = captured[0]!.url; - expect(url.search).toBe(""); + test.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 () => { diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 1a5db643..742b8563 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -338,7 +338,7 @@ export type WebhookEndpoint = { id: string; url: string; version: number; - description?: string; + description?: string | null; disabled: boolean; filter_types?: string[] | null; channels?: string[] | null; @@ -359,7 +359,7 @@ export type WebhookEndpointList = { export type WebhookEventType = { name: string; - description?: string; + description: string; archived: boolean; created_at: string; updated_at: string; @@ -505,11 +505,13 @@ export async function listWebhookEventTypes( return response.json() as Promise; } +export type WebhookMessageListParams = WebhookPageParams & { status?: WebhookMessageStatus }; + export async function listWebhookMessages( applicationId: string, instanceId: string, endpointId: string, - params?: WebhookPageParams & { status?: WebhookMessageStatus }, + params?: WebhookMessageListParams, ): Promise { const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/messages`); appendPageParams(url, params); @@ -534,12 +536,11 @@ export async function recoverWebhookMessages( applicationId: string, instanceId: string, endpointId: string, - window: { since: string; until?: string }, + timeWindow: { since: string; until?: string }, ): Promise { const url = webhooksUrl(applicationId, instanceId, `/${endpointId}/recover`); - const body: { since: string; until?: string } = { since: window.since }; - if (window.until) body.until = window.until; - await plapiFetch("POST", url, { body: JSON.stringify(body) }); + // 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( @@ -557,6 +558,7 @@ export async function getWebhookPortalUrl( 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);