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] 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, + ); +});