diff --git a/package-lock.json b/package-lock.json index c39a1ec..d7629b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,14 @@ "": { "name": "@fintoc/cli", "version": "0.1.1", - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { "@inquirer/prompts": "^8.4.2", "commander": "^14.0.3", "fintoc": "^1.19.0", - "smol-toml": "^1.6.1" + "smol-toml": "^1.6.1", + "ws": "^8.20.0", + "zod": "^4.4.3" }, "bin": { "fintoc": "dist/index.js" @@ -20,6 +22,7 @@ "devDependencies": { "@antfu/eslint-config": "^8.2.0", "@types/node": "^25.6.0", + "@types/ws": "^8.18.1", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", @@ -2259,6 +2262,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -7819,6 +7832,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -7888,6 +7922,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 2f524f7..c2092e3 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,14 @@ "@inquirer/prompts": "^8.4.2", "commander": "^14.0.3", "fintoc": "^1.19.0", - "smol-toml": "^1.6.1" + "smol-toml": "^1.6.1", + "ws": "^8.20.0", + "zod": "^4.4.3" }, "devDependencies": { "@antfu/eslint-config": "^8.2.0", "@types/node": "^25.6.0", + "@types/ws": "^8.18.1", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c2eb74c..3f6eb8a 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -48,6 +48,7 @@ describe('help consistency', () => { expect(stdout).toContain('Auth:') expect(stdout).toContain('Resources:') expect(stdout).toContain('Utilities:') + expect(stdout).toContain('webhooks') expect(stdout).toContain('Get started: fintoc login') }) }) @@ -70,6 +71,15 @@ describe('help consistency', () => { }) }) + describe('when webhooks command is called without a verb', () => { + test('exits 0 and shows webhooks help', () => { + const { stdout, exitCode } = run(['webhooks']) + expect(exitCode).toBe(0) + expect(stdout).toContain('Listen for webhook events') + expect(stdout).toContain('listen') + }) + }) + describe('when operation --help is shown', () => { test('shows operation-specific options and global options', () => { const { stdout, exitCode } = run(['payment_intents', 'list', '--help']) diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts new file mode 100644 index 0000000..0902bc9 --- /dev/null +++ b/src/commands/webhooks.ts @@ -0,0 +1,69 @@ +import type { Command } from 'commander' +import { listenToRelay } from '../lib/action-cable.js' +import { resolveAuth } from '../lib/auth.js' +import { addDefaultAction } from '../lib/commands.js' +import { handleError } from '../lib/errors.js' +import { hint, info } from '../lib/output.js' +import { createWebhookRelayHandlers } from '../lib/webhooks/handlers.js' +import { createCliSession } from '../lib/webhooks/sessions.js' + +type RootOpts = { + apiKey?: string + json?: boolean +} + +export const webhooksCommand = (program: Command) => { + const cmd = program.command('webhooks').description('Listen for webhook events') + cmd.configureHelp({ showGlobalOptions: true }) + addDefaultAction(cmd) + + cmd + .command('listen') + .description('Listen for webhook events in real time') + .action(async (_opts: unknown, actionCmd: Command) => { + const rootOpts = actionCmd.parent!.parent!.opts() + const auth = resolveAuth(rootOpts) + + const shutdown = () => { + process.exit(0) + } + + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) + + let caughtError: unknown + try { + const session = await createCliSession({ + secretKey: auth.secretKey, + streamType: 'webhook_event', + }) + + if (!rootOpts.json) { + const whsMessage = session.webhook_secret + ? ` Your webhook signing secret is ${session.webhook_secret}` + : '' + + info(`Listening for webhooks.${whsMessage}`) + info('Press Ctrl+C to stop.') + + hint('') + } + + await listenToRelay({ + websocketUrl: session.websocket_url, + sessionId: session.id, + secret: session.secret, + handlers: createWebhookRelayHandlers({ json: rootOpts.json }), + }) + } catch (err) { + caughtError = err + } finally { + process.off('SIGINT', shutdown) + process.off('SIGTERM', shutdown) + } + + if (caughtError) { + handleError(caughtError, { json: rootOpts.json }) + } + }) +} diff --git a/src/index.ts b/src/index.ts index a4a5165..a4448b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { doctorCommand } from './commands/doctor.js' import { loginCommand } from './commands/login.js' import { logoutCommand } from './commands/logout.js' import { openCommand } from './commands/open.js' +import { webhooksCommand } from './commands/webhooks.js' import { addDefaultAction } from './lib/commands.js' import { error } from './lib/output.js' import { registerResourceCommands } from './resources/factory.js' @@ -15,7 +16,7 @@ declare const __CLI_VERSION__: string const versionString = `fintoc/${__CLI_VERSION__} ${process.platform} node-${process.version}` const AUTH_COMMANDS = new Set(['login', 'logout', 'config']) -const UTILITY_COMMANDS = new Set(['doctor', 'open']) +const UTILITY_COMMANDS = new Set(['doctor', 'open', 'webhooks']) type HelpEntry = { name: () => string; description: () => string } @@ -76,6 +77,7 @@ logoutCommand(program) configCommand(program) doctorCommand(program) openCommand(program) +webhooksCommand(program) registerResourceCommands(program, v1Resources) const v2Cmd = program.command('v2').description('API v2 resources') diff --git a/src/lib/__tests__/action-cable.test.ts b/src/lib/__tests__/action-cable.test.ts new file mode 100644 index 0000000..48e20ee --- /dev/null +++ b/src/lib/__tests__/action-cable.test.ts @@ -0,0 +1,64 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, test } from 'vitest' +import { + createSubscribeCommand, + createSubscriptionIdentifier, + originForWebSocketUrl, + parseActionCableMessage, +} from '../action-cable.js' + +describe('ActionCable relay', () => { + test('creates subscription identifier with session credentials', () => { + expect(JSON.parse(createSubscriptionIdentifier('clisess_123', 'whsec_test_123'))).toEqual({ + channel: 'CliSessionsChannel', + session_id: 'clisess_123', + secret: 'whsec_test_123', + }) + }) + + test('creates ActionCable subscribe command', () => { + const payload = JSON.parse(createSubscribeCommand('clisess_123', 'whsec_test_123')) + + expect(payload).toEqual({ + command: 'subscribe', + identifier: createSubscriptionIdentifier('clisess_123', 'whsec_test_123'), + }) + }) + + test('derives an allowed Origin from the websocket URL', () => { + expect(originForWebSocketUrl('ws://api.localhost:3000/cable')).toBe('http://api.localhost:3000') + expect(originForWebSocketUrl('wss://api.fintoc.com/cable')).toBe('https://api.fintoc.com') + }) + + test('parses valid ActionCable messages', () => { + const payload = { + identifier: 'subscription-id', + message: { + type: 'webhook_event', + event: { id: 'evt_123' }, + }, + } + + expect(parseActionCableMessage(Buffer.from(JSON.stringify(payload)))).toEqual({ + message: payload.message, + }) + }) + + test('ignores invalid JSON', () => { + expect(parseActionCableMessage(Buffer.from('{'))).toBeUndefined() + }) + + test('ignores malformed ActionCable messages', () => { + expect( + parseActionCableMessage( + Buffer.from( + JSON.stringify({ + message: { + type: 123, + }, + }), + ), + ), + ).toBeUndefined() + }) +}) diff --git a/src/lib/action-cable.ts b/src/lib/action-cable.ts new file mode 100644 index 0000000..1b9bdd3 --- /dev/null +++ b/src/lib/action-cable.ts @@ -0,0 +1,145 @@ +import WebSocket from 'ws' +import * as z from 'zod/mini' + +const relayMessageSchema = z.looseObject({ + type: z.string(), +}) + +const actionCableMessageSchema = z.object({ + type: z.optional(z.string()), + message: z.optional(relayMessageSchema), +}) + +export type RelayMessage = z.infer +export type ActionCableMessage = z.infer + +export const handledMessageTypes = ['webhook_event'] as const +export type HandledMessageType = (typeof handledMessageTypes)[number] + +export type HandledRelayMessage = Omit< + RelayMessage, + 'type' +> & + Record & { type: T } + +export type RelayMessageHandler = ( + message: HandledRelayMessage, +) => void | Promise + +export type RelayMessageHandlers = { + [T in HandledMessageType]?: RelayMessageHandler +} + +type ListenOptions = { + websocketUrl: string + sessionId: string + secret: string + handlers: RelayMessageHandlers +} + +export const originForWebSocketUrl = (websocketUrl: string) => { + const url = new URL(websocketUrl) + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' + url.pathname = '' + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') +} + +export const createSubscriptionIdentifier = (sessionId: string, secret: string) => + JSON.stringify({ + channel: 'CliSessionsChannel', + session_id: sessionId, + secret, + }) + +export const createSubscribeCommand = (sessionId: string, secret: string) => + JSON.stringify({ + command: 'subscribe', + identifier: createSubscriptionIdentifier(sessionId, secret), + }) + +export const parseActionCableMessage = ( + data: WebSocket.RawData, +): ActionCableMessage | undefined => { + let parsed: unknown + try { + parsed = JSON.parse(data.toString()) + } catch { + return undefined + } + + const result = actionCableMessageSchema.safeParse(parsed) + return result.success ? result.data : undefined +} + +const messageCanBeHandled = (message: RelayMessage): message is HandledRelayMessage => { + return handledMessageTypes.includes(message.type as HandledMessageType) +} + +const handleRelayMessage = (message: RelayMessage, handlers: RelayMessageHandlers) => { + if (!messageCanBeHandled(message)) { + return undefined + } + + const handler = handlers[message.type] + return handler?.(message) +} + +export const listenToRelay = ({ websocketUrl, sessionId, secret, handlers }: ListenOptions) => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(websocketUrl, { + headers: { + Origin: originForWebSocketUrl(websocketUrl), + }, + }) + let closed = false + + const finish = (err?: Error) => { + if (closed) { + return + } + closed = true + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close() + } + if (err) { + reject(err) + } else { + resolve() + } + } + + ws.on('open', () => { + ws.send(createSubscribeCommand(sessionId, secret)) + }) + + ws.on('message', (data) => { + const payload = parseActionCableMessage(data) + if (!payload) { + return + } + + if (payload.type === 'reject_subscription') { + finish(new Error('Relay listener subscription was rejected')) + return + } + if (payload.type) { + return + } + if (!payload.message) { + return + } + + Promise.resolve(handleRelayMessage(payload.message, handlers)).catch(finish) + }) + + ws.on('error', (err) => { + finish(err instanceof Error ? err : new Error('WebSocket connection failed')) + }) + + ws.on('close', () => { + finish() + }) + }) +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..0e5e870 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,54 @@ +import { API_HOST } from './constants.js' +import { getCliVersion } from './version.js' + +type ApiRequestOptions = { + method: 'GET' | 'POST' + secretKey: string + body?: Record + signal?: AbortSignal +} + +const apiBaseUrl = () => { + if (API_HOST.includes('localhost')) { + return `http://${API_HOST}` + } + return `https://${API_HOST}` +} + +export const apiUrl = (path: string) => new URL(path, apiBaseUrl()).toString() + +export const apiRequest = async (path: string, options: ApiRequestOptions): Promise => { + let response: Response + try { + response = await fetch(apiUrl(path), { + method: options.method, + signal: options.signal, + headers: { + Authorization: options.secretKey, + 'Content-Type': 'application/json', + 'User-Agent': `fintoc-cli/${getCliVersion()}`, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + } catch (err) { + if (err instanceof Error) { + ;(err as Error & { code?: string }).code = 'ERR_NETWORK' + } + throw err + } + + if (response.status === 204) { + return undefined + } + + const data = (await response.json().catch(() => ({}))) as unknown + if (!response.ok) { + const err = new Error(response.statusText || 'API request failed') as Error & { + response?: { data?: unknown } + } + err.response = { data } + throw err + } + + return data +} diff --git a/src/lib/webhooks/__tests__/handlers.test.ts b/src/lib/webhooks/__tests__/handlers.test.ts new file mode 100644 index 0000000..24c233e --- /dev/null +++ b/src/lib/webhooks/__tests__/handlers.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { printJson } from '../../output.js' +import { createWebhookRelayHandlers } from '../handlers.js' + +vi.mock('../../output.js', () => ({ + printJson: vi.fn(), +})) + +describe('webhook relay handlers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('pretty-prints the full event for webhook messages', () => { + 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 + + createWebhookRelayHandlers({}).webhook_event!(message) + + expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + }) + + test('prints the full relay message in JSON mode', () => { + const message = { + type: 'webhook_event', + id: 'wem_123', + status: 'pending', + event: JSON.stringify({ id: 'evt_123' }), + signature: 'test_signature', + event_type: 'payment_intent.succeeded', + timestamp: 1_234, + } as const + + createWebhookRelayHandlers({ json: true }).webhook_event!(message) + + expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + }) + + test('rejects malformed webhook messages', () => { + expect(() => + createWebhookRelayHandlers({}).webhook_event!({ + type: 'webhook_event', + event: '{}', + signature: 'test_signature', + event_type: 'payment_intent.succeeded', + timestamp: 1.2, + }), + ).toThrow('Invalid webhook event message') + }) +}) diff --git a/src/lib/webhooks/__tests__/sessions.test.ts b/src/lib/webhooks/__tests__/sessions.test.ts new file mode 100644 index 0000000..1ff7022 --- /dev/null +++ b/src/lib/webhooks/__tests__/sessions.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createCliSession } from '../sessions.js' + +vi.mock('../../version.js', () => ({ + getCliVersion: vi.fn(() => '0.1.1'), +})) + +describe('CLI webhook sessions', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + test('creates a session for all webhook events', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: 'clisess_123', + object: 'cli_session', + websocket_url: 'wss://api.fintoc.com/cable', + websocket_id: 'ws_123', + secret: 'a'.repeat(128), + webhook_secret: 'whsec_test_123', + expires_at: '2026-01-01T00:00:00.000Z', + }), + { status: 201 }, + ), + ) + + const session = await createCliSession({ + secretKey: 'sk_test_123', + streamType: 'webhook_event', + }) + + expect(session).toMatchObject({ + id: 'clisess_123', + websocket_url: 'wss://api.fintoc.com/cable', + websocket_id: 'ws_123', + secret: 'a'.repeat(128), + webhook_secret: 'whsec_test_123', + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/internal\/v1\/cli\/sessions$/), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'sk_test_123' }), + body: JSON.stringify({ stream_type: 'webhook_event' }), + }), + ) + }) + + test('accepts sessions without a webhook secret', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: 'clisess_123', + websocket_url: 'wss://api.fintoc.com/cable', + websocket_id: 'ws_123', + secret: 'a'.repeat(128), + }), + { status: 201 }, + ), + ) + + const session = await createCliSession({ + secretKey: 'sk_test_123', + streamType: 'webhook_event', + }) + + expect(session).not.toHaveProperty('webhook_secret') + }) + + test('rejects invalid session responses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: 'clisess_123', + websocket_url: 'https://api.fintoc.com/cable', + websocket_id: 'ws_123', + secret: 'a'.repeat(128), + }), + { status: 201 }, + ), + ) + + await expect( + createCliSession({ + secretKey: 'sk_test_123', + streamType: 'webhook_event', + }), + ).rejects.toThrow('Invalid CLI session response') + }) +}) diff --git a/src/lib/webhooks/handlers.ts b/src/lib/webhooks/handlers.ts new file mode 100644 index 0000000..cc69ffd --- /dev/null +++ b/src/lib/webhooks/handlers.ts @@ -0,0 +1,31 @@ +import type { HandledRelayMessage, RelayMessageHandlers } from '../action-cable.js' +import * as z from 'zod/mini' +import { printJson } from '../output.js' + +const webhookEventMessageSchema = z.object({ + type: z.literal('webhook_event'), + event: z.string(), + signature: z.string(), + event_type: z.string(), + timestamp: z.int(), +}) + +export type WebhookRelayHandler = ( + message: HandledRelayMessage<'webhook_event'>, + options: { json?: boolean }, +) => void | Promise + +export const handleWebhookEvent: WebhookRelayHandler = (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)) +} + +export const createWebhookRelayHandlers = (options: { json?: boolean }) => { + return { + webhook_event: (message) => handleWebhookEvent(message, options), + } satisfies RelayMessageHandlers +} diff --git a/src/lib/webhooks/sessions.ts b/src/lib/webhooks/sessions.ts new file mode 100644 index 0000000..821e9fd --- /dev/null +++ b/src/lib/webhooks/sessions.ts @@ -0,0 +1,45 @@ +import * as z from 'zod/mini' +import { apiRequest } from '../api.js' + +type CreateOptions = { + secretKey: string + streamType: 'webhook_event' +} + +const nonEmptyString = z.string().check(z.minLength(1)) + +const websocketUrl = z.stringFormat('websocket_url', (value) => { + try { + const url = new URL(value) + return url.protocol === 'ws:' || url.protocol === 'wss:' + } catch { + return false + } +}) + +const cliSessionSchema = z.object({ + id: nonEmptyString, + websocket_id: nonEmptyString, + websocket_url: websocketUrl, + secret: nonEmptyString, + webhook_secret: z.optional(nonEmptyString), +}) + +export type CliSession = z.infer + +export const createCliSession = async (options: CreateOptions): Promise => { + const response = await apiRequest('/internal/v1/cli/sessions', { + method: 'POST', + secretKey: options.secretKey, + body: { + stream_type: options.streamType, + }, + }) + + const result = cliSessionSchema.safeParse(response) + if (!result.success) { + throw new Error(`Invalid CLI session response: ${z.prettifyError(result.error)}`) + } + + return result.data +}