diff --git a/CHANGELOG.md b/CHANGELOG.md index 180de01..bdc0550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes and polish. +## [0.4.0] — 2026-07-01 + +Completes the public API (#245): **outbound event webhooks** so +automations can *react* to activity instead of polling. + +### Added + +- **Outbound event webhooks (`/api/v1/webhooks`).** Register an HTTPS + endpoint (scope `webhooks:manage`) to be POSTed to when an event + happens in your account — `message.received`, `message.status_updated`, + or `conversation.created`. Manage endpoints with + `GET/POST /api/v1/webhooks` and `GET/PATCH/DELETE /api/v1/webhooks/{id}`. + Each delivery is signed with an `X-Wacrm-Signature` + (HMAC-SHA256 over `timestamp.body`) so receivers can verify + authenticity and reject replays; the signing secret is returned once + at creation and stored encrypted. Delivery is best-effort — an + endpoint that fails repeatedly is auto-disabled after a threshold of + consecutive failures. See `docs/public-api.md`. + **Migration required:** apply + `supabase/migrations/028_webhook_endpoints.sql`. + ([#245](https://github.com/ArnasDon/wacrm/issues/245)) + ## [0.3.0] — 2026-07-01 Multi-user accounts ship. Every wacrm install is multi-tenant on the diff --git a/docs/public-api.md b/docs/public-api.md index 0201fc3..1d2b890 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -4,10 +4,9 @@ The public API lets you drive your wacrm instance from your own scripts and automations — send messages, manage contacts, launch broadcasts — without going through the dashboard UI. -> **Status:** stable. Authentication, scopes, rate limiting, and the -> messages / contacts / conversations / broadcasts endpoints ship now. -> The one remaining roadmap item is outbound event webhooks — see -> [Roadmap](#roadmap). +> **Status:** stable. Authentication, scopes, rate limiting, the +> messages / contacts / conversations / broadcasts endpoints, and +> outbound event [webhooks](#webhooks) all ship now. ## Authentication @@ -50,6 +49,7 @@ it. Grant the minimum. | `contacts:write` | Create and update contacts | | `conversations:read` | List and read conversations | | `broadcasts:send` | Launch broadcast campaigns | +| `webhooks:manage` | Register and manage outbound webhooks | A key with **no scopes** still authenticates and can call `GET /api/v1/me` — useful for verifying a key works. @@ -281,15 +281,103 @@ Cursors are keyset-based (stable under concurrent inserts). Pass the cursor back verbatim — don't parse it. `next_cursor: null` means the last page. +## Webhooks + +Rather than polling, register an endpoint and wacrm will POST to it when +things happen in your account. **Migration required:** apply +`supabase/migrations/028_webhook_endpoints.sql`. + +### Events + +| Event | Fires when | +| ------------------------ | ------------------------------------------------- | +| `message.received` | An inbound message arrives from a contact | +| `message.status_updated` | A message you sent changed delivery status | +| `conversation.created` | A new conversation is opened for a contact | + +### Managing endpoints + +All under scope `webhooks:manage`. + +- `POST /api/v1/webhooks` — register `{ "url": "https://…", "events": ["message.received"] }`. `url` must be `https://`. **The response includes `secret` exactly once** — store it to verify signatures; wacrm keeps only an encrypted copy. +- `GET /api/v1/webhooks` — list your endpoints (never returns the secret). +- `GET /api/v1/webhooks/{id}` — read one. +- `PATCH /api/v1/webhooks/{id}` — update `url`, `events`, or `is_active` (re-enabling clears the failure counter). +- `DELETE /api/v1/webhooks/{id}` — remove one. + +```bash +curl -X POST https://your-crm.example.com/api/v1/webhooks \ + -H "Authorization: Bearer wacrm_live_xxx" \ + -H "Content-Type: application/json" \ + -d '{ "url": "https://example.com/hooks/wacrm", "events": ["message.received"] }' +# → 201 { "data": { "id": "…", "url": "…", "events": [...], "secret": "whsec_…" } } +``` + +### Delivery payload + +Every delivery is a POST with this envelope; `id` is a unique per- +delivery uuid you can dedupe on, and `data` varies by `event`: + +```json +{ + "id": "8f3c…", + "event": "message.received", + "occurred_at": "2026-07-01T12:00:00.000Z", + "account_id": "…", + "data": { /* per-event, see below */ } +} +``` + +`data` by event: + +```jsonc +// message.received +{ "conversation_id": "…", "contact_id": "…", "whatsapp_message_id": "wamid.…", "content_type": "text", "text": "Hi 👋" } +// conversation.created +{ "conversation_id": "…", "contact_id": "…" } +// message.status_updated +{ "whatsapp_message_id": "wamid.…", "conversation_id": "…", "status": "delivered" } +``` + +Headers: `X-Wacrm-Event`, `X-Wacrm-Webhook-Id`, and `X-Wacrm-Signature`. + +### Verifying the signature + +`X-Wacrm-Signature: t=,v1=` where `v1 = +HMAC-SHA256(secret, "${t}.${rawBody}")`. Recompute it over the **raw +request body** and compare in constant time; reject if `t` is more than +a few minutes old (replay protection). + +```js +const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/); +const expected = crypto.createHmac('sha256', secret) + .update(`${t}.${rawBody}`).digest('hex'); +const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); +``` + +### Delivery semantics + +Delivery is **best-effort**: a single attempt per event with a short +timeout, and **redirects are not followed**. `message.status_updated` +covers messages wacrm stores (inbox + API sends), not broadcast-only +sends, and — because providers re-send and re-order status callbacks — +the same status may arrive more than once or out of order; **dedupe on +`id` and don't assume ordering**. Each consecutive failure increments +`failure_count`; after enough consecutive failures the endpoint is +auto-disabled (`is_active: false`) — re-enable it with `PATCH` (which +resets the counter). Durable retry-with-backoff (a delivery queue) is a +future enhancement; today, treat missed deliveries as possible and +reconcile with the read endpoints when it matters. + +**Target restrictions (SSRF).** The `url` must be `https://` and must +resolve to a public address — requests to `localhost`, private/RFC1918 +ranges, link-local (incl. cloud metadata `169.254.169.254`), and similar +internal targets are refused at delivery time. + ## Roadmap -The one remaining item tracked in -[#245](https://github.com/ArnasDon/wacrm/issues/245): - -- **Outbound event webhooks** — register a URL to receive signed POSTs - when things happen in your account (`message.received`, - `message.status_updated`, `conversation.created`) so automations can - *react* to inbound activity instead of polling. Planned: - a `webhook_endpoints` table (next migration, `028_*`), a - `webhooks:manage` scope, `/api/v1/webhooks` management endpoints, and - HMAC-`X-Wacrm-Signature`-signed delivery with retry/backoff. +The public API now covers messaging, contacts, conversations, +broadcasts, and outbound webhooks — the full scope of +[#245](https://github.com/ArnasDon/wacrm/issues/245). Future ideas +(deals/pipelines, templates, flows, a delivery queue for webhooks) are +not yet scheduled. diff --git a/package.json b/package.json index a8ec223..4e4f539 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wacrm", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "Self-hostable CRM template for WhatsApp built on Next.js and Supabase — shared inbox, contacts, sales pipelines, broadcasts, and no-code automations.", "license": "MIT", diff --git a/src/app/api/v1/webhooks/[id]/route.ts b/src/app/api/v1/webhooks/[id]/route.ts new file mode 100644 index 0000000..30c4951 --- /dev/null +++ b/src/app/api/v1/webhooks/[id]/route.ts @@ -0,0 +1,146 @@ +// ============================================================ +// GET /api/v1/webhooks/{id} — read an endpoint (webhooks:manage) +// PATCH /api/v1/webhooks/{id} — update url/events/is_active +// DELETE /api/v1/webhooks/{id} — remove an endpoint +// +// All account-scoped: a foreign id → 404 (never 403). The signing +// secret is never returned here — it's shown once at creation only. +// ============================================================ + +import { requireApiKey } from '@/lib/auth/api-context'; +import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond'; +import { normalizeEvents } from '@/lib/webhooks/events'; +import { + WEBHOOK_PUBLIC_COLUMNS, + serializeWebhookEndpoint, + normalizeWebhookUrl, +} from '@/lib/webhooks/endpoints'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireApiKey(request, 'webhooks:manage'); + const { id } = await params; + + const { data, error } = await ctx.supabase + .from('webhook_endpoints') + .select(WEBHOOK_PUBLIC_COLUMNS) + .eq('id', id) + .eq('account_id', ctx.accountId) + .maybeSingle(); + + if (error) { + console.error('[api/v1/webhooks] read error:', error); + return fail('internal', 'Failed to read webhook', 500); + } + if (!data) return fail('not_found', 'Webhook not found', 404); + + return ok(serializeWebhookEndpoint(data as Record)); + } catch (err) { + return toApiErrorResponse(err); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireApiKey(request, 'webhooks:manage'); + const { id } = await params; + + const body = (await request.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!body || typeof body !== 'object') { + return fail('bad_request', 'Request body must be a JSON object', 400); + } + + const updates: Record = {}; + + if ('url' in body) { + const url = normalizeWebhookUrl(body.url); + if (!url) { + return fail('bad_request', "'url' must be a valid https:// URL", 400); + } + updates.url = url; + } + + if ('events' in body) { + const events = normalizeEvents(body.events); + if (!events) { + return fail( + 'bad_request', + "'events' must be a non-empty array of known event names", + 400 + ); + } + updates.events = events; + } + + if ('is_active' in body) { + if (typeof body.is_active !== 'boolean') { + return fail('bad_request', "'is_active' must be a boolean", 400); + } + updates.is_active = body.is_active; + // Re-enabling a disabled endpoint clears its failure streak so it + // isn't instantly re-disabled by a single stale failure. + if (body.is_active === true) updates.failure_count = 0; + } + + if (Object.keys(updates).length === 0) { + return fail('bad_request', 'No updatable fields provided', 400); + } + + // Scope the update by account_id so a foreign id touches nothing; + // the returned row (null when unmatched) drives the 404. + const { data, error } = await ctx.supabase + .from('webhook_endpoints') + .update(updates) + .eq('id', id) + .eq('account_id', ctx.accountId) + .select(WEBHOOK_PUBLIC_COLUMNS) + .maybeSingle(); + + if (error) { + console.error('[api/v1/webhooks] update error:', error); + return fail('internal', 'Failed to update webhook', 500); + } + if (!data) return fail('not_found', 'Webhook not found', 404); + + return ok(serializeWebhookEndpoint(data as Record)); + } catch (err) { + return toApiErrorResponse(err); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireApiKey(request, 'webhooks:manage'); + const { id } = await params; + + const { data, error } = await ctx.supabase + .from('webhook_endpoints') + .delete() + .eq('id', id) + .eq('account_id', ctx.accountId) + .select('id') + .maybeSingle(); + + if (error) { + console.error('[api/v1/webhooks] delete error:', error); + return fail('internal', 'Failed to delete webhook', 500); + } + if (!data) return fail('not_found', 'Webhook not found', 404); + + return ok({ id: data.id, deleted: true }); + } catch (err) { + return toApiErrorResponse(err); + } +} diff --git a/src/app/api/v1/webhooks/route.ts b/src/app/api/v1/webhooks/route.ts new file mode 100644 index 0000000..cd4e71b --- /dev/null +++ b/src/app/api/v1/webhooks/route.ts @@ -0,0 +1,102 @@ +// ============================================================ +// GET /api/v1/webhooks — list webhook endpoints (scope: webhooks:manage) +// POST /api/v1/webhooks — register an endpoint (scope: webhooks:manage) +// +// POST returns the signing `secret` in plaintext exactly once — store +// it to verify the `X-Wacrm-Signature` on deliveries. wacrm keeps only +// an encrypted copy and can never show it again. +// ============================================================ + +import { requireApiKey } from '@/lib/auth/api-context'; +import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond'; +import { encrypt } from '@/lib/whatsapp/encryption'; +import { normalizeEvents } from '@/lib/webhooks/events'; +import { + WEBHOOK_PUBLIC_COLUMNS, + serializeWebhookEndpoint, + generateWebhookSecret, + normalizeWebhookUrl, +} from '@/lib/webhooks/endpoints'; + +export async function GET(request: Request) { + try { + const ctx = await requireApiKey(request, 'webhooks:manage'); + + const { data, error } = await ctx.supabase + .from('webhook_endpoints') + .select(WEBHOOK_PUBLIC_COLUMNS) + .eq('account_id', ctx.accountId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('[api/v1/webhooks] list error:', error); + return fail('internal', 'Failed to list webhooks', 500); + } + + // The roster is small and settings-class — return it whole (the + // list envelope's cursor is always null here). + return okList( + (data ?? []).map((r) => + serializeWebhookEndpoint(r as Record) + ), + null + ); + } catch (err) { + return toApiErrorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireApiKey(request, 'webhooks:manage'); + + const body = (await request.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!body || typeof body !== 'object') { + return fail('bad_request', 'Request body must be a JSON object', 400); + } + + const url = normalizeWebhookUrl(body.url); + if (!url) { + return fail('bad_request', "'url' must be a valid https:// URL", 400); + } + + const events = normalizeEvents(body.events); + if (!events) { + return fail( + 'bad_request', + "'events' must be a non-empty array of known event names", + 400 + ); + } + + const secret = generateWebhookSecret(); + + const { data: created, error } = await ctx.supabase + .from('webhook_endpoints') + .insert({ + account_id: ctx.accountId, + created_by: ctx.createdBy, + url, + secret: encrypt(secret), + events, + }) + .select(WEBHOOK_PUBLIC_COLUMNS) + .single(); + + if (error || !created) { + console.error('[api/v1/webhooks] create error:', error); + return fail('internal', 'Failed to create webhook', 500); + } + + // Secret shown exactly once. + return ok( + { ...serializeWebhookEndpoint(created as Record), secret }, + 201 + ); + } catch (err) { + return toApiErrorResponse(err); + } +} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index aff9761..a1f1504 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -7,6 +7,7 @@ import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { dispatchWebhookEvent } from '@/lib/webhooks/deliver' import { handleTemplateWebhookChange, isTemplateWebhookField, @@ -354,7 +355,10 @@ async function handleStatusUpdate(status: { recipient_id: string }) { // 1) Mirror onto messages (legacy behavior) — Meta's status values - // already match the CHECK constraint on messages.status. + // already match the CHECK constraint on messages.status. No + // `.select()`: message_id is NOT unique (migration 009 — Meta ids + // repeat across numbers), so this updates 0..N rows and must not + // assume a single row. const { error: msgErr } = await supabaseAdmin() .from('messages') .update({ status: status.status }) @@ -364,6 +368,10 @@ async function handleStatusUpdate(status: { console.error('Error updating message status:', msgErr) } + // Webhook fan-out for this status change happens at the END of this + // handler (after the broadcast mirror below), so a slow subscriber + // endpoint can't delay the broadcast_recipients update. + // 2) Mirror onto broadcast_recipients via whatsapp_message_id // (added in migration 003). The aggregate trigger on // broadcast_recipients re-derives the parent broadcast's @@ -378,26 +386,53 @@ async function handleStatusUpdate(status: { if (recFetchErr) { console.error('Error fetching broadcast recipient:', recFetchErr) - return + } else if ( + recipient && + // Guard transitions — forward-only on the success ladder, and + // `failed` only from pre-delivered states. + isValidStatusTransition(recipient.status, status.status) + ) { + const update: Record = { status: status.status } + if (status.status === 'sent' && !('sent_at' in update)) update.sent_at = tsIso + if (status.status === 'delivered') update.delivered_at = tsIso + if (status.status === 'read') update.read_at = tsIso + + const { error: recUpdateErr } = await supabaseAdmin() + .from('broadcast_recipients') + .update(update) + .eq('id', recipient.id) + + if (recUpdateErr) { + console.error('Error updating broadcast recipient status:', recUpdateErr) + } } - if (!recipient) return // message wasn't part of a broadcast — fine - // Guard transitions — forward-only on the success ladder, and - // `failed` only from pre-delivered states. - if (!isValidStatusTransition(recipient.status, status.status)) return + // 3) Webhook fan-out for messages we store (inbox / API sends). + // Runs last so a slow subscriber can't delay the mirrors above. + // Bounded to one row (message_id isn't unique) purely to resolve + // the owning account for delivery. + const { data: msgRow } = await supabaseAdmin() + .from('messages') + .select('conversation_id, conversations(account_id)') + .eq('message_id', status.id) + .limit(1) + .maybeSingle() - const update: Record = { status: status.status } - if (status.status === 'sent' && !('sent_at' in update)) update.sent_at = tsIso - if (status.status === 'delivered') update.delivered_at = tsIso - if (status.status === 'read') update.read_at = tsIso - - const { error: recUpdateErr } = await supabaseAdmin() - .from('broadcast_recipients') - .update(update) - .eq('id', recipient.id) - - if (recUpdateErr) { - console.error('Error updating broadcast recipient status:', recUpdateErr) + if (msgRow) { + const conv = msgRow.conversations as { account_id: string } | null + const accountId = conv?.account_id + if (accountId) { + await dispatchWebhookEvent( + supabaseAdmin(), + accountId, + 'message.status_updated', + { + whatsapp_message_id: status.id, + conversation_id: msgRow.conversation_id, + status: status.status, + } + ) + } } } @@ -548,12 +583,24 @@ async function processMessage( const contactRecord = contactOutcome.contact // Find or create conversation - const conversation = await findOrCreateConversation( + const convResult = await findOrCreateConversation( accountId, configOwnerUserId, contactRecord.id ) - if (!conversation) return + if (!convResult) return + const conversation = convResult.conversation + + // Emit conversation.created as soon as the thread is opened — BEFORE + // the reaction short-circuit below — so a conversation first opened by + // a reaction still fires the event, and a subscriber always sees the + // thread open before its first message.received. + if (convResult.created) { + await dispatchWebhookEvent(supabaseAdmin(), accountId, 'conversation.created', { + conversation_id: conversation.id, + contact_id: contactRecord.id, + }) + } // Reactions short-circuit here — they aren't messages. We never insert // into `messages`, never bump unread_count, never update last_message_text. @@ -736,6 +783,21 @@ async function processMessage( }, }).catch((err) => console.error('[automations] dispatch failed:', err)) } + + // message.received webhook (public API). Awaited — not fire-and-forget + // — because we're inside the route's `after()` block, which only keeps + // the function alive for promises it can see; a detached promise could + // be frozen before it delivers. `dispatchWebhookEvent` early-exits + // when the account has no matching endpoint and never throws. + // (conversation.created is emitted earlier, right after the thread is + // opened.) + await dispatchWebhookEvent(supabaseAdmin(), accountId, 'message.received', { + conversation_id: conversation.id, + contact_id: contactRecord.id, + whatsapp_message_id: message.id, + content_type: contentType, + text: contentText, + }) } async function parseMessageContent( @@ -967,7 +1029,7 @@ async function findOrCreateConversation( .single() if (!findError && existing) { - return existing + return { conversation: existing, created: false } } // Create new conversation. Same tenancy + audit split as @@ -987,5 +1049,5 @@ async function findOrCreateConversation( return null } - return newConv + return { conversation: newConv, created: true } } diff --git a/src/lib/api-keys/scopes.ts b/src/lib/api-keys/scopes.ts index e274063..62c1257 100644 --- a/src/lib/api-keys/scopes.ts +++ b/src/lib/api-keys/scopes.ts @@ -20,6 +20,7 @@ export const API_SCOPES = [ 'contacts:write', 'conversations:read', 'broadcasts:send', + 'webhooks:manage', ] as const; export type ApiScope = (typeof API_SCOPES)[number]; @@ -32,6 +33,7 @@ export const SCOPE_DESCRIPTIONS: Record = { 'contacts:write': 'Create and update contacts', 'conversations:read': 'List and read conversations', 'broadcasts:send': 'Launch broadcast campaigns', + 'webhooks:manage': 'Register and manage outbound event webhooks', }; /** Type-narrow an unknown value into a valid `ApiScope`. */ diff --git a/src/lib/webhooks/deliver.test.ts b/src/lib/webhooks/deliver.test.ts new file mode 100644 index 0000000..50cb72b --- /dev/null +++ b/src/lib/webhooks/deliver.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +vi.mock('@/lib/whatsapp/encryption', () => ({ + decrypt: (s: string) => s, + encrypt: (s: string) => s, +})); + +// Control the SSRF guard per-test. +vi.mock('@/lib/webhooks/ssrf', () => ({ + isDeliverableUrl: vi.fn(async () => true), +})); + +import { dispatchWebhookEvent, MAX_CONSECUTIVE_FAILURES } from './deliver'; +import { isDeliverableUrl } from './ssrf'; + +interface Row { + id: string; + url: string; + secret: string; +} +interface Calls { + updates: { id: string; payload: Record }[]; + rpcs: { name: string; args: Record }[]; +} + +function makeDb(rows: Row[], calls: Calls) { + const from = () => { + let mode: 'select' | 'update' = 'select'; + let payload: Record = {}; + let id: string | null = null; + const b: Record = { + select: () => b, + eq: (col: string, val: string) => { + if (col === 'id') id = val; + return b; + }, + update: (p: Record) => { + mode = 'update'; + payload = p; + return b; + }, + contains: () => Promise.resolve({ data: rows, error: null }), + then: (resolve: (v: unknown) => unknown) => { + if (mode === 'update' && id) calls.updates.push({ id, payload }); + return resolve({ data: null, error: null }); + }, + }; + return b; + }; + const rpc = (name: string, args: Record) => { + calls.rpcs.push({ name, args }); + return Promise.resolve({ data: null, error: null }); + }; + return { from, rpc } as unknown as SupabaseClient; +} + +const emptyCalls = (): Calls => ({ updates: [], rpcs: [] }); + +beforeEach(() => { + vi.mocked(isDeliverableUrl).mockResolvedValue(true); + vi.stubGlobal('fetch', vi.fn()); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('dispatchWebhookEvent', () => { + it('signs + POSTs (no redirect follow) and resets failure_count on success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response); + vi.stubGlobal('fetch', fetchMock); + const calls = emptyCalls(); + + await dispatchWebhookEvent( + makeDb([{ id: 'a', url: 'https://a.test/hook', secret: 's1' }], calls), + 'acct-1', + 'message.received', + { x: 1 } + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('https://a.test/hook'); + expect(opts.redirect).toBe('manual'); + expect(opts.headers['X-Wacrm-Event']).toBe('message.received'); + expect(opts.headers['X-Wacrm-Signature']).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/); + // Payload carries a dedupe id. + expect(JSON.parse(opts.body).id).toMatch(/[0-9a-f-]{36}/); + expect(calls.updates[0]).toMatchObject({ id: 'a', payload: { failure_count: 0 } }); + expect(calls.rpcs).toHaveLength(0); + }); + + it('records an atomic failure (RPC) when the endpoint errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response)); + const calls = emptyCalls(); + + await dispatchWebhookEvent( + makeDb([{ id: 'b', url: 'https://b.test/hook', secret: 's2' }], calls), + 'acct-1', + 'message.received', + {} + ); + + expect(calls.rpcs[0]).toEqual({ + name: 'record_webhook_failure', + args: { endpoint_id: 'b', max_failures: MAX_CONSECUTIVE_FAILURES }, + }); + expect(calls.updates).toHaveLength(0); + }); + + it('blocks a non-public target (SSRF guard) without fetching', async () => { + vi.mocked(isDeliverableUrl).mockResolvedValue(false); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const calls = emptyCalls(); + + await dispatchWebhookEvent( + makeDb([{ id: 'c', url: 'https://127.0.0.1/hook', secret: 's3' }], calls), + 'acct-1', + 'message.received', + {} + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(calls.rpcs[0].name).toBe('record_webhook_failure'); + }); + + it('does nothing when no endpoints are subscribed', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const calls = emptyCalls(); + await dispatchWebhookEvent(makeDb([], calls), 'acct-1', 'message.received', {}); + expect(fetchMock).not.toHaveBeenCalled(); + expect(calls.rpcs).toHaveLength(0); + expect(calls.updates).toHaveLength(0); + }); +}); diff --git a/src/lib/webhooks/deliver.ts b/src/lib/webhooks/deliver.ts new file mode 100644 index 0000000..e60c722 --- /dev/null +++ b/src/lib/webhooks/deliver.ts @@ -0,0 +1,158 @@ +// ============================================================ +// Outbound webhook delivery. +// +// `dispatchWebhookEvent` finds the account's active endpoints +// subscribed to an event, signs one JSON payload, and POSTs it to +// each in parallel. It is best-effort and never throws — callers fire +// it from the inbound webhook's `after()` block, where a failed +// delivery must not affect the 200 OK returned to Meta. +// +// Delivery semantics (documented in docs/public-api.md): +// - At-most-once per event, single attempt with a short timeout. +// - Each consecutive failure bumps `failure_count`; once it crosses +// MAX_CONSECUTIVE_FAILURES the endpoint is auto-disabled +// (`is_active = false`) so a dead sink stops being hit. A success +// resets the counter and stamps `last_delivery_at`. +// - Durable retry-with-backoff would need a queue/worker (a +// follow-up); in-process retries inside `after()` would burn the +// route's duration budget without a real durability guarantee. +// ============================================================ + +import { randomUUID } from 'node:crypto'; + +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { decrypt } from '@/lib/whatsapp/encryption'; +import { buildSignatureHeader } from '@/lib/webhooks/sign'; +import { isDeliverableUrl } from '@/lib/webhooks/ssrf'; +import type { WebhookEvent } from '@/lib/webhooks/events'; + +/** Per-endpoint HTTP timeout. Kept short — this runs in `after()`. */ +export const DELIVERY_TIMEOUT_MS = 5000; + +/** Auto-disable an endpoint after this many consecutive failures. */ +export const MAX_CONSECUTIVE_FAILURES = 15; + +interface EndpointRow { + id: string; + url: string; + secret: string; +} + +/** + * Deliver `event` (+ `data`) to every active endpoint of `accountId` + * subscribed to it. Never throws. + */ +export async function dispatchWebhookEvent( + db: SupabaseClient, + accountId: string, + event: WebhookEvent, + data: unknown +): Promise { + try { + const { data: rows, error } = await db + .from('webhook_endpoints') + .select('id, url, secret') + .eq('account_id', accountId) + .eq('is_active', true) + .contains('events', [event]); + + if (error || !rows || rows.length === 0) return; + + // Sign the exact bytes we send so a receiver can recompute the + // HMAC over the raw request body. `id` is a per-delivery uuid the + // receiver can dedupe on (deliveries are at-least-once and may + // repeat / arrive out of order). + const payload = JSON.stringify({ + id: randomUUID(), + event, + occurred_at: new Date().toISOString(), + account_id: accountId, + data, + }); + const tsSeconds = Math.floor(Date.now() / 1000); + + await Promise.allSettled( + (rows as EndpointRow[]).map((row) => + deliverOne(db, row, event, payload, tsSeconds) + ) + ); + } catch (err) { + // Never let a delivery problem bubble into the webhook response. + console.error('[webhooks] dispatch failed:', err); + } +} + +async function deliverOne( + db: SupabaseClient, + row: EndpointRow, + event: WebhookEvent, + payload: string, + tsSeconds: number +): Promise { + // SSRF guard: refuse to POST to a host that resolves to a private / + // loopback / link-local address. Counts as a failure so a + // misconfigured internal URL surfaces and eventually auto-disables. + if (!(await isDeliverableUrl(row.url))) { + console.warn('[webhooks] refusing non-public delivery target for', row.id); + await recordFailure(db, row); + return; + } + + let secret: string; + try { + secret = decrypt(row.secret); + } catch (err) { + // A row whose secret can't be decrypted can never produce a valid + // signature — count it as a failure so it eventually auto-disables. + console.error('[webhooks] secret decrypt failed for', row.id, err); + await recordFailure(db, row); + return; + } + + try { + const res = await fetch(row.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Wacrm-Event': event, + 'X-Wacrm-Webhook-Id': row.id, + 'X-Wacrm-Signature': buildSignatureHeader(payload, secret, tsSeconds), + }, + body: payload, + // Do NOT follow redirects — a public URL could 3xx-bounce to an + // internal address, bypassing the SSRF check above. A 3xx is a + // misconfiguration; treat it as a failure. + redirect: 'manual', + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`endpoint responded ${res.status}`); + + // Success: clear the failure streak. + await db + .from('webhook_endpoints') + .update({ failure_count: 0, last_delivery_at: new Date().toISOString() }) + .eq('id', row.id); + } catch (err) { + console.warn( + `[webhooks] delivery to ${row.id} failed:`, + err instanceof Error ? err.message : err + ); + await recordFailure(db, row); + } +} + +async function recordFailure(db: SupabaseClient, row: EndpointRow): Promise { + // Atomic increment (+ auto-disable at the threshold) via a SQL + // function — a read-modify-write here would lose increments when two + // deliveries to the same endpoint run concurrently (e.g. + // conversation.created + message.received for one inbound message), + // so a dead endpoint might never reach the disable threshold. + const { error } = await db.rpc('record_webhook_failure', { + endpoint_id: row.id, + max_failures: MAX_CONSECUTIVE_FAILURES, + }); + if (error) { + console.error('[webhooks] record_webhook_failure failed for', row.id, error); + } +} diff --git a/src/lib/webhooks/endpoints.test.ts b/src/lib/webhooks/endpoints.test.ts new file mode 100644 index 0000000..851f441 --- /dev/null +++ b/src/lib/webhooks/endpoints.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + generateWebhookSecret, + serializeWebhookEndpoint, + normalizeWebhookUrl, + WEBHOOK_SECRET_PREFIX, +} from './endpoints'; + +describe('generateWebhookSecret', () => { + it('is prefixed and high-entropy, and unique per call', () => { + const a = generateWebhookSecret(); + const b = generateWebhookSecret(); + expect(a.startsWith(WEBHOOK_SECRET_PREFIX)).toBe(true); + expect(a.length).toBeGreaterThan(WEBHOOK_SECRET_PREFIX.length + 20); + expect(a).not.toBe(b); + }); +}); + +describe('serializeWebhookEndpoint', () => { + it('projects public fields and never leaks the secret', () => { + const out = serializeWebhookEndpoint({ + id: 'w1', + account_id: 'acct', + created_by: 'u1', + url: 'https://example.com/hook', + secret: 'encrypted-blob', + events: ['message.received'], + is_active: true, + last_delivery_at: null, + failure_count: 0, + created_at: '2026-01-01T00:00:00Z', + }); + expect(out).not.toHaveProperty('secret'); + expect(out).not.toHaveProperty('account_id'); + expect(out).toEqual({ + id: 'w1', + url: 'https://example.com/hook', + events: ['message.received'], + is_active: true, + last_delivery_at: null, + failure_count: 0, + created_at: '2026-01-01T00:00:00Z', + }); + }); +}); + +describe('normalizeWebhookUrl', () => { + it('accepts https and normalizes', () => { + expect(normalizeWebhookUrl(' https://example.com/hook ')).toBe( + 'https://example.com/hook' + ); + }); + + it('rejects http, non-URLs, and non-strings', () => { + expect(normalizeWebhookUrl('http://example.com/hook')).toBeNull(); + expect(normalizeWebhookUrl('not a url')).toBeNull(); + expect(normalizeWebhookUrl(123)).toBeNull(); + }); +}); diff --git a/src/lib/webhooks/endpoints.ts b/src/lib/webhooks/endpoints.ts new file mode 100644 index 0000000..a1dbd52 --- /dev/null +++ b/src/lib/webhooks/endpoints.ts @@ -0,0 +1,66 @@ +// ============================================================ +// Webhook endpoint store helpers — secret generation + the public +// (secret-free) serialization used by the management API. +// +// The signing secret is stored AES-256-GCM-encrypted at rest (see +// migration 028) and returned in plaintext exactly once, at creation. +// ============================================================ + +import { randomBytes } from 'node:crypto'; + +/** Secret prefix — self-identifying, like `wacrm_live_` for keys. */ +export const WEBHOOK_SECRET_PREFIX = 'whsec_'; + +/** + * Columns safe to return over the API — everything except the + * (encrypted) `secret`, which is only ever surfaced once at creation. + */ +export const WEBHOOK_PUBLIC_COLUMNS = + 'id, url, events, is_active, last_delivery_at, failure_count, created_at'; + +export interface ApiWebhookEndpoint { + id: string; + url: string; + events: string[]; + is_active: boolean; + last_delivery_at: string | null; + failure_count: number; + created_at: string; +} + +/** Generate a fresh signing secret (full-entropy, URL/header-safe). */ +export function generateWebhookSecret(): string { + return `${WEBHOOK_SECRET_PREFIX}${randomBytes(32).toString('base64url')}`; +} + +/** Project a `WEBHOOK_PUBLIC_COLUMNS` row into the API shape. */ +export function serializeWebhookEndpoint( + row: Record +): ApiWebhookEndpoint { + return { + id: row.id as string, + url: row.url as string, + events: (row.events as string[] | null) ?? [], + is_active: Boolean(row.is_active), + last_delivery_at: (row.last_delivery_at as string | null) ?? null, + failure_count: (row.failure_count as number | null) ?? 0, + created_at: row.created_at as string, + }; +} + +/** + * Validate a webhook target URL: must be a well-formed absolute + * `https://` URL (an unencrypted `http://` sink would leak signed + * event payloads). Returns the normalized string or null. + */ +export function normalizeWebhookUrl(input: unknown): string | null { + if (typeof input !== 'string') return null; + const trimmed = input.trim(); + try { + const u = new URL(trimmed); + if (u.protocol !== 'https:') return null; + return u.toString(); + } catch { + return null; + } +} diff --git a/src/lib/webhooks/events.test.ts b/src/lib/webhooks/events.test.ts new file mode 100644 index 0000000..e497eca --- /dev/null +++ b/src/lib/webhooks/events.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { + WEBHOOK_EVENTS, + WEBHOOK_EVENT_DESCRIPTIONS, + isWebhookEvent, + normalizeEvents, +} from './events'; + +describe('isWebhookEvent', () => { + it('accepts every declared event and rejects others', () => { + for (const e of WEBHOOK_EVENTS) expect(isWebhookEvent(e)).toBe(true); + expect(isWebhookEvent('message.deleted')).toBe(false); + expect(isWebhookEvent(42)).toBe(false); + }); +}); + +describe('every event has a description', () => { + it('covers the vocabulary', () => { + for (const e of WEBHOOK_EVENTS) { + expect(WEBHOOK_EVENT_DESCRIPTIONS[e]).toBeTruthy(); + } + }); +}); + +describe('normalizeEvents', () => { + it('de-duplicates a valid list', () => { + expect( + normalizeEvents(['message.received', 'message.received', 'conversation.created']) + ).toEqual(['message.received', 'conversation.created']); + }); + + it('rejects an unknown event', () => { + expect(normalizeEvents(['message.received', 'nope'])).toBeNull(); + }); + + it('rejects a non-array and an empty array', () => { + expect(normalizeEvents('message.received')).toBeNull(); + expect(normalizeEvents([])).toBeNull(); + }); +}); diff --git a/src/lib/webhooks/events.ts b/src/lib/webhooks/events.ts new file mode 100644 index 0000000..f2108b9 --- /dev/null +++ b/src/lib/webhooks/events.ts @@ -0,0 +1,48 @@ +// ============================================================ +// Outbound webhook event vocabulary — pure, no I/O. +// +// An endpoint subscribes to one or more of these. Adding an event is +// one entry here plus a `dispatchWebhookEvent` call at the source of +// the event (the DB stores subscriptions as a free `text[]`, so no +// migration is needed — same model as API scopes). +// ============================================================ + +export const WEBHOOK_EVENTS = [ + 'message.received', // an inbound WhatsApp message landed + 'message.status_updated', // a sent message advanced (sent/delivered/read) + 'conversation.created', // a new conversation was opened for a contact +] as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; + +/** Human-readable descriptions (surfaced in docs / a future UI). */ +export const WEBHOOK_EVENT_DESCRIPTIONS: Record = { + 'message.received': 'An inbound message was received from a contact', + 'message.status_updated': + 'A message you sent changed delivery status (sent/delivered/read/failed)', + 'conversation.created': 'A new conversation was opened', +}; + +/** Type-narrow an unknown value into a valid `WebhookEvent`. */ +export function isWebhookEvent(value: unknown): value is WebhookEvent { + return ( + typeof value === 'string' && + (WEBHOOK_EVENTS as readonly string[]).includes(value) + ); +} + +/** + * Validate + de-duplicate a caller-supplied event list. Returns the + * cleaned list, or `null` if any entry is unknown (callers turn that + * into a 400). An empty list is rejected as `null` too — an endpoint + * subscribed to nothing is almost certainly a mistake. + */ +export function normalizeEvents(input: unknown): WebhookEvent[] | null { + if (!Array.isArray(input) || input.length === 0) return null; + const out: WebhookEvent[] = []; + for (const entry of input) { + if (!isWebhookEvent(entry)) return null; + if (!out.includes(entry)) out.push(entry); + } + return out; +} diff --git a/src/lib/webhooks/sign.test.ts b/src/lib/webhooks/sign.test.ts new file mode 100644 index 0000000..fe28e0f --- /dev/null +++ b/src/lib/webhooks/sign.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { buildSignatureHeader, verifySignatureHeader } from './sign'; + +const secret = 'whsec_testsecret'; +const body = JSON.stringify({ event: 'message.received', data: { a: 1 } }); + +describe('buildSignatureHeader', () => { + it('emits the t=,v1= shape and is deterministic', () => { + const h1 = buildSignatureHeader(body, secret, 1_700_000_000); + const h2 = buildSignatureHeader(body, secret, 1_700_000_000); + expect(h1).toMatch(/^t=1700000000,v1=[0-9a-f]{64}$/); + expect(h1).toBe(h2); + }); +}); + +describe('verifySignatureHeader', () => { + const now = 1_700_000_000; + const header = buildSignatureHeader(body, secret, now); + + it('accepts a valid, in-tolerance signature', () => { + expect(verifySignatureHeader(header, body, secret, now + 10)).toBe(true); + }); + + it('rejects a tampered body', () => { + expect(verifySignatureHeader(header, body + 'x', secret, now)).toBe(false); + }); + + it('rejects a wrong secret', () => { + expect(verifySignatureHeader(header, body, 'whsec_other', now)).toBe(false); + }); + + it('rejects a stale timestamp (replay protection)', () => { + expect(verifySignatureHeader(header, body, secret, now + 10_000)).toBe(false); + }); + + it('rejects a malformed header', () => { + expect(verifySignatureHeader('garbage', body, secret, now)).toBe(false); + }); + + it('tolerates uppercase hex and whitespace in the header', () => { + const [, t, v1] = header.match(/^t=(\d+),v1=([0-9a-f]+)$/)!; + const loose = `t=${t}, v1=${v1.toUpperCase()}`; + expect(verifySignatureHeader(loose, body, secret, Number(t))).toBe(true); + }); +}); diff --git a/src/lib/webhooks/sign.ts b/src/lib/webhooks/sign.ts new file mode 100644 index 0000000..4d5ad06 --- /dev/null +++ b/src/lib/webhooks/sign.ts @@ -0,0 +1,68 @@ +// ============================================================ +// Webhook payload signing — pure, server-side. +// +// Every delivery carries an `X-Wacrm-Signature` header so receivers +// can verify the request really came from wacrm and wasn't tampered +// with or replayed. The scheme is Stripe-style: +// +// X-Wacrm-Signature: t=,v1= +// +// where the signed message is `${t}.${rawBody}` and the key is the +// endpoint's secret. Receivers recompute the HMAC over the raw body +// they received (not a re-serialized copy) and compare in constant +// time, and reject if `t` is too old (replay protection). +// ============================================================ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** + * Build the `X-Wacrm-Signature` header value for `rawBody`, signed + * with `secret` at time `timestampSeconds` (pass it in — never call + * Date.now() here, so the value is testable and callers control the + * clock). + */ +export function buildSignatureHeader( + rawBody: string, + secret: string, + timestampSeconds: number +): string { + const signature = createHmac('sha256', secret) + .update(`${timestampSeconds}.${rawBody}`) + .digest('hex'); + return `t=${timestampSeconds},v1=${signature}`; +} + +/** + * Verify a signature header. Exposed so a wacrm-to-wacrm integration + * (or a test) can validate deliveries; receivers in other stacks + * reimplement the same three lines. `toleranceSeconds` bounds replay. + */ +export function verifySignatureHeader( + header: string, + rawBody: string, + secret: string, + nowSeconds: number, + toleranceSeconds = 300 +): boolean { + const parts = Object.fromEntries( + header.split(',').map((kv) => { + const i = kv.indexOf('='); + return [kv.slice(0, i).trim(), kv.slice(i + 1)]; + }) + ); + const t = Number(parts.t); + // Normalize the presented signature: hex is case-insensitive and a + // header may carry stray whitespace (e.g. `t=…, v1=…`), so lower-case + // and trim before the constant-time compare. + const v1 = typeof parts.v1 === 'string' ? parts.v1.trim().toLowerCase() : ''; + if (!Number.isFinite(t) || !v1) return false; + if (Math.abs(nowSeconds - t) > toleranceSeconds) return false; + + const expected = createHmac('sha256', secret) + .update(`${t}.${rawBody}`) + .digest('hex'); + // Constant-time compare; guard against length mismatch (timingSafeEqual + // throws on unequal-length buffers). + if (expected.length !== v1.length) return false; + return timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); +} diff --git a/src/lib/webhooks/ssrf.test.ts b/src/lib/webhooks/ssrf.test.ts new file mode 100644 index 0000000..ea429f9 --- /dev/null +++ b/src/lib/webhooks/ssrf.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { isPrivateOrReservedIp, isDeliverableUrl } from './ssrf'; + +describe('isPrivateOrReservedIp', () => { + it('flags loopback / private / link-local / CGNAT IPv4', () => { + for (const ip of [ + '127.0.0.1', + '10.0.0.5', + '172.16.0.1', + '172.31.255.255', + '192.168.1.1', + '169.254.169.254', // cloud metadata + '100.64.0.1', // CGNAT + '0.0.0.0', + ]) { + expect(isPrivateOrReservedIp(ip)).toBe(true); + } + }); + + it('allows public IPv4', () => { + for (const ip of ['8.8.8.8', '1.1.1.1', '172.15.0.1', '172.32.0.1', '93.184.216.34']) { + expect(isPrivateOrReservedIp(ip)).toBe(false); + } + }); + + it('flags loopback / ULA / link-local IPv6 and IPv4-mapped privates', () => { + for (const ip of ['::1', 'fe80::1', 'fc00::1', 'fd12::34', '::ffff:127.0.0.1']) { + expect(isPrivateOrReservedIp(ip)).toBe(true); + } + expect(isPrivateOrReservedIp('2606:4700:4700::1111')).toBe(false); + }); +}); + +describe('isDeliverableUrl', () => { + it('rejects literal private IPs and internal names without DNS', async () => { + expect(await isDeliverableUrl('https://127.0.0.1/hook')).toBe(false); + expect(await isDeliverableUrl('https://169.254.169.254/latest/meta-data')).toBe(false); + expect(await isDeliverableUrl('https://[::1]/hook')).toBe(false); + expect(await isDeliverableUrl('https://localhost/hook')).toBe(false); + expect(await isDeliverableUrl('https://foo.internal/hook')).toBe(false); + }); + + it('rejects a malformed URL', async () => { + expect(await isDeliverableUrl('not a url')).toBe(false); + }); + + it('allows a literal public IP', async () => { + expect(await isDeliverableUrl('https://8.8.8.8/hook')).toBe(true); + }); +}); diff --git a/src/lib/webhooks/ssrf.ts b/src/lib/webhooks/ssrf.ts new file mode 100644 index 0000000..c4bad5d --- /dev/null +++ b/src/lib/webhooks/ssrf.ts @@ -0,0 +1,83 @@ +// ============================================================ +// SSRF guard for outbound webhook delivery. +// +// A webhook URL is attacker-influenced (any account admin with +// `webhooks:manage` can register one) and our server makes the request, +// so an unguarded fetch is a Server-Side Request Forgery primitive: a +// URL pointing at `127.0.0.1`, a cloud metadata IP (`169.254.169.254`), +// or an RFC1918 host would let a caller probe / POST to internal +// services from the app's network. +// +// `isDeliverableUrl` resolves the host and rejects any address that is +// loopback, private, link-local, ULA, or otherwise non-publicly- +// routable. Combined with `redirect: 'manual'` at the call site (so a +// public URL can't 3xx-bounce to an internal one), this blocks the +// common SSRF vectors. It is NOT a defense against DNS rebinding (a +// host that resolves public here but flips to private before connect) — +// that needs pinning the resolved IP into the socket, which fetch +// doesn't expose; documented as a residual risk. +// ============================================================ + +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +/** True for loopback / private / link-local / reserved IPv4 or IPv6. */ +export function isPrivateOrReservedIp(ip: string): boolean { + const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const a = Number(v4[1]); + const b = Number(v4[2]); + if (a === 0) return true; // "this" network + if (a === 10) return true; // private + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + + const v6 = ip.toLowerCase().replace(/^\[|\]$/g, ''); + if (v6 === '::1' || v6 === '::') return true; // loopback / unspecified + if (v6.startsWith('fe8') || v6.startsWith('fe9') || v6.startsWith('fea') || v6.startsWith('feb')) + return true; // fe80::/10 link-local + if (v6.startsWith('fc') || v6.startsWith('fd')) return true; // fc00::/7 ULA + const mapped = v6.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (mapped) return isPrivateOrReservedIp(mapped[1]); // IPv4-mapped + return false; +} + +/** + * True if `rawUrl`'s host resolves only to publicly-routable + * address(es). Returns false for a malformed URL, an obvious internal + * name (`localhost`, `*.local`, `*.internal`), a literal private IP, or + * a hostname that resolves to any private/reserved address. + */ +export async function isDeliverableUrl(rawUrl: string): Promise { + let host: string; + try { + host = new URL(rawUrl).hostname.replace(/^\[|\]$/g, ''); + } catch { + return false; + } + + if (isIP(host)) return !isPrivateOrReservedIp(host); + + const lower = host.toLowerCase(); + if ( + lower === 'localhost' || + lower.endsWith('.localhost') || + lower.endsWith('.local') || + lower.endsWith('.internal') + ) { + return false; + } + + try { + const results = await lookup(host, { all: true }); + if (results.length === 0) return false; + return results.every((r) => !isPrivateOrReservedIp(r.address)); + } catch { + return false; // unresolvable → not deliverable + } +} diff --git a/supabase/migrations/028_webhook_endpoints.sql b/supabase/migrations/028_webhook_endpoints.sql new file mode 100644 index 0000000..dccb6e0 --- /dev/null +++ b/supabase/migrations/028_webhook_endpoints.sql @@ -0,0 +1,103 @@ +-- ============================================================ +-- 028_webhook_endpoints.sql — Outbound event webhooks (public API) +-- +-- Lets an account register HTTPS endpoints that wacrm POSTs to when +-- something happens (an inbound message arrives, a delivery status +-- changes, a conversation is created). This is the "react to inbound" +-- half of the public API (#245): instead of polling +-- `GET /api/v1/conversations`, an automation subscribes once and is +-- pushed the events it cares about. +-- +-- Design notes +-- - Account-scoped, never user-scoped (same as `api_keys`). +-- `created_by` records who registered it (audit); ON DELETE SET +-- NULL so removing a teammate doesn't drop their integration's +-- endpoint. +-- - `secret` is the HMAC signing key. UNLIKE `api_keys` (where we +-- store only a hash because the key is a bearer credential the +-- *client* presents), here *we* sign each outgoing payload with +-- the secret and the receiver verifies it — so we need the +-- plaintext at delivery time. We store it AES-256-GCM-encrypted +-- at rest (same `encrypt()`/`decrypt()` as `whatsapp_config. +-- access_token`), and return the plaintext to the creator exactly +-- once so they can configure their verifier. +-- - `events[]` is the subscription filter (free text[], validated +-- in the app layer against `src/lib/webhooks/events.ts` — a new +-- event type is a code change, not a migration, mirroring scopes). +-- - `failure_count` counts *consecutive* delivery failures; the +-- deliverer auto-sets `is_active = false` once it crosses a +-- threshold so a permanently-dead endpoint stops being retried. +-- A successful delivery resets it to 0. +-- +-- RLS +-- Settings-class, mirroring `api_keys`: any member may read the +-- roster; only admin+ may create/update/delete. The delivery path +-- and the public-API management routes both use the service-role +-- client (an API caller has no `auth.uid()`), so RLS is the guard +-- for any dashboard UI that reads the table directly. +-- +-- Idempotent — safe to run multiple times. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS webhook_endpoints ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + url text NOT NULL, -- HTTPS endpoint we POST to + secret text NOT NULL, -- AES-256-GCM-encrypted HMAC signing secret + events text[] NOT NULL DEFAULT '{}', + is_active boolean NOT NULL DEFAULT true, + last_delivery_at timestamptz, -- last successful delivery + failure_count integer NOT NULL DEFAULT 0, -- consecutive failures; reset to 0 on success + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Every delivery + management query filters by account_id. +CREATE INDEX IF NOT EXISTS webhook_endpoints_account_id_idx + ON webhook_endpoints (account_id); + +ALTER TABLE webhook_endpoints ENABLE ROW LEVEL SECURITY; + +-- SELECT: any member of the account (viewer+) can see the roster. +DROP POLICY IF EXISTS webhook_endpoints_select ON webhook_endpoints; +CREATE POLICY webhook_endpoints_select ON webhook_endpoints FOR SELECT + USING (is_account_member(account_id)); + +-- INSERT / UPDATE / DELETE: admin+ only (settings-class). +DROP POLICY IF EXISTS webhook_endpoints_insert ON webhook_endpoints; +CREATE POLICY webhook_endpoints_insert ON webhook_endpoints FOR INSERT + WITH CHECK (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS webhook_endpoints_update ON webhook_endpoints; +CREATE POLICY webhook_endpoints_update ON webhook_endpoints FOR UPDATE + USING (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS webhook_endpoints_delete ON webhook_endpoints; +CREATE POLICY webhook_endpoints_delete ON webhook_endpoints FOR DELETE + USING (is_account_member(account_id, 'admin')); + +-- ============================================================ +-- Atomic consecutive-failure counter. +-- +-- The deliverer records failures through this function rather than a +-- read-modify-write: two deliveries to the same endpoint can run +-- concurrently (e.g. conversation.created + message.received for one +-- inbound message), and a client-side `count = count + 1` would lose +-- increments, so a dead endpoint might never reach the auto-disable +-- threshold. The `+ 1` and the disable decision happen in one UPDATE. +-- Only ever disables (never re-enables) — re-enabling is an explicit +-- PATCH by an admin, which resets the counter. +-- ============================================================ +CREATE OR REPLACE FUNCTION public.record_webhook_failure( + endpoint_id uuid, + max_failures int +) +RETURNS void AS $$ + UPDATE webhook_endpoints + SET failure_count = failure_count + 1, + is_active = CASE + WHEN failure_count + 1 >= max_failures THEN false + ELSE is_active + END + WHERE id = endpoint_id; +$$ LANGUAGE sql SECURITY DEFINER SET search_path = public;