From dd153c0490673a96d13621853d4412ced353035d Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Fri, 1 May 2026 13:17:06 +0000 Subject: [PATCH 1/3] feat(webhooks): Wave native webhook receiver + per-business secret storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Wave's native webhook flow to chittyfinance, scoped per (tenant, business) pair to support tenants with multiple connected Wave businesses. Wave webhook subscriptions are configured in the Wave dashboard (no programmatic API exists per Wave's GraphQL schema), so operators paste the per-business URL into the Wave Webhooks page and seed the corresponding secret via the admin endpoint. New endpoints (all under /api/webhooks/wave/:tenantId/:businessId): - POST — receiver (HMAC verified, dedup'd, audit-logged) - PUT /secret — admin secret-storage (service token auth) - GET /secret/exists — admin existence check (never returns secret) - DELETE /secret — admin secret removal Verification matches Wave's documented signature scheme: - Header: x-wave-signature: t=,v1= - Signed payload: . (raw bytes, not re-serialized) - 5-minute replay window enforced - Constant-time hex compare Receiver behaviour: - Skips signature verification when no secret stored (allows initial Wave dashboard ping during setup) - Validates payload business_id matches URL parameter (defense in depth) - KV idempotency keyed on event_id, 7-day TTL - Audit-logs each recognized event via ledgerLog (ChittyLedger) - Treats empty body or unrecognized JSON shape as a setup ping (200 ack) Currently supported event types per Wave's Webhooks Setup Guide: invoice.overdue, invoice.viewed, invoice.approved ("More supported events will be available soon" — receiver is generic and audit-logs all conformant events; specific business logic for future events is added as Wave expands the surface.) KV layout: webhook:wave:secret:: → HMAC secret webhook:wave:dedup: → 7d TTL idempotency marker Tests: +28 (16 secret-storage + 12 receiver) — full suite 281 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/webhooks-wave-receiver.test.ts | 305 ++++++++++++++++++ server/__tests__/webhooks-wave-secret.test.ts | 214 ++++++++++++ server/routes/webhooks.ts | 243 ++++++++++++++ 3 files changed, 762 insertions(+) create mode 100644 server/__tests__/webhooks-wave-receiver.test.ts create mode 100644 server/__tests__/webhooks-wave-secret.test.ts diff --git a/server/__tests__/webhooks-wave-receiver.test.ts b/server/__tests__/webhooks-wave-receiver.test.ts new file mode 100644 index 0000000..c972194 --- /dev/null +++ b/server/__tests__/webhooks-wave-receiver.test.ts @@ -0,0 +1,305 @@ +/** + * Tests for POST /api/webhooks/wave/:tenantId/:businessId — Wave native receiver. + * + * Exercises the documented Wave webhook flow: + * - x-wave-signature: t=,v1= + * - HMAC-SHA256 over `.` using per-(tenant,business) secret + * - 5-minute replay window + * - business_id in payload must match URL parameter + * - KV-based dedup via event_id (7-day TTL) + * + * No DB or service modules — only KV (Map-backed) and ledger-client (real, but + * configured to skip network in test env via CHITTY_LEDGER_BASE). + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { webhookRoutes } from '../routes/webhooks'; + +// Stub ledger client — its actual fetch is skipped in test env via env vars, +// but we don't want any real I/O attempted at all. +vi.mock('../lib/ledger-client', () => ({ + ledgerLog: vi.fn(), +})); + +const SERVICE_TOKEN = 'test-service-token'; +const SECRET = 'wave-test-secret-32bytes'; +const TENANT = '11111111-1111-1111-1111-111111111111'; +const BUSINESS = 'biz-deadbeef'; +const KEY = `webhook:wave:secret:${TENANT}:${BUSINESS}`; + +function makeKv() { + const store = new Map(); + return { + store, + binding: { + get: async (k: string) => store.get(k) ?? null, + put: async (k: string, v: string, _opts?: unknown) => { + store.set(k, v); + }, + delete: async (k: string) => { + store.delete(k); + }, + } as unknown as KVNamespace, + }; +} + +function makeEnv(kv: KVNamespace) { + return { + CHITTY_AUTH_SERVICE_TOKEN: SERVICE_TOKEN, + FINANCE_KV: kv, + } as Parameters[1]; +} + +async function hmacHex(secret: string, payload: string): Promise { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)); + return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, '0')).join(''); +} + +async function buildSignedRequest(opts: { + body: object | string; + secret?: string; + timestamp?: number; + signatureOverride?: string; + tenantId?: string; + businessId?: string; +}): Promise { + const tenantId = opts.tenantId ?? TENANT; + const businessId = opts.businessId ?? BUSINESS; + const rawBody = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + const ts = opts.timestamp ?? Math.floor(Date.now() / 1000); + const headers: Record = { 'content-type': 'application/json' }; + + if (opts.signatureOverride !== undefined) { + headers['x-wave-signature'] = opts.signatureOverride; + } else if (opts.secret !== undefined) { + const sig = await hmacHex(opts.secret, `${ts}.${rawBody}`); + headers['x-wave-signature'] = `t=${ts},v1=${sig}`; + headers['x-wave-timestamp'] = String(ts); + } + + return new Request(`http://x/api/webhooks/wave/${tenantId}/${businessId}`, { + method: 'POST', + headers, + body: rawBody, + }); +} + +function validInvoiceOverdueEvent(overrides: Partial<{ event_id: string; business_id: string }> = {}) { + return { + event_id: overrides.event_id ?? 'evt-1', + event_type: 'invoice.overdue', + business_id: overrides.business_id ?? BUSINESS, + data: { + invoice_id: 'inv-1', + customer_id: 'cust-1', + currency_code: 'USD', + due_date: '2026-04-30', + invoice_balance: '200.00', + issue_date: '2026-04-30', + }, + }; +} + +async function storeSecret(kv: ReturnType, secret = SECRET) { + kv.store.set(KEY, secret); +} + +describe('Wave webhook receiver', () => { + describe('signature verification', () => { + it('rejects when secret is stored but signature header is missing', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(validInvoiceOverdueEvent()), + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'invalid_signature' }); + }); + + it('rejects when signature is wrong', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const ts = Math.floor(Date.now() / 1000); + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + signatureOverride: `t=${ts},v1=deadbeefdeadbeef`, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + }); + + it('rejects when signature header is malformed (no v1)', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + signatureOverride: `t=${Math.floor(Date.now() / 1000)}`, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + }); + + it('rejects when timestamp is outside the 5-minute replay window', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const oldTs = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + secret: SECRET, + timestamp: oldTs, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + }); + + it('accepts request with valid signature and current timestamp', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const req = await buildSignedRequest({ body: validInvoiceOverdueEvent(), secret: SECRET }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(202); + const body = (await res.json()) as Record; + expect(body.received).toBe(true); + expect(body.eventId).toBe('evt-1'); + expect(body.eventType).toBe('invoice.overdue'); + }); + + it('skips signature verification entirely when no secret is stored (allows initial setup ping)', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(validInvoiceOverdueEvent()), + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(202); + }); + }); + + describe('payload handling', () => { + it('acks empty body as a setup ping (200, not error)', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '', + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true }); + }); + + it('acks unrecognized payload shape as setup ping (no error)', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ping: 'hello' }), + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true }); + }); + + it('rejects when business_id in payload does not match URL', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const event = validInvoiceOverdueEvent({ business_id: 'biz-other' }); + const req = await buildSignedRequest({ body: event, secret: SECRET }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'business_id_mismatch', + expected: BUSINESS, + got: 'biz-other', + }); + }); + + it('handles invoice.viewed event', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + event_id: 'evt-viewed-1', + event_type: 'invoice.viewed', + business_id: BUSINESS, + data: { invoice_id: 'inv-1', view_timestamp: '2026-04-30T06:18:01.212000+00:00' }, + }), + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(202); + const body = (await res.json()) as Record; + expect(body.eventType).toBe('invoice.viewed'); + }); + }); + + describe('idempotency / dedup', () => { + it('marks repeat event_id as duplicate', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const event = validInvoiceOverdueEvent({ event_id: 'evt-dup' }); + const req1 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }); + const res1 = await webhookRoutes.fetch(req1, env); + expect(res1.status).toBe(202); + + const req2 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }); + const res2 = await webhookRoutes.fetch(req2, env); + expect(res2.status).toBe(202); + expect(await res2.json()).toEqual({ + received: true, + duplicate: true, + eventId: 'evt-dup', + }); + }); + + it('does NOT dedup distinct event_ids', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req1 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(validInvoiceOverdueEvent({ event_id: 'evt-a' })), + }); + const req2 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(validInvoiceOverdueEvent({ event_id: 'evt-b' })), + }); + const res1 = await webhookRoutes.fetch(req1, env); + const res2 = await webhookRoutes.fetch(req2, env); + expect(res1.status).toBe(202); + expect(res2.status).toBe(202); + const b2 = (await res2.json()) as Record; + expect(b2.duplicate).toBeUndefined(); + }); + }); +}); diff --git a/server/__tests__/webhooks-wave-secret.test.ts b/server/__tests__/webhooks-wave-secret.test.ts new file mode 100644 index 0000000..2795ae9 --- /dev/null +++ b/server/__tests__/webhooks-wave-secret.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for the Wave per-(tenant, business) webhook secret-storage admin + * endpoints (PUT/GET/DELETE /api/webhooks/wave/:tenantId/:businessId/secret). + * + * These routes only touch KV — no DB, no service modules, no DB-shape risk. + * Tests use a Map-backed KV stand-in, matching the existing Mercury pattern. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { webhookRoutes } from '../routes/webhooks'; + +const SERVICE_TOKEN = 'test-service-token'; +const TENANT = '11111111-1111-1111-1111-111111111111'; +const BUSINESS = 'biz-deadbeef'; +const KEY = `webhook:wave:secret:${TENANT}:${BUSINESS}`; + +function makeKv() { + const store = new Map(); + return { + store, + binding: { + get: async (k: string) => store.get(k) ?? null, + put: async (k: string, v: string) => { + store.set(k, v); + }, + delete: async (k: string) => { + store.delete(k); + }, + } as unknown as KVNamespace, + }; +} + +function makeEnv(kv: KVNamespace, opts: { token?: string } = {}) { + return { + CHITTY_AUTH_SERVICE_TOKEN: opts.token ?? SERVICE_TOKEN, + FINANCE_KV: kv, + } as Parameters[1]; +} + +function authHeader(token = SERVICE_TOKEN) { + return { authorization: `Bearer ${token}` }; +} + +async function callPut(env: ReturnType, body: unknown, headers: Record) { + return webhookRoutes.fetch( + new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}/secret`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }), + env, + ); +} + +async function callExists(env: ReturnType, headers: Record) { + return webhookRoutes.fetch( + new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}/secret/exists`, { + method: 'GET', + headers, + }), + env, + ); +} + +async function callDelete(env: ReturnType, headers: Record) { + return webhookRoutes.fetch( + new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}/secret`, { + method: 'DELETE', + headers, + }), + env, + ); +} + +describe('Wave webhook secret storage', () => { + let kv: ReturnType; + + beforeEach(() => { + kv = makeKv(); + }); + + describe('PUT secret', () => { + it('rejects when service token is not configured', async () => { + const env = makeEnv(kv.binding, { token: '' }); + const res = await callPut(env, { secret: 'abc' }, authHeader()); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'auth_not_configured' }); + }); + + it('rejects requests with no Authorization header', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, { secret: 'abc' }, {}); + expect(res.status).toBe(401); + }); + + it('rejects requests with wrong token', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, { secret: 'abc' }, authHeader('wrong-token')); + expect(res.status).toBe(401); + expect(kv.store.has(KEY)).toBe(false); + }); + + it('rejects malformed JSON body', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, '{not-json', authHeader()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_json' }); + }); + + it('rejects missing secret field', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, {}, authHeader()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'secret required' }); + }); + + it('rejects non-string secret', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, { secret: 12345 }, authHeader()); + expect(res.status).toBe(400); + }); + + it('rejects empty-string secret', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, { secret: '' }, authHeader()); + expect(res.status).toBe(400); + }); + + it('stores valid secret at the canonical KV key', async () => { + const env = makeEnv(kv.binding); + const res = await callPut(env, { secret: 'wave-hash-xyz' }, authHeader()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ stored: true, tenantId: TENANT, businessId: BUSINESS }); + expect(kv.store.get(KEY)).toBe('wave-hash-xyz'); + }); + + it('overwrites existing secret on subsequent PUT', async () => { + const env = makeEnv(kv.binding); + await callPut(env, { secret: 'first' }, authHeader()); + await callPut(env, { secret: 'second' }, authHeader()); + expect(kv.store.get(KEY)).toBe('second'); + }); + }); + + describe('GET secret/exists', () => { + it('rejects unauthorized', async () => { + const env = makeEnv(kv.binding); + const res = await callExists(env, {}); + expect(res.status).toBe(401); + }); + + it('returns exists=false when no secret stored', async () => { + const env = makeEnv(kv.binding); + const res = await callExists(env, authHeader()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ exists: false, tenantId: TENANT, businessId: BUSINESS }); + }); + + it('returns exists=true after PUT, never returning the secret value', async () => { + const env = makeEnv(kv.binding); + await callPut(env, { secret: 'super-secret' }, authHeader()); + const res = await callExists(env, authHeader()); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.exists).toBe(true); + expect(JSON.stringify(body)).not.toContain('super-secret'); + }); + }); + + describe('DELETE secret', () => { + it('rejects unauthorized', async () => { + const env = makeEnv(kv.binding); + const res = await callDelete(env, {}); + expect(res.status).toBe(401); + }); + + it('removes a stored secret', async () => { + const env = makeEnv(kv.binding); + await callPut(env, { secret: 'abc' }, authHeader()); + expect(kv.store.has(KEY)).toBe(true); + const res = await callDelete(env, authHeader()); + expect(res.status).toBe(200); + expect(kv.store.has(KEY)).toBe(false); + }); + + it('is idempotent for non-existent secret', async () => { + const env = makeEnv(kv.binding); + const res = await callDelete(env, authHeader()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ deleted: true, tenantId: TENANT, businessId: BUSINESS }); + }); + }); + + it('keeps secrets isolated across tenant/business pairs', async () => { + const env = makeEnv(kv.binding); + const otherTenant = '22222222-2222-2222-2222-222222222222'; + const otherBusiness = 'biz-other'; + + await callPut(env, { secret: 'a-secret' }, authHeader()); + + const otherRes = await webhookRoutes.fetch( + new Request(`http://x/api/webhooks/wave/${otherTenant}/${otherBusiness}/secret/exists`, { + method: 'GET', + headers: authHeader(), + }), + env, + ); + expect(otherRes.status).toBe(200); + expect(await otherRes.json()).toEqual({ + exists: false, + tenantId: otherTenant, + businessId: otherBusiness, + }); + }); +}); diff --git a/server/routes/webhooks.ts b/server/routes/webhooks.ts index 183a1be..a9c7697 100644 --- a/server/routes/webhooks.ts +++ b/server/routes/webhooks.ts @@ -560,3 +560,246 @@ webhookRoutes.post('/api/webhooks/wave', async (c) => { schemaAdvisory: schemaResult.advisory, }, 201); }); + +// ─── Wave per-(tenant, business) webhook secret storage ─── +// +// Wave webhooks are scoped to a (tenant, business) pair because a single +// tenant can connect multiple Wave businesses (different LLCs sharing one +// chittyfinance tenant). Secrets are issued by Wave when a webhook +// subscription is created and must be supplied to the verification step +// of the receiver (added in a follow-up PR once Wave's signature schema +// is verified). +// +// KV layout: +// webhook:wave:secret:: → HMAC secret (string) +// webhook:wave:dedup: → 7d TTL idempotency marker (set by receiver, not here) + +function waveSecretKey(tenantId: string, businessId: string): string { + return `webhook:wave:secret:${tenantId}:${businessId}`; +} + +function isAuthorizedWaveSecretCaller(env: { CHITTY_AUTH_SERVICE_TOKEN?: string }, authHeader: string): boolean { + const expected = env.CHITTY_AUTH_SERVICE_TOKEN; + if (!expected) return false; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + return token.length > 0 && token === expected; +} + +/** + * Verify Wave's webhook signature. + * + * Per Wave's Webhooks Setup Guide: + * - Header: x-wave-signature: t=,v1= + * - Signed payload: . (raw body, do not re-serialize) + * - Replay window: reject if |now - timestamp| > 300 seconds (5 min) + * + * Algorithm matches Mercury's signature scheme; kept as a separate function + * so the two integrations can diverge independently as Wave's docs evolve. + */ +async function verifyWaveSignature( + rawBody: string, + signatureHeader: string, + secret: string, + nowMs: number = Date.now(), +): Promise { + const parts = signatureHeader.split(','); + let timestamp: string | undefined; + let signature: string | undefined; + + for (const part of parts) { + const [key, ...rest] = part.split('='); + const value = rest.join('='); + if (key === 't') timestamp = value; + if (key === 'v1') signature = value; + } + + if (!timestamp || !signature) return false; + + // 5-minute replay window + const tsSeconds = Number(timestamp); + if (!Number.isFinite(tsSeconds)) return false; + if (Math.abs(Math.floor(nowMs / 1000) - tsSeconds) > 300) return false; + + const signedPayload = `${timestamp}.${rawBody}`; + const encoder = new TextEncoder(); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + + const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(signedPayload)); + const expected = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join(''); + + if (expected.length !== signature.length) return false; + let result = 0; + for (let i = 0; i < expected.length; i++) { + result |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return result === 0; +} + +/** Wave native webhook event envelope. Matches the format documented in Wave's Webhooks Setup Guide. */ +const waveNativeEventSchema = z.object({ + event_id: z.string(), + event_type: z.string(), // e.g. 'invoice.overdue', 'invoice.viewed', 'invoice.approved' + business_id: z.string(), + data: z.record(z.unknown()), +}); + +// PUT /api/webhooks/wave/:tenantId/:businessId/secret — store per-(tenant, business) webhook secret +// Auth: service token (internal use only) +webhookRoutes.put('/api/webhooks/wave/:tenantId/:businessId/secret', async (c) => { + if (!c.env.CHITTY_AUTH_SERVICE_TOKEN) return c.json({ error: 'auth_not_configured' }, 500); + if (!isAuthorizedWaveSecretCaller(c.env, c.req.header('authorization') ?? '')) { + return c.json({ error: 'unauthorized' }, 401); + } + + let parsed: { secret?: unknown }; + try { + parsed = await c.req.json<{ secret: unknown }>(); + } catch { + return c.json({ error: 'invalid_json' }, 400); + } + const { secret } = parsed; + if (typeof secret !== 'string' || secret.length === 0) { + return c.json({ error: 'secret required' }, 400); + } + + const tenantId = c.req.param('tenantId'); + const businessId = c.req.param('businessId'); + await c.env.FINANCE_KV.put(waveSecretKey(tenantId, businessId), secret); + + return c.json({ stored: true, tenantId, businessId }); +}); + +// GET /api/webhooks/wave/:tenantId/:businessId/secret/exists — existence check (never returns the secret) +// Auth: service token (internal use only) +webhookRoutes.get('/api/webhooks/wave/:tenantId/:businessId/secret/exists', async (c) => { + if (!c.env.CHITTY_AUTH_SERVICE_TOKEN) return c.json({ error: 'auth_not_configured' }, 500); + if (!isAuthorizedWaveSecretCaller(c.env, c.req.header('authorization') ?? '')) { + return c.json({ error: 'unauthorized' }, 401); + } + + const tenantId = c.req.param('tenantId'); + const businessId = c.req.param('businessId'); + const value = await c.env.FINANCE_KV.get(waveSecretKey(tenantId, businessId)); + + return c.json({ exists: value !== null, tenantId, businessId }); +}); + +// DELETE /api/webhooks/wave/:tenantId/:businessId/secret — remove stored secret +// Auth: service token (internal use only) +webhookRoutes.delete('/api/webhooks/wave/:tenantId/:businessId/secret', async (c) => { + if (!c.env.CHITTY_AUTH_SERVICE_TOKEN) return c.json({ error: 'auth_not_configured' }, 500); + if (!isAuthorizedWaveSecretCaller(c.env, c.req.header('authorization') ?? '')) { + return c.json({ error: 'unauthorized' }, 401); + } + + const tenantId = c.req.param('tenantId'); + const businessId = c.req.param('businessId'); + await c.env.FINANCE_KV.delete(waveSecretKey(tenantId, businessId)); + + return c.json({ deleted: true, tenantId, businessId }); +}); + +// POST /api/webhooks/wave/:tenantId/:businessId — Wave native webhook receiver +// +// Auth: x-wave-signature HMAC-SHA256 verified against per-(tenant, business) +// secret stored in KV. Skipped if no secret stored (allows initial +// dashboard configuration ping). +// +// Configuration: Wave webhook subscriptions are configured per-business in +// the Wave dashboard (no programmatic API). Operators paste this URL into +// the Wave Webhooks page for each connected business: +// +// https://finance.chitty.cc/api/webhooks/wave// +// +// Then store the secret Wave reveals via: +// PUT /api/webhooks/wave///secret { secret: "..." } +// +// Currently supported event types (per Wave's Webhooks Setup Guide): +// invoice.overdue, invoice.viewed, invoice.approved +// "More supported events will be available soon" — handler audit-logs all +// recognized events; specific business logic is added as Wave expands the +// event surface. +webhookRoutes.post('/api/webhooks/wave/:tenantId/:businessId', async (c) => { + const tenantId = c.req.param('tenantId'); + const businessId = c.req.param('businessId'); + + // Raw body must be read once and used as-is for HMAC verification (per Wave docs). + const rawBody = await c.req.text(); + const kv = c.env.FINANCE_KV; + const secret = await kv.get(waveSecretKey(tenantId, businessId)); + const signatureHeader = c.req.header('x-wave-signature') ?? ''; + + if (secret) { + if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) { + return c.json({ error: 'invalid_signature' }, 401); + } + } else { + console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped'); + } + + // Parse JSON; empty/non-JSON body → ack as setup ping + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return c.json({ received: true }, 200); + } + + const parsed = waveNativeEventSchema.safeParse(body); + if (!parsed.success) { + console.warn('[webhook:wave] Unrecognized payload, acking', { + tenantId, + businessId, + keys: typeof body === 'object' && body ? Object.keys(body as object) : typeof body, + }); + return c.json({ received: true }, 200); + } + + const event = parsed.data; + + // URL-vs-payload business binding check (defense in depth) + if (event.business_id !== businessId) { + return c.json({ error: 'business_id_mismatch', expected: businessId, got: event.business_id }, 400); + } + + // KV idempotency — 7-day dedup window keyed on event_id + const dedupKey = `webhook:wave:dedup:${event.event_id}`; + if (await kv.get(dedupKey)) { + return c.json({ received: true, duplicate: true, eventId: event.event_id }, 202); + } + await kv.put(dedupKey, '1', { expirationTtl: 604800 }); + + ledgerLog( + c, + { + entityType: 'audit', + action: `webhook.wave.${event.event_type.replace(/[^a-z0-9_]/gi, '_')}`, + metadata: { + tenantId, + businessId, + eventId: event.event_id, + eventType: event.event_type, + data: event.data, + }, + }, + c.env, + ); + + return c.json( + { + received: true, + eventId: event.event_id, + eventType: event.event_type, + tenantId, + businessId, + }, + 202, + ); +}); From 0162d532cef826a9458ec59f7bcf3c5f7746608c Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 7 May 2026 19:56:15 +0000 Subject: [PATCH 2/3] fix(webhooks): close Wave receiver forgery vector + scope dedup per business Addresses review feedback on PR #113: - **Forgery guard**: when no per-(tenant,business) secret is stored in KV, schema-valid Wave events are now rejected with 401 instead of being ack'd and ledger-logged. The setup-ping bypass is preserved only for non-event probe shapes (empty body, non-JSON, unrecognized JSON). - **Dedup key scoping**: `webhook:wave:dedup:` -> `webhook:wave:dedup:::`. Wave's event_id is unique per business, not globally; without scoping, the same id across two businesses would silently drop the second event. - **Replay window asymmetry**: tightened to -300s/+60s (was symmetric +-300s). Future-dated timestamps don't occur from Wave's signers and indicate clock drift or tampering. - **Audit-log schema drift**: signed-but-unparseable bodies and signed-but-unrecognized envelopes now ledger-log `webhook.wave.parse_error` / `webhook.wave.unrecognized` so drift surfaces in the audit trail instead of stderr. - **Tests**: drop `vi.mock('../lib/ledger-client', ...)` per project rule (no service-module mocks). Stub global fetch instead so the real ledger-client code path is exercised without outbound HTTP. Added cases for same-length wrong sig (constant-time path), future timestamp, non-numeric timestamp, non-JSON probe body, cross-business dedup isolation, and updated existing tests to use signed requests now that unsigned events are rejected. Tests: 286/286 passing (+5 new); typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/webhooks-wave-receiver.test.ts | 146 ++++++++++++++---- server/routes/webhooks.ts | 81 +++++++--- 2 files changed, 177 insertions(+), 50 deletions(-) diff --git a/server/__tests__/webhooks-wave-receiver.test.ts b/server/__tests__/webhooks-wave-receiver.test.ts index c972194..ff68d17 100644 --- a/server/__tests__/webhooks-wave-receiver.test.ts +++ b/server/__tests__/webhooks-wave-receiver.test.ts @@ -11,14 +11,22 @@ * No DB or service modules — only KV (Map-backed) and ledger-client (real, but * configured to skip network in test env via CHITTY_LEDGER_BASE). */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { webhookRoutes } from '../routes/webhooks'; -// Stub ledger client — its actual fetch is skipped in test env via env vars, -// but we don't want any real I/O attempted at all. -vi.mock('../lib/ledger-client', () => ({ - ledgerLog: vi.fn(), -})); +// Real ledger-client code runs; only the network boundary (fetch) is intercepted. +// Per project rule: no mocking of service modules — but stubbing the global +// fetch boundary keeps the real ledger-client logic exercised while preventing +// outbound calls to ledger.chitty.cc during unit tests. +const originalFetch = globalThis.fetch; +beforeEach(() => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ id: 't', sequenceNumber: '0', hash: '' }), { status: 200 }), + ); +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); const SERVICE_TOKEN = 'test-service-token'; const SECRET = 'wave-test-secret-32bytes'; @@ -179,7 +187,7 @@ describe('Wave webhook receiver', () => { expect(body.eventType).toBe('invoice.overdue'); }); - it('skips signature verification entirely when no secret is stored (allows initial setup ping)', async () => { + it('rejects schema-valid events when no secret is stored (forgery guard)', async () => { const kv = makeKv(); const env = makeEnv(kv.binding); const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { @@ -188,7 +196,49 @@ describe('Wave webhook receiver', () => { body: JSON.stringify(validInvoiceOverdueEvent()), }); const res = await webhookRoutes.fetch(req, env); - expect(res.status).toBe(202); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'webhook_not_configured' }); + }); + + it('rejects same-length wrong signature (constant-time path)', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const ts = Math.floor(Date.now() / 1000); + const realSig = await hmacHex(SECRET, `${ts}.${JSON.stringify(validInvoiceOverdueEvent())}`); + const wrongSameLen = realSig.replace(/^./, (ch) => (ch === 'a' ? 'b' : 'a')); + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + signatureOverride: `t=${ts},v1=${wrongSameLen}`, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + }); + + it('rejects future-dated timestamp beyond skew tolerance', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const futureTs = Math.floor(Date.now() / 1000) + 600; + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + secret: SECRET, + timestamp: futureTs, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); + }); + + it('rejects when timestamp is not a number', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const req = await buildSignedRequest({ + body: validInvoiceOverdueEvent(), + signatureOverride: `t=notanumber,v1=deadbeef`, + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(401); }); }); @@ -219,6 +269,18 @@ describe('Wave webhook receiver', () => { expect(await res.json()).toEqual({ received: true }); }); + it('acks non-JSON body (Wave dashboard probe) as setup ping', async () => { + const kv = makeKv(); + const env = makeEnv(kv.binding); + const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'not-json', + }); + const res = await webhookRoutes.fetch(req, env); + expect(res.status).toBe(200); + }); + it('rejects when business_id in payload does not match URL', async () => { const kv = makeKv(); await storeSecret(kv); @@ -234,18 +296,18 @@ describe('Wave webhook receiver', () => { }); }); - it('handles invoice.viewed event', async () => { + it('handles signed invoice.viewed event', async () => { const kv = makeKv(); + await storeSecret(kv); const env = makeEnv(kv.binding); - const req = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ + const req = await buildSignedRequest({ + body: { event_id: 'evt-viewed-1', event_type: 'invoice.viewed', business_id: BUSINESS, data: { invoice_id: 'inv-1', view_timestamp: '2026-04-30T06:18:01.212000+00:00' }, - }), + }, + secret: SECRET, }); const res = await webhookRoutes.fetch(req, env); expect(res.status).toBe(202); @@ -257,21 +319,14 @@ describe('Wave webhook receiver', () => { describe('idempotency / dedup', () => { it('marks repeat event_id as duplicate', async () => { const kv = makeKv(); + await storeSecret(kv); const env = makeEnv(kv.binding); const event = validInvoiceOverdueEvent({ event_id: 'evt-dup' }); - const req1 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(event), - }); + const req1 = await buildSignedRequest({ body: event, secret: SECRET }); const res1 = await webhookRoutes.fetch(req1, env); expect(res1.status).toBe(202); - const req2 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(event), - }); + const req2 = await buildSignedRequest({ body: event, secret: SECRET }); const res2 = await webhookRoutes.fetch(req2, env); expect(res2.status).toBe(202); expect(await res2.json()).toEqual({ @@ -283,16 +338,15 @@ describe('Wave webhook receiver', () => { it('does NOT dedup distinct event_ids', async () => { const kv = makeKv(); + await storeSecret(kv); const env = makeEnv(kv.binding); - const req1 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(validInvoiceOverdueEvent({ event_id: 'evt-a' })), + const req1 = await buildSignedRequest({ + body: validInvoiceOverdueEvent({ event_id: 'evt-a' }), + secret: SECRET, }); - const req2 = new Request(`http://x/api/webhooks/wave/${TENANT}/${BUSINESS}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(validInvoiceOverdueEvent({ event_id: 'evt-b' })), + const req2 = await buildSignedRequest({ + body: validInvoiceOverdueEvent({ event_id: 'evt-b' }), + secret: SECRET, }); const res1 = await webhookRoutes.fetch(req1, env); const res2 = await webhookRoutes.fetch(req2, env); @@ -301,5 +355,33 @@ describe('Wave webhook receiver', () => { const b2 = (await res2.json()) as Record; expect(b2.duplicate).toBeUndefined(); }); + + it('isolates dedup keys across (tenant, business) pairs', async () => { + const kv = makeKv(); + const otherBusiness = 'biz-other'; + const otherSecret = 'other-secret'; + kv.store.set(KEY, SECRET); + kv.store.set(`webhook:wave:secret:${TENANT}:${otherBusiness}`, otherSecret); + const env = makeEnv(kv.binding); + + const sameId = 'evt-shared'; + const req1 = await buildSignedRequest({ + body: validInvoiceOverdueEvent({ event_id: sameId }), + secret: SECRET, + }); + const res1 = await webhookRoutes.fetch(req1, env); + expect(res1.status).toBe(202); + + // Same event_id under a different business must NOT be deduplicated. + const req2 = await buildSignedRequest({ + body: validInvoiceOverdueEvent({ event_id: sameId, business_id: otherBusiness }), + secret: otherSecret, + businessId: otherBusiness, + }); + const res2 = await webhookRoutes.fetch(req2, env); + expect(res2.status).toBe(202); + const b2 = (await res2.json()) as Record; + expect(b2.duplicate).toBeUndefined(); + }); }); }); diff --git a/server/routes/webhooks.ts b/server/routes/webhooks.ts index a9c7697..a78f4c7 100644 --- a/server/routes/webhooks.ts +++ b/server/routes/webhooks.ts @@ -615,10 +615,14 @@ async function verifyWaveSignature( if (!timestamp || !signature) return false; - // 5-minute replay window + // Asymmetric replay window: tolerate 5 min of past skew, only 60s of future skew. + // Future-dated timestamps shouldn't occur from Wave's signers and indicate + // either clock drift or tampering — keep that window tight. const tsSeconds = Number(timestamp); if (!Number.isFinite(tsSeconds)) return false; - if (Math.abs(Math.floor(nowMs / 1000) - tsSeconds) > 300) return false; + const nowSec = Math.floor(nowMs / 1000); + if (nowSec - tsSeconds > 300) return false; + if (tsSeconds - nowSec > 60) return false; const signedPayload = `${timestamp}.${rawBody}`; const encoder = new TextEncoder(); @@ -736,41 +740,82 @@ webhookRoutes.post('/api/webhooks/wave/:tenantId/:businessId', async (c) => { const secret = await kv.get(waveSecretKey(tenantId, businessId)); const signatureHeader = c.req.header('x-wave-signature') ?? ''; - if (secret) { - if (!signatureHeader || !(await verifyWaveSignature(rawBody, signatureHeader, secret))) { - return c.json({ error: 'invalid_signature' }, 401); - } - } else { - console.warn('[webhook:wave] No secret stored for', { tenantId, businessId }, '— signature verification skipped'); + const sigVerified = secret + ? signatureHeader.length > 0 && (await verifyWaveSignature(rawBody, signatureHeader, secret)) + : false; + + if (secret && !sigVerified) { + return c.json({ error: 'invalid_signature' }, 401); } - // Parse JSON; empty/non-JSON body → ack as setup ping + // Parse JSON; empty/non-JSON body is treated as a setup ping (Wave dashboard + // sends a probe when the operator first saves the URL). let body: unknown; + let parseFailed = false; try { - body = JSON.parse(rawBody); + body = rawBody.length === 0 ? null : JSON.parse(rawBody); } catch { + parseFailed = true; + } + + if (parseFailed) { + if (sigVerified) { + // Signed by Wave but unparseable — surface to the audit trail rather + // than silently 200ing. + ledgerLog( + c, + { + entityType: 'audit', + action: 'webhook.wave.parse_error', + metadata: { tenantId, businessId, bodyBytes: rawBody.length }, + }, + c.env, + ); + } return c.json({ received: true }, 200); } - const parsed = waveNativeEventSchema.safeParse(body); + const parsed = body == null ? { success: false as const } : waveNativeEventSchema.safeParse(body); if (!parsed.success) { - console.warn('[webhook:wave] Unrecognized payload, acking', { - tenantId, - businessId, - keys: typeof body === 'object' && body ? Object.keys(body as object) : typeof body, - }); + if (sigVerified) { + // Signed Wave payload that doesn't match the documented envelope — + // schema drift. Audit-log so it shows up in the ledger instead of + // disappearing into stderr. + ledgerLog( + c, + { + entityType: 'audit', + action: 'webhook.wave.unrecognized', + metadata: { + tenantId, + businessId, + shape: typeof body === 'object' && body ? Object.keys(body as object).slice(0, 10) : typeof body, + }, + }, + c.env, + ); + } return c.json({ received: true }, 200); } const event = parsed.data; + // No secret stored, but the body looks like a real Wave event. Reject — + // someone learning the URL must not be able to inject ledger entries. + // The setup-ping bypass is reserved for non-event probe shapes. + if (!secret) { + return c.json({ error: 'webhook_not_configured' }, 401); + } + // URL-vs-payload business binding check (defense in depth) if (event.business_id !== businessId) { return c.json({ error: 'business_id_mismatch', expected: businessId, got: event.business_id }, 400); } - // KV idempotency — 7-day dedup window keyed on event_id - const dedupKey = `webhook:wave:dedup:${event.event_id}`; + // KV idempotency — 7-day dedup window keyed on (tenant, business, event_id). + // Scoping prevents cross-business event_id collisions (Wave's event_ids are + // unique per business, not globally). + const dedupKey = `webhook:wave:dedup:${tenantId}:${businessId}:${event.event_id}`; if (await kv.get(dedupKey)) { return c.json({ received: true, duplicate: true, eventId: event.event_id }, 202); } From ca9fbe29600efe27c4d02efd4ab0fc2c0c13eea0 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:44:16 +0000 Subject: [PATCH 3/3] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- server/routes/webhooks.ts | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/server/routes/webhooks.ts b/server/routes/webhooks.ts index a78f4c7..a943663 100644 --- a/server/routes/webhooks.ts +++ b/server/routes/webhooks.ts @@ -710,6 +710,43 @@ webhookRoutes.delete('/api/webhooks/wave/:tenantId/:businessId/secret', async (c return c.json({ deleted: true, tenantId, businessId }); }); +/** + * Sanitize webhook data before logging to ledger. + * Extracts safe identifiers and masks sensitive information. + */ +function sanitizeWebhookData(data: Record): Record { + const sanitized: Record = {}; + + // Whitelist of safe fields to include + const safeFields = ['invoiceId', 'customerId', 'amount', 'status', 'id', 'type']; + + for (const field of safeFields) { + if (field in data) { + sanitized[field] = data[field]; + } + } + + // Mask account numbers (any field containing 'account' and looking like a number) + for (const [key, value] of Object.entries(data)) { + if (key.toLowerCase().includes('account') && typeof value === 'string') { + if (/^\d+$/.test(value) && value.length >= 4) { + sanitized[key] = `***${value.slice(-4)}`; + } + } + } + + // Generate a short summary of available keys (for debugging) + const allKeys = Object.keys(data); + if (allKeys.length > 0) { + sanitized._keys = allKeys.slice(0, 10); // First 10 keys only + if (allKeys.length > 10) { + sanitized._keysOmitted = allKeys.length - 10; + } + } + + return sanitized; +} + // POST /api/webhooks/wave/:tenantId/:businessId — Wave native webhook receiver // // Auth: x-wave-signature HMAC-SHA256 verified against per-(tenant, business) @@ -831,7 +868,7 @@ webhookRoutes.post('/api/webhooks/wave/:tenantId/:businessId', async (c) => { businessId, eventId: event.event_id, eventType: event.event_type, - data: event.data, + data: sanitizeWebhookData(event.data), }, }, c.env,