diff --git a/server/__tests__/webhooks-wave-receiver.test.ts b/server/__tests__/webhooks-wave-receiver.test.ts new file mode 100644 index 0000000..ff68d17 --- /dev/null +++ b/server/__tests__/webhooks-wave-receiver.test.ts @@ -0,0 +1,387 @@ +/** + * 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, afterEach, vi } from 'vitest'; +import { webhookRoutes } from '../routes/webhooks'; + +// 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'; +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('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}`, { + 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: '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); + }); + }); + + 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('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); + 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 signed invoice.viewed event', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + 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); + 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(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const event = validInvoiceOverdueEvent({ event_id: 'evt-dup' }); + const req1 = await buildSignedRequest({ body: event, secret: SECRET }); + const res1 = await webhookRoutes.fetch(req1, env); + expect(res1.status).toBe(202); + + 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({ + received: true, + duplicate: true, + eventId: 'evt-dup', + }); + }); + + it('does NOT dedup distinct event_ids', async () => { + const kv = makeKv(); + await storeSecret(kv); + const env = makeEnv(kv.binding); + const req1 = await buildSignedRequest({ + body: validInvoiceOverdueEvent({ event_id: 'evt-a' }), + secret: SECRET, + }); + 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); + expect(res1.status).toBe(202); + expect(res2.status).toBe(202); + 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/__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..a943663 100644 --- a/server/routes/webhooks.ts +++ b/server/routes/webhooks.ts @@ -560,3 +560,328 @@ 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; + + // 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; + 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(); + + 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 }); +}); + +/** + * 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) +// 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') ?? ''; + + 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 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 = 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 = body == null ? { success: false as const } : waveNativeEventSchema.safeParse(body); + if (!parsed.success) { + 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 (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); + } + 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: sanitizeWebhookData(event.data), + }, + }, + c.env, + ); + + return c.json( + { + received: true, + eventId: event.event_id, + eventType: event.event_type, + tenantId, + businessId, + }, + 202, + ); +});