From 2de88b80181acd3bf83e093be64f63443be9fd7c Mon Sep 17 00:00:00 2001 From: Daniel Leal Date: Fri, 8 May 2026 19:19:06 -0400 Subject: [PATCH] feat(webhooks): send to URL --- src/__tests__/index.test.ts | 6 +++ src/commands/webhooks.ts | 12 +++++- src/lib/webhooks/__tests__/handlers.test.ts | 47 +++++++++++++++++---- src/lib/webhooks/handlers.ts | 33 +++++++++++++-- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3f6eb8a..bb0c59e 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -78,6 +78,12 @@ describe('help consistency', () => { expect(stdout).toContain('Listen for webhook events') expect(stdout).toContain('listen') }) + + test('shows listen forwarding option', () => { + const { stdout, exitCode } = run(['webhooks', 'listen', '--help']) + expect(exitCode).toBe(0) + expect(stdout).toContain('--forward-to') + }) }) describe('when operation --help is shown', () => { diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts index 0902bc9..f4fa4dc 100644 --- a/src/commands/webhooks.ts +++ b/src/commands/webhooks.ts @@ -12,6 +12,10 @@ type RootOpts = { json?: boolean } +type ListenOpts = { + forwardTo?: string +} + export const webhooksCommand = (program: Command) => { const cmd = program.command('webhooks').description('Listen for webhook events') cmd.configureHelp({ showGlobalOptions: true }) @@ -20,7 +24,8 @@ export const webhooksCommand = (program: Command) => { cmd .command('listen') .description('Listen for webhook events in real time') - .action(async (_opts: unknown, actionCmd: Command) => { + .option('--forward-to ', 'Forward webhook events to a URL') + .action(async (opts: ListenOpts, actionCmd: Command) => { const rootOpts = actionCmd.parent!.parent!.opts() const auth = resolveAuth(rootOpts) @@ -53,7 +58,10 @@ export const webhooksCommand = (program: Command) => { websocketUrl: session.websocket_url, sessionId: session.id, secret: session.secret, - handlers: createWebhookRelayHandlers({ json: rootOpts.json }), + handlers: createWebhookRelayHandlers({ + json: rootOpts.json, + forwardTo: opts.forwardTo, + }), }) } catch (err) { caughtError = err diff --git a/src/lib/webhooks/__tests__/handlers.test.ts b/src/lib/webhooks/__tests__/handlers.test.ts index 24c233e..500cbcd 100644 --- a/src/lib/webhooks/__tests__/handlers.test.ts +++ b/src/lib/webhooks/__tests__/handlers.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { printJson } from '../../output.js' import { createWebhookRelayHandlers } from '../handlers.js' @@ -11,7 +11,11 @@ describe('webhook relay handlers', () => { vi.clearAllMocks() }) - test('pretty-prints the full event for webhook messages', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('pretty-prints the full event for webhook messages', async () => { const event = JSON.stringify({ id: 'evt_123' }) const message = { type: 'webhook_event', @@ -21,12 +25,12 @@ describe('webhook relay handlers', () => { timestamp: 1_234, } as const - createWebhookRelayHandlers({}).webhook_event!(message) + await createWebhookRelayHandlers({}).webhook_event!(message) expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) }) - test('prints the full relay message in JSON mode', () => { + test('prints the full relay message in JSON mode', async () => { const message = { type: 'webhook_event', id: 'wem_123', @@ -37,13 +41,40 @@ describe('webhook relay handlers', () => { timestamp: 1_234, } as const - createWebhookRelayHandlers({ json: true }).webhook_event!(message) + await createWebhookRelayHandlers({ json: true }).webhook_event!(message) + + expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + }) + + test('forwards the raw event with the webhook signature', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + const event = JSON.stringify({ id: 'evt_123' }) + const message = { + type: 'webhook_event', + event, + signature: 'test_signature', + event_type: 'payment_intent.succeeded', + timestamp: 1_234, + } as const + + await createWebhookRelayHandlers({ + forwardTo: 'https://example.test/webhooks', + }).webhook_event!(message) + expect(fetchMock).toHaveBeenCalledWith('https://example.test/webhooks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Fintoc-Signature': 'test_signature', + }, + body: event, + }) expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) }) - test('rejects malformed webhook messages', () => { - expect(() => + test('rejects malformed webhook messages', async () => { + await expect( createWebhookRelayHandlers({}).webhook_event!({ type: 'webhook_event', event: '{}', @@ -51,6 +82,6 @@ describe('webhook relay handlers', () => { event_type: 'payment_intent.succeeded', timestamp: 1.2, }), - ).toThrow('Invalid webhook event message') + ).rejects.toThrow('Invalid webhook event message') }) }) diff --git a/src/lib/webhooks/handlers.ts b/src/lib/webhooks/handlers.ts index cc69ffd..1827166 100644 --- a/src/lib/webhooks/handlers.ts +++ b/src/lib/webhooks/handlers.ts @@ -12,19 +12,46 @@ const webhookEventMessageSchema = z.object({ export type WebhookRelayHandler = ( message: HandledRelayMessage<'webhook_event'>, - options: { json?: boolean }, + options: WebhookRelayOptions, ) => void | Promise -export const handleWebhookEvent: WebhookRelayHandler = (message, _options) => { +type WebhookRelayOptions = { + json?: boolean + forwardTo?: string +} + +const forwardWebhookEvent = async ( + url: string, + data: z.infer, +) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Fintoc-Signature': data.signature, + }, + body: data.event, + }) + + if (!response.ok) { + throw new Error(`Failed to forward webhook event: ${response.status} ${response.statusText}`) + } +} + +export const handleWebhookEvent: WebhookRelayHandler = async (message, options) => { const result = webhookEventMessageSchema.safeParse(message) if (!result.success) { throw new Error(`Invalid webhook event message: ${z.prettifyError(result.error)}`) } printJson(JSON.parse(result.data.event)) + + if (options.forwardTo) { + await forwardWebhookEvent(options.forwardTo, result.data) + } } -export const createWebhookRelayHandlers = (options: { json?: boolean }) => { +export const createWebhookRelayHandlers = (options: WebhookRelayOptions) => { return { webhook_event: (message) => handleWebhookEvent(message, options), } satisfies RelayMessageHandlers