diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts new file mode 100644 index 00000000..d6108bbc --- /dev/null +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts @@ -0,0 +1,154 @@ +/** + * /api/v1/companies/{companyId}/webhooks/{id}/deliveries — list deliveries. + * + * Returns the most recent deliveries for the webhook, newest first. + * Cursor pagination on (created_at DESC, id DESC). Single-delivery lookup + * via ?delivery_id=. + * + * Response carries `status`, `attempts`, `next_attempt_at`, the captured + * `response_status` / `response_body` / `error` so a caller (or the dashboard + * webhook detail panel) can debug a flaky receiver. + */ + +import { z } from 'zod' +import { paginated } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { decodeDefaultCursor, encodeDefaultCursor, parsePaginationParams } from '@/lib/api/v1/pagination' + +const DELIVERY_COLUMNS = + 'id, webhook_id, event_type, status, attempts, next_attempt_at, response_status, response_body, error, request_id, created_at, delivered_at' + +const DeliverySummary = z.object({ + id: z.string().uuid(), + webhook_id: z.string().uuid(), + event_type: z.string(), + status: z.enum(['pending', 'in_flight', 'delivered', 'failed', 'dead']), + attempts: z.number().int(), + next_attempt_at: z.string(), + response_status: z.number().int().nullable(), + response_body: z.string().nullable(), + error: z.string().nullable(), + request_id: z.string().nullable(), + created_at: z.string(), + delivered_at: z.string().nullable(), +}) + +registerEndpoint({ + operation: 'webhooks.deliveries.list', + method: 'GET', + path: '/api/v1/companies/:companyId/webhooks/:id/deliveries', + summary: 'List deliveries for a webhook subscription.', + description: + 'Returns deliveries for the webhook in newest-first order. Each row carries the current status (pending / in_flight / delivered / failed / dead), the attempt count, the next scheduled retry time, and the captured response details from the last attempt.', + useWhen: + 'You are debugging a flaky receiver, or building a delivery-history UI for a settings page.', + doNotUseFor: + 'Listing deliveries across multiple webhooks (this endpoint is single-webhook scoped).', + pitfalls: [ + 'response_body is truncated to 4 KB — receivers returning long error pages have their response truncated.', + 'A delivery in `failed` status is non-terminal — the dispatcher will retry it at next_attempt_at. `dead` is terminal.', + ], + example: { + response: { + data: [ + { + id: 'wh_dlv_…', + webhook_id: 'a8f1…', + event_type: 'invoice.paid', + status: 'delivered', + attempts: 1, + next_attempt_at: '2026-05-15T12:00:00Z', + response_status: 200, + response_body: 'ok', + error: null, + request_id: 'whdel_…', + created_at: '2026-05-15T12:00:00Z', + delivered_at: '2026-05-15T12:00:01Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: z.array(DeliverySummary) }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.deliveries.list', + async (request, ctx, params) => { + const { id: webhookId } = await params.params + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + // Defensive early return — the wrapper guarantees companyId for + // routes inside /companies/{companyId}/, but a missing value here + // would silently produce `WHERE company_id = NULL` (always-empty) + // rather than a hard auth failure. Surface the misconfiguration. + if (!ctx.companyId) { + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId }) + } + + // Verify the webhook itself belongs to ctx.companyId before listing + // its deliveries. The deliveries query already filters by + // (company_id, webhook_id) so a cross-tenant id wouldn't return + // anything — but emitting an explicit ownership check first surfaces + // a clean 404 (rather than a confusing empty list) and matches the + // pattern used for :retry and :test. Defense in depth alongside RLS. + const { data: webhookOwnership, error: ownershipErr } = await ctx.supabase + .from('webhooks') + .select('id') + .eq('id', webhookId) + .eq('company_id', ctx.companyId) + .maybeSingle() + + if (ownershipErr) return v1ErrorResponse(ownershipErr, ctx.log, { requestId: ctx.requestId }) + if (!webhookOwnership) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + let query = ctx.supabase + .from('webhook_deliveries') + .select(DELIVERY_COLUMNS) + .eq('company_id', ctx.companyId) + .eq('webhook_id', webhookId) + .order('created_at', { ascending: false }) + .order('id', { ascending: false }) + .limit(limit + 1) + + const deliveryId = url.searchParams.get('delivery_id') + if (deliveryId) { + query = query.eq('id', deliveryId) + } + + if (decoded) { + query = query.or( + `created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.lt.${decoded.id})`, + ) + } + + const { data, error } = await query + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + + type Row = { id: string; created_at: string } + const rows = (data ?? []) as unknown as Row[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) + : null + + return paginated(trimmed, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts new file mode 100644 index 00000000..fba255f7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts @@ -0,0 +1,274 @@ +/** + * /api/v1/companies/{companyId}/webhooks/{id} — get / update / delete. + * + * GET — return the full webhook row (no secret). + * PATCH — update name, description, webhook_url, active. Cannot change + * event_type (immutable: would require re-pinning api_version). + * Cannot rotate the secret here (separate flow, deferred to + * Phase 6 follow-up). + * DELETE — hard delete the webhook. The webhook_deliveries.webhook_id FK + * is ON DELETE SET NULL (declared in migration 20260515170000), + * so the delivery audit trail SURVIVES webhook deletion + * (BFNAR 2013:2 kap 8 § behandlingshistorik — accounting-event + * deliveries must be retained for 7 years). Pending/failed + * deliveries become dormant (the dispatcher skips + * webhook_id IS NULL rows); terminal rows stay queryable via + * the (future) per-company audit-trail surface. + */ + +import { z } from 'zod' +import { ok, noContent } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { validateWebhookUrl } from '@/lib/webhooks/url-guard' + +const WEBHOOK_DETAIL_COLUMNS = + 'id, name, description, event_type, webhook_url, active, api_version_pinned, disabled_at, disabled_reason, created_at, updated_at' + +const WebhookDetail = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + event_type: z.string(), + webhook_url: z.string(), + active: z.boolean(), + api_version_pinned: z.string(), + disabled_at: z.string().nullable(), + disabled_reason: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +const PatchWebhookSchema = z + .object({ + name: z.string().min(1).max(120).optional(), + description: z.string().max(500).nullable().optional(), + webhook_url: z + .string() + .url() + .max(2048) + .refine((u) => u.startsWith('https://'), { message: 'webhook_url must use https://' }) + .optional(), + active: z.boolean().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required.' }) + +// ────────────────────────────────────────────────────────────────── +// GET — detail +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'webhooks.get', + method: 'GET', + path: '/api/v1/companies/:companyId/webhooks/:id', + summary: 'Get a webhook subscription by id.', + description: 'Returns the webhook configuration. The HMAC signing secret is never exposed.', + useWhen: 'You need the current state of a single webhook (e.g. to render a settings page).', + doNotUseFor: 'Reading the secret (returned only once on creation).', + pitfalls: [], + example: { + response: { + data: { + id: 'a8f1…', + name: 'CRM sync', + description: null, + event_type: 'invoice.paid', + webhook_url: 'https://example.com/hooks/gnubok', + active: true, + api_version_pinned: '2026-05-12', + disabled_at: null, + disabled_reason: null, + created_at: '2026-05-15T12:00:00Z', + updated_at: '2026-05-15T12:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: WebhookDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.get', + async (_request, ctx, params) => { + const { id } = await params.params + const { data, error } = await ctx.supabase + .from('webhooks') + .select(WEBHOOK_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', id) + .maybeSingle() + + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + if (!data) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// PATCH — update +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'webhooks.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/webhooks/:id', + summary: 'Update a webhook subscription.', + description: + 'Update the URL, name, description, or active flag. event_type is immutable — delete and recreate to change it. Setting active=false manually pauses delivery without deleting; setting active=true clears any disabled_at/disabled_reason set by the auto-disable on HTTP 410.', + useWhen: 'You need to point an existing webhook at a new URL or temporarily pause delivery.', + doNotUseFor: 'Rotating the signing secret (delete and recreate). Changing event_type.', + pitfalls: [ + 'Re-enabling a webhook (active: true) does NOT replay deliveries that went to dead status while it was disabled — those need POST /webhook-deliveries/{id}/retry.', + ], + example: { + request: { active: true }, + response: { + data: { + id: 'a8f1…', + name: 'CRM sync', + description: null, + event_type: 'invoice.paid', + webhook_url: 'https://example.com/hooks/gnubok', + active: true, + api_version_pinned: '2026-05-12', + disabled_at: null, + disabled_reason: null, + created_at: '2026-05-15T12:00:00Z', + updated_at: '2026-05-15T12:05:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: PatchWebhookSchema }, + response: { success: WebhookDetail }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.update', + async (request, ctx, params) => { + const { id } = await params.params + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + const parsed = PatchWebhookSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + // SSRF guard on webhook_url change — same DNS/IP-class validation as + // POST /webhooks. Skip when webhook_url isn't being changed. + if (body.webhook_url !== undefined) { + const urlCheck = await validateWebhookUrl(body.webhook_url) + if (!urlCheck.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'webhook_url', reason: urlCheck.reason, message: urlCheck.detail }, + }) + } + } + + // Re-enable clears disabled_at/disabled_reason (legitimate operator + // action after fixing the receiver). Manual disable sets them. + const update: Record = { ...body } + if (body.active === true) { + update.disabled_at = null + update.disabled_reason = null + } else if (body.active === false) { + update.disabled_at = new Date().toISOString() + update.disabled_reason = 'manually_disabled' + } + + if (ctx.dryRun) { + return dryRunPreview( + { id, ...update, would_persist: true }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('webhooks') + .update(update) + .eq('company_id', ctx.companyId!) + .eq('id', id) + .select(WEBHOOK_DETAIL_COLUMNS) + .maybeSingle() + + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + if (!data) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// DELETE +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'webhooks.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/webhooks/:id', + summary: 'Delete a webhook subscription.', + description: + 'Hard-deletes the webhook. The delivery audit trail SURVIVES — both terminal (delivered, dead) and non-terminal (pending, failed) delivery rows persist with webhook_id = NULL so the BFNAR 2013:2 kap 8 § behandlingshistorik (7-year retention) for accounting-event deliveries is preserved. Non-terminal rows go dormant (the dispatcher skips them).', + useWhen: 'You no longer want this webhook to receive events.', + doNotUseFor: + 'Temporarily pausing delivery — use PATCH with active=false instead so the configuration survives.', + pitfalls: ['Audit history survives DELETE; only the receiver subscription is removed. To suppress future events without retaining the registration use PATCH active=false.'], + example: { + response: { + data: { deleted: true }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'webhooks:manage', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: z.object({ deleted: z.boolean() }) }, +}) + +export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.delete', + async (_request, ctx, params) => { + const { id } = await params.params + const { error } = await ctx.supabase + .from('webhooks') + .delete() + .eq('company_id', ctx.companyId!) + .eq('id', id) + + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + return noContent({ requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts new file mode 100644 index 00000000..074e979f --- /dev/null +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts @@ -0,0 +1,119 @@ +/** + * /api/v1/companies/{companyId}/webhooks/{id}/test — POST :test verb. + * + * Enqueues a synthetic `webhook.test` delivery against the configured + * receiver. The dispatcher cron picks it up at next-minute boundary + * exactly as it would a real event. The response returns the + * webhook_delivery_id so the caller can poll + * GET /webhooks/{id}/deliveries?delivery_id=... to see the outcome. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' + +// Scope catalogue: this verb shares webhooks:manage with the parent +// resource. Add the entry to lib/auth/scopes.ts in the same commit when +// promoting the route from skeleton to live (the catalogue currently lists +// only the four CRUD entries plus this :test verb is implied by the +// resource scope; explicit entry follows in the next commit). + +registerEndpoint({ + operation: 'webhooks.test', + method: 'POST', + path: '/api/v1/companies/:companyId/webhooks/:id/test', + summary: 'Send a synthetic test event to a webhook.', + description: + 'Enqueues a webhook.test delivery against the configured receiver. The dispatcher delivers it on the next per-minute cron tick. Use the returned webhook_delivery_id to poll GET /webhooks/{id}/deliveries for the outcome.', + useWhen: + 'After creating or modifying a webhook, before relying on it in production — to validate that the receiver is reachable and that signature verification works on the receiver side.', + doNotUseFor: + 'Smoke-testing the dispatcher itself (use a real event). Replaying a failed delivery (use POST /webhook-deliveries/{id}/retry).', + pitfalls: [ + 'Test deliveries follow the same retry policy as real events — a 500 from your receiver will retry 7 times over ~72h. Use a 2xx ack-only handler if you want a clean signal.', + ], + example: { + response: { + data: { + webhook_delivery_id: 'wh_dlv_…', + status: 'pending', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: false, + reversible: false, + dryRunSupported: false, + response: { + success: z.object({ + webhook_delivery_id: z.string().uuid(), + status: z.literal('pending'), + }), + }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.test', + async (_request, ctx, params) => { + const { id } = await params.params + + const { data: webhook, error: lookupErr } = await ctx.supabase + .from('webhooks') + .select('id, api_version_pinned, active, disabled_at') + .eq('company_id', ctx.companyId!) + .eq('id', id) + .maybeSingle() + + if (lookupErr) return v1ErrorResponse(lookupErr, ctx.log, { requestId: ctx.requestId }) + if (!webhook) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + + type W = { id: string; api_version_pinned: string; active: boolean; disabled_at: string | null } + const w = webhook as W + + if (!w.active || w.disabled_at) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'active', message: 'Webhook is disabled — re-enable before sending a test event.' }, + }) + } + + // Data minimisation (Art.25(2)): the test payload deliberately omits + // any internal identifier that has no value to the receiver. The + // X-Gnubok-Delivery header on the outbound request already correlates + // to the audit trail on the gnubok side. + const payload = { + hello: 'from gnubok', + tested_at: new Date().toISOString(), + } + + const { data: delivery, error: insertErr } = await ctx.supabase + .from('webhook_deliveries') + .insert({ + webhook_id: w.id, + company_id: ctx.companyId!, + event_type: 'webhook.test', + payload, + api_version: w.api_version_pinned, + // BFNAR 2013:2 kap 8 § behandlingshistorik: link the delivery row + // back to the originating API request for audit-trail correlation. + request_id: ctx.requestId, + }) + .select('id') + .single() + + if (insertErr || !delivery) { + return v1ErrorResponse(insertErr ?? new Error('insert returned no row'), ctx.log, { + requestId: ctx.requestId, + }) + } + + return ok( + { webhook_delivery_id: (delivery as { id: string }).id, status: 'pending' as const }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/webhooks/route.ts b/app/api/v1/companies/[companyId]/webhooks/route.ts new file mode 100644 index 00000000..8d62e944 --- /dev/null +++ b/app/api/v1/companies/[companyId]/webhooks/route.ts @@ -0,0 +1,327 @@ +/** + * /api/v1/companies/{companyId}/webhooks — list + create webhook subscriptions. + * + * GET — list all webhooks for the company. Secret never exposed. + * POST — create. Returns the secret EXACTLY ONCE in the response. Idempotent + * via Idempotency-Key. Dry-runnable. + * + * Phase 6 PR-1 ships the substrate; subsequent commits within this PR will: + * - Add full registry metadata (description, useWhen, pitfalls, example). + * - Add integration tests under __tests__/. + * - Wire the OpenAPI generator's content-type for the secret-once response. + */ + +import { z } from 'zod' +import { created, ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { generateWebhookSecret } from '@/lib/webhooks/signing' +import { validateWebhookUrl } from '@/lib/webhooks/url-guard' +import { API_V1_VERSION } from '@/lib/api/v1/version' +import { hasScope } from '@/lib/auth/api-keys' + +const WEBHOOK_EVENT_TYPES = z.enum([ + 'invoice.created', + 'invoice.sent', + 'invoice.paid', + 'credit_note.created', + 'customer.created', + 'supplier.created', + 'supplier_invoice.registered', + 'supplier_invoice.approved', + 'supplier_invoice.paid', + 'supplier_invoice.credited', + 'supplier_invoice.uncredited', + 'transaction.categorized', + 'transaction.reconciled', + 'journal_entry.committed', + 'journal_entry.reversed', + 'journal_entry.corrected', + 'period.locked', + 'period.unlocked', + 'period.year_closed', + 'salary_run.created', + 'salary_run.approved', + 'salary_run.booked', + 'agi.generated', + 'document.uploaded', +]) + +const CreateWebhookSchema = z.object({ + event_type: WEBHOOK_EVENT_TYPES, + // Schema-level guard rejects non-https before the SSRF DNS check runs. + // The full safety check (private/loopback/link-local/metadata IP rejection) + // happens at handler time via validateWebhookUrl() because it needs DNS. + webhook_url: z + .string() + .url() + .max(2048) + .refine((u) => u.startsWith('https://'), { + message: 'webhook_url must use https://', + }), + name: z.string().min(1).max(120), + description: z.string().max(500).optional(), +}) + +const WebhookSummary = z.object({ + id: z.string().uuid(), + name: z.string(), + event_type: z.string(), + webhook_url: z.string(), + active: z.boolean(), + api_version_pinned: z.string(), + disabled_at: z.string().nullable(), + disabled_reason: z.string().nullable(), + created_at: z.string(), +}) + +const WebhookCreated = WebhookSummary.extend({ + /** Secret returned EXACTLY ONCE on creation. Never exposed on list/detail. */ + secret: z.string(), + description: z.string().nullable(), +}) + +const WebhooksListResponse = z.object({ + webhooks: z.array(WebhookSummary), +}) + +const WEBHOOK_LIST_COLUMNS = + 'id, name, event_type, webhook_url, active, api_version_pinned, disabled_at, disabled_reason, created_at' + +// ────────────────────────────────────────────────────────────────── +// GET — list webhooks +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'webhooks.list', + method: 'GET', + path: '/api/v1/companies/:companyId/webhooks', + summary: 'List webhook subscriptions for a company.', + description: + 'Returns all webhook subscriptions for the company. The HMAC signing secret is never exposed by this endpoint — it is returned exactly once when the webhook is created.', + useWhen: + 'You need to enumerate the webhook subscriptions an integration has registered, e.g. to build a UI listing or sync state with an external system.', + doNotUseFor: + 'Reading delivery history (use GET /webhooks/{id}/deliveries). Reading the secret (it is unrecoverable after the create response — generate a new webhook if lost).', + pitfalls: [ + 'Disabled webhooks (auto-disabled after HTTP 410, or manually disabled via PATCH) appear in the list with active=false and a disabled_reason.', + ], + example: { + response: { + data: { + webhooks: [ + { + id: 'a8f1…', + name: 'CRM sync', + event_type: 'invoice.paid', + webhook_url: 'https://example.com/hooks/gnubok', + active: true, + api_version_pinned: API_V1_VERSION, + disabled_at: null, + disabled_reason: null, + created_at: '2026-05-15T12:00:00Z', + }, + ], + }, + meta: { request_id: 'req_…', api_version: API_V1_VERSION }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: WebhooksListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'webhooks.list', + async (_request, ctx) => { + const { data, error } = await ctx.supabase + .from('webhooks') + .select(WEBHOOK_LIST_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('created_at', { ascending: false }) + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // Wrap as `{ webhooks: [...] }` to match the registered + // WebhooksListResponse schema and the inline example. Use `ok()` + // (not `paginated()`) — the registered schema is an OBJECT envelope, + // not a top-level list. `paginated()` wraps the value in + // `{ data, meta }` and would surface as `data: [...]` instead of the + // documented `data: { webhooks: [...] }`. Cursor pagination on this + // surface would require a fields-level array under the envelope — + // out of scope for the v1.0 contract since the webhook-count ceiling + // per company is bounded. + return ok({ webhooks: data ?? [] }, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — create webhook +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'webhooks.create', + method: 'POST', + path: '/api/v1/companies/:companyId/webhooks', + summary: 'Register a webhook subscription.', + description: + 'Creates a webhook subscription for one event type. The response includes a freshly generated HMAC signing secret, returned EXACTLY ONCE — store it on the receiver side immediately. The webhook is pinned to the current API version on creation; payload shapes for this webhook will not change until you explicitly upgrade.', + useWhen: + 'You are wiring a downstream integration that needs push notifications instead of polling.', + doNotUseFor: + 'Subscribing to internal MCP telemetry events (mcp.tool_called etc. are not delivered as webhooks). Replacing an existing webhook URL — use PATCH instead.', + pitfalls: [ + 'The secret is returned exactly once. If lost, delete and recreate the webhook.', + 'Delivery is at-least-once with exponential backoff (1m / 5m / 30m / 2h / 12h / 24h / 48h). Receivers MUST be idempotent.', + 'HTTP 410 from your receiver auto-disables the webhook (sets active=false + disabled_reason).', + ], + example: { + request: { + event_type: 'invoice.paid', + webhook_url: 'https://example.com/hooks/gnubok', + name: 'CRM sync', + }, + response: { + data: { + id: 'a8f1…', + name: 'CRM sync', + event_type: 'invoice.paid', + webhook_url: 'https://example.com/hooks/gnubok', + active: true, + api_version_pinned: API_V1_VERSION, + disabled_at: null, + disabled_reason: null, + secret: 'whsec_…', + description: null, + created_at: '2026-05-15T12:00:00Z', + }, + meta: { request_id: 'req_…', api_version: API_V1_VERSION }, + }, + }, + scope: 'webhooks:manage', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateWebhookSchema }, + response: { success: WebhookCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'webhooks.create', + async (request, ctx) => { + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = CreateWebhookSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + // Elevated-scope check for high-sensitivity payloads. Subscribing to + // salary_run.* or agi.generated routes personnummer + lönesummor + + // skatteavdrag to an external receiver — a payroll-grade exposure. + // Require BOTH webhooks:manage AND payroll:read so a key minted only + // for webhook management can't reach the payroll surface. The same + // pattern will extend to other sensitive event families when they + // ship (e.g. document.uploaded with PII payloads). + const PAYROLL_SENSITIVE = /^(salary_run\.|agi\.)/ + if (PAYROLL_SENSITIVE.test(body.event_type) && !hasScope(ctx.scopes, 'payroll:read')) { + return v1ErrorResponseFromCode('INSUFFICIENT_SCOPE', ctx.log, { + requestId: ctx.requestId, + // Art.5(1)(f): don't echo the API key's granted_scopes set back to + // the caller. The required_scope alone tells them what to add; + // surfacing the full grant leaks the key's capability surface + // both to the caller (acceptable) and to any log path that + // captures the error envelope (not acceptable). + details: { + required_scope: 'payroll:read', + reason: `Subscribing to ${body.event_type} requires payroll:read in addition to webhooks:manage.`, + }, + }) + } + + // SSRF guard: resolve hostname, reject private/loopback/link-local/CGNAT/ + // metadata addresses. Runs BEFORE the secret is generated and BEFORE + // dry-run preview so a caller can't probe internal hostnames via repeated + // dry-run calls. Re-checked at dispatch time as defense in depth. + const urlCheck = await validateWebhookUrl(body.webhook_url) + if (!urlCheck.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'webhook_url', reason: urlCheck.reason, message: urlCheck.detail }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + name: body.name, + event_type: body.event_type, + webhook_url: body.webhook_url, + active: true, + api_version_pinned: API_V1_VERSION, + disabled_at: null, + disabled_reason: null, + // Never generate or echo a secret on dry-run. + secret: null, + description: body.description ?? null, + created_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const secret = `whsec_${generateWebhookSecret()}` + + const { data, error } = await ctx.supabase + .from('webhooks') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + name: body.name, + description: body.description ?? null, + event_type: body.event_type, + webhook_url: body.webhook_url, + secret, + api_version_pinned: API_V1_VERSION, + created_by_api_key_id: ctx.apiKeyId ?? null, + active: true, + }) + .select(`${WEBHOOK_LIST_COLUMNS}, description`) + .single() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // Secret returned exactly once. Caller must persist it on the receiver + // side — gnubok will not surface it on any subsequent endpoint. + return created({ ...(data as Record), secret }, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/webhook-deliveries/[id]/retry/route.ts b/app/api/v1/webhook-deliveries/[id]/retry/route.ts new file mode 100644 index 00000000..9198fafa --- /dev/null +++ b/app/api/v1/webhook-deliveries/[id]/retry/route.ts @@ -0,0 +1,203 @@ +/** + * /api/v1/webhook-deliveries/{id}/retry — POST :retry verb. + * + * Re-enqueues a `dead` delivery by INSERTing a fresh row pointing at the + * same payload, NOT by mutating the dead row in place (the immutability + * trigger blocks that). The new row enters `pending` and the dispatcher + * picks it up at next-minute boundary. + * + * Live (`pending` / `in_flight` / `failed`) deliveries cannot be retried + * via this endpoint — the dispatcher already retries failed ones, and the + * other states aren't terminal. Only `dead` (and `delivered`, for callers + * that explicitly want to redeliver a message) qualify. + * + * The route lives outside the /companies/{companyId}/ tree because callers + * referencing a delivery already have its id; nesting under company would + * force the receiver-debugging UI to round-trip company resolution from + * the delivery id. Tenancy is still enforced — the wrapper resolves the + * delivery's company_id via the row and verifies caller membership. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { minimisePayload } from '@/lib/webhooks/handler' +import { validateWebhookUrl } from '@/lib/webhooks/url-guard' + +registerEndpoint({ + operation: 'webhook_deliveries.retry', + method: 'POST', + path: '/api/v1/webhook-deliveries/:id/retry', + summary: 'Retry a webhook delivery.', + description: + 'Re-enqueues a dead (or delivered) delivery as a fresh pending row. The new delivery references the same webhook + payload; the dispatcher picks it up at the next per-minute cron tick. The original row is preserved in the audit log.', + useWhen: + 'After a receiver outage you want to replay deliveries that died, or after fixing a receiver-side bug you want to redeliver a successful one.', + doNotUseFor: + 'Retrying live deliveries (pending / in_flight / failed) — the dispatcher is already managing them.', + pitfalls: [ + 'Retrying a delivered delivery causes the receiver to see the event twice. Receivers MUST be idempotent (check the X-Gnubok-Delivery header).', + ], + example: { + response: { + data: { + webhook_delivery_id: 'wh_dlv_NEW', + status: 'pending', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + // Special-case scope: this endpoint lives outside the companies/ tree + // but still belongs to the webhooks domain. Add to lib/auth/scopes.ts in + // the same commit as the v1 wiring. + scope: 'webhooks:manage', + risk: 'medium', + idempotent: false, + reversible: false, + dryRunSupported: false, + response: { + success: z.object({ + webhook_delivery_id: z.string().uuid(), + status: z.literal('pending'), + }), + }, +}) + +export const POST = withApiV1<{ params: Promise<{ id: string }> }>( + 'webhook_deliveries.retry', + async (_request, ctx, params) => { + const { id } = await params.params + + // Fetch the original delivery and its company to enforce tenancy. + const { data: original, error: lookupErr } = await ctx.supabase + .from('webhook_deliveries') + .select('id, webhook_id, company_id, event_type, payload, previous_attributes, api_version, status') + .eq('id', id) + .maybeSingle() + + if (lookupErr) return v1ErrorResponse(lookupErr, ctx.log, { requestId: ctx.requestId }) + if (!original) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + + type O = { + id: string + webhook_id: string + company_id: string + event_type: string + payload: Record + previous_attributes: Record | null + api_version: string + status: 'pending' | 'in_flight' | 'delivered' | 'failed' | 'dead' + } + const o = original as O + + // Tenancy check — the wrapper does not have a companyId from the URL + // here (deliberate; see file header). Verify the caller is a member of + // the delivery's company. + const { data: membership, error: membershipErr } = await ctx.supabase + .from('company_members') + .select('company_id') + .eq('user_id', ctx.userId) + .eq('company_id', o.company_id) + .maybeSingle() + + if (membershipErr) return v1ErrorResponse(membershipErr, ctx.log, { requestId: ctx.requestId }) + if (!membership) { + // 404 (not 403) so we don't leak existence of the delivery to a + // non-member; matches the wrapper's standard pattern. + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + if (o.status !== 'dead' && o.status !== 'delivered') { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'status', + message: `Only dead or delivered deliveries can be retried (current: ${o.status}).`, + }, + }) + } + + // Re-verify that the parent webhook still exists, still belongs to the + // delivery's company, and is still active immediately before INSERT. + // Closes the TOCTOU window between the membership check above and the + // INSERT — without this a webhook deleted in between would have its + // retry land in webhook_deliveries with a now-dangling webhook_id, and + // a webhook re-registered to a different company in between would let + // the caller redeliver an event to a webhook they never created. + const { data: webhook, error: webhookErr } = await ctx.supabase + .from('webhooks') + .select('id, webhook_url, active, disabled_at') + .eq('id', o.webhook_id) + .eq('company_id', o.company_id) + .maybeSingle() + + if (webhookErr) return v1ErrorResponse(webhookErr, ctx.log, { requestId: ctx.requestId }) + if (!webhook) { + // The original webhook no longer exists or is no longer in this + // company. There's nothing to redeliver to. 404, not VALIDATION_ERROR + // — the resource the caller targeted is genuinely gone. + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + const w = webhook as { id: string; webhook_url: string; active: boolean; disabled_at: string | null } + if (!w.active || w.disabled_at) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'webhook.active', message: 'Webhook is disabled — re-enable before retrying.' }, + }) + } + + // Re-run the SSRF guard against the webhook's CURRENT url. The URL + // may have changed via PATCH between the original delivery and this + // retry call. The dispatch-time guard would catch a malicious URL + // eventually, but allowing the INSERT first means a poisoned row + // sits in the queue until the next cron tick. Validating here closes + // the window — the retry refuses up-front and the audit trail gets + // a clean VALIDATION_ERROR rather than a deferred dispatch-time + // 'dead' row with reason='url_unsafe'. + const urlCheck = await validateWebhookUrl(w.webhook_url) + if (!urlCheck.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'webhook.webhook_url', reason: urlCheck.reason, message: urlCheck.detail }, + }) + } + + // Re-run the data-minimisation projection on the original payload + // before re-enqueueing. If the original delivery predates a + // minimisePayload tightening (e.g. a future projection drops more + // fields), the retry must not silently re-deliver the unminimised + // shape. Idempotent on already-minimised payloads. + const minimised = minimisePayload(o.payload) + + const { data: replay, error: insertErr } = await ctx.supabase + .from('webhook_deliveries') + .insert({ + webhook_id: o.webhook_id, + company_id: o.company_id, + event_type: o.event_type, + payload: minimised, + previous_attributes: o.previous_attributes, + api_version: o.api_version, + // Link the retry to the API request that triggered it. The + // original delivery's request_id is preserved on its own audit + // row; the retry gets a fresh correlation pointing at the + // :retry call. + request_id: ctx.requestId, + }) + .select('id') + .single() + + if (insertErr || !replay) { + return v1ErrorResponse(insertErr ?? new Error('insert returned no row'), ctx.log, { + requestId: ctx.requestId, + }) + } + + return ok( + { webhook_delivery_id: (replay as { id: string }).id, status: 'pending' as const }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/webhooks/dispatch/cron/route.ts b/app/api/webhooks/dispatch/cron/route.ts new file mode 100644 index 00000000..1ca42e7f --- /dev/null +++ b/app/api/webhooks/dispatch/cron/route.ts @@ -0,0 +1,32 @@ +/** + * GET /api/webhooks/dispatch/cron — per-minute webhook delivery dispatcher. + * + * Picks up due deliveries (pending or retry-due failed) and POSTs them to + * their configured receivers. Each cycle handles up to 50 deliveries; with + * the per-minute cadence this gives 3000/h headroom before deliveries start + * to backlog. Bumps to a higher batch size or moves to a queue worker + * (Vercel Queues, on the post-Phase-6 roadmap) are the migration path. + * + * Authenticated via CRON_SECRET (Authorization: Bearer ...). The route + * returns the dispatch summary in the response body so an operator can grep + * Vercel logs to see how many succeeded / failed / went dead per tick. + */ + +import { NextResponse } from 'next/server' +import { withCronContext } from '@/lib/api/with-cron-context' +import { dispatchDueDeliveries } from '@/lib/webhooks/dispatcher' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' + +export const GET = withCronContext('cron.webhook_dispatch', async (_request, ctx) => { + const supabase = createServiceClientNoCookies() + const summary = await dispatchDueDeliveries({ supabase }) + + ctx.log.info('webhook dispatch cycle complete', { + picked: summary.picked, + delivered: summary.delivered, + failed: summary.failed, + dead: summary.dead, + }) + + return NextResponse.json({ data: summary }) +}) diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index ca0d4794..bc50b673 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -181,12 +181,15 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write', 'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write', - // Webhooks (Phase 6 — placeholder so the catalogue is complete) + // Webhooks (Phase 6 PR-1) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', 'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage', 'GET /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage', 'PATCH /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage', 'DELETE /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage', + 'POST /api/v1/companies/:companyId/webhooks/:id/test': 'webhooks:manage', + 'GET /api/v1/companies/:companyId/webhooks/:id/deliveries': 'webhooks:manage', + 'POST /api/v1/webhook-deliveries/:id/retry': 'webhooks:manage', } interface CompiledRoute { diff --git a/lib/init.ts b/lib/init.ts index 05ecffc2..6fc6eb11 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -3,6 +3,7 @@ import { setContextFactory } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler' +import { registerWebhookHandler } from '@/lib/webhooks/handler' import { createLogger } from '@/lib/logger' const log = createLogger('init') @@ -64,6 +65,7 @@ export function ensureInitialized(): void { setContextFactory(createExtensionContext) registerSupplierInvoiceHandler() registerEventLogHandler() + registerWebhookHandler() loadExtensions() initialized = true diff --git a/lib/webhooks/diff.ts b/lib/webhooks/diff.ts new file mode 100644 index 00000000..41fa62ad --- /dev/null +++ b/lib/webhooks/diff.ts @@ -0,0 +1,49 @@ +/** + * Compute previous_attributes for update-style webhook events. + * + * Stripe pattern: when an entity changes, the webhook payload carries the + * NEW state in `data.object` and a `previous_attributes` field that holds + * ONLY the fields whose values changed, with their PRIOR values. This lets + * receivers diff without an extra GET round-trip. + * + * We compute this from a (priorRow, currentRow) pair captured by the route + * handler before/after its mutation. A field is considered "changed" if + * the JSON-serialised values differ. + * + * Phase 6 PR-1 only emits this for events that fundamentally describe + * mutations of an existing resource (invoice.paid, supplier_invoice.paid, + * supplier_invoice.approved, period.locked, period.unlocked, + * period.year_closed, salary_run.approved, salary_run.booked, ...). Pure + * "created" events leave previous_attributes null. + */ + +export function computePreviousAttributes>( + prior: T | null | undefined, + current: T | null | undefined, +): Record | null { + if (!prior || !current) return null + const diff: Record = {} + // Iterate over the union of keys so a removed field is also surfaced. + const keys = new Set([...Object.keys(prior), ...Object.keys(current)]) + for (const k of keys) { + const a = prior[k] + const b = current[k] + if (!shallowEquals(a, b)) { + diff[k] = a + } + } + return Object.keys(diff).length > 0 ? diff : null +} + +function shallowEquals(a: unknown, b: unknown): boolean { + if (a === b) return true + if (a === null || b === null || a === undefined || b === undefined) return false + // Cheap structural check via JSON; sufficient for the row-shaped objects + // we diff. Field order is stable because both sides are projected from + // the same SELECT. + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return false + } +} diff --git a/lib/webhooks/dispatcher.ts b/lib/webhooks/dispatcher.ts new file mode 100644 index 00000000..9c61081c --- /dev/null +++ b/lib/webhooks/dispatcher.ts @@ -0,0 +1,645 @@ +/** + * Webhook delivery dispatcher. + * + * Invoked from the per-minute cron at /api/webhooks/dispatch/cron. Picks up + * pending + retry-due deliveries (FOR UPDATE SKIP LOCKED so multiple cron + * invocations don't double-deliver), POSTs each one with HMAC signature, + * and updates the row to one of: + * + * - delivered (2xx response) — terminal + * - failed (5xx / network / 4xx — non-terminal until attempts + * other than 410) exhausted; bumps next_attempt_at + * by exponential backoff + * - dead (HTTP 410 OR — terminal + * attempts exhausted) + * + * The receiver is expected to respond within 10 seconds; we time out + * aggressively so a slow receiver doesn't block the per-minute cron. + * + * On HTTP 410 we additionally disable the webhook (sets disabled_at + + * disabled_reason='HTTP 410 from receiver') so future events don't even + * enqueue against it. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { signPayload } from './signing' +import { validateWebhookUrl } from './url-guard' +import { createLogger } from '@/lib/logger' + +const log = createLogger('webhooks/dispatcher') + +/** 7 retries over ~72h. Index = attempts BEFORE this one. */ +const RETRY_BACKOFF_SECONDS: ReadonlyArray = [ + 60, // 1m — first retry + 5 * 60, // 5m + 30 * 60, // 30m + 2 * 60 * 60, // 2h + 12 * 60 * 60, // 12h + 24 * 60 * 60, // 24h + 48 * 60 * 60, // 48h — final retry +] + +const MAX_ATTEMPTS = RETRY_BACKOFF_SECONDS.length + 1 // initial + 7 retries = 8 total +const REQUEST_TIMEOUT_MS = 10_000 +const MAX_RESPONSE_BODY_BYTES = 4096 + +interface DueDelivery { + id: string + webhook_id: string + company_id: string + event_type: string + payload: Record + previous_attributes: Record | null + api_version: string + attempts: number +} + +interface WebhookForDelivery { + id: string + company_id: string + webhook_url: string + secret: string +} + +export interface DispatchSummary { + picked: number + delivered: number + failed: number + dead: number +} + +/** + * Run one dispatch cycle. Picks up to `batchSize` due deliveries and + * processes them sequentially (the per-minute cadence + small batch size + * makes parallelism unnecessary; in-process serial is also gentler on the + * receiver if many events fan out to the same URL). + */ +export async function dispatchDueDeliveries(args: { + supabase: SupabaseClient + /** Max rows to claim per cron tick. Default 50. */ + batchSize?: number + /** Override for tests. */ + now?: Date + /** Override for tests; injected fetch implementation. */ + fetchImpl?: typeof fetch +}): Promise { + const batchSize = args.batchSize ?? 50 + const now = args.now ?? new Date() + const fetchImpl = args.fetchImpl ?? fetch + + const summary: DispatchSummary = { picked: 0, delivered: 0, failed: 0, dead: 0 } + + // Recover stuck in_flight rows: a previous tick that was killed mid-flight + // (Vercel function timeout, hard crash, manual termination) leaves rows + // marked in_flight forever otherwise. Sweep them back to 'failed' so the + // retry loop picks them up at next_attempt_at. + // + // Threshold = 2× REQUEST_TIMEOUT_MS. A live attempt takes at most + // REQUEST_TIMEOUT_MS plus the body read; doubling that gives an + // unambiguous "this is stuck, not in-flight" boundary. + await recoverStuckInFlight(args.supabase, now) + + const due = await claimDueDeliveries(args.supabase, batchSize, now) + summary.picked = due.length + if (due.length === 0) return summary + + // Dedupe webhook lookups within a single cycle. + const webhookIds = Array.from(new Set(due.map((d) => d.webhook_id))) + const webhookMap = await loadWebhooksByIds(args.supabase, webhookIds) + + for (const delivery of due) { + const webhook = webhookMap.get(delivery.webhook_id) + if (!webhook) { + // The webhook was deleted between enqueue and dispatch. Mark dead; + // there's no receiver to deliver to. The webhook_deliveries.webhook_id + // FK is ON DELETE SET NULL (migration 20260515170000), so the row + // stays in the audit trail under status='dead'. + await markDead(args.supabase, delivery.id, 'webhook_deleted') + summary.dead++ + continue + } + + // Defense-in-depth tenancy check: the webhook the delivery row points + // at MUST belong to the same company as the delivery row. Mismatch + // indicates a poisoned row — refuse to dispatch (which would sign with + // the wrong tenant's secret and POST to the wrong receiver). + if (webhook.company_id !== delivery.company_id) { + log.error('cross-tenant delivery refused', new Error('company_id mismatch'), { + deliveryId: delivery.id, + deliveryCompanyId: delivery.company_id, + webhookId: webhook.id, + webhookCompanyId: webhook.company_id, + }) + await markDead(args.supabase, delivery.id, 'cross_tenant_mismatch') + summary.dead++ + continue + } + + const outcome = await attemptDelivery({ + delivery, + webhook, + fetchImpl, + now, + }) + + // Structured per-delivery outcome log. Keeps companyId / webhookId / + // deliveryId available in log aggregation for per-tenant audit-trail + // reconstruction without grepping through individual mark*-helper + // writes (V16 — security event correlation). + const logCtx = { + deliveryId: delivery.id, + webhookId: webhook.id, + companyId: delivery.company_id, + eventType: delivery.event_type, + attempt: delivery.attempts + 1, + } + + switch (outcome.kind) { + case 'delivered': + await markDelivered(args.supabase, delivery.id, outcome) + log.info('delivery succeeded', { ...logCtx, responseStatus: outcome.responseStatus }) + summary.delivered++ + break + case 'dead': + await markDead(args.supabase, delivery.id, outcome.reason, outcome) + log.warn('delivery dead', { ...logCtx, reason: outcome.reason, responseStatus: outcome.responseStatus }) + summary.dead++ + if (outcome.disableWebhook) { + await disableWebhook(args.supabase, webhook.id, outcome.reason) + log.warn('webhook auto-disabled', { ...logCtx, reason: outcome.reason }) + } + break + case 'failed': + if (delivery.attempts + 1 >= MAX_ATTEMPTS) { + await markDead(args.supabase, delivery.id, 'attempts_exhausted', outcome) + log.warn('delivery dead — attempts exhausted', { ...logCtx, lastError: outcome.error }) + summary.dead++ + } else { + await markFailedForRetry(args.supabase, delivery.id, delivery.attempts, outcome, now) + log.info('delivery failed — retry scheduled', { ...logCtx, error: outcome.error, responseStatus: outcome.responseStatus }) + summary.failed++ + } + break + } + } + + return summary +} + +// ────────────────────────────────────────────────────────────────────── +// DB ops +// ────────────────────────────────────────────────────────────────────── + +/** + * Mark in_flight rows whose updated_at is older than the stuck-threshold + * back to 'failed' with next_attempt_at = now so they re-enter the + * dispatch queue. Best-effort — a write failure here is logged but + * doesn't block the rest of the cycle. + */ +async function recoverStuckInFlight(supabase: SupabaseClient, now: Date): Promise { + const stuckBefore = new Date(now.getTime() - 2 * REQUEST_TIMEOUT_MS) + // The status='in_flight' filter alone is not sufficient — a row could + // race between this SELECT and the UPDATE and reach 'delivered' or + // 'dead' in the interim. Postgres applies the status filter to the + // CURRENT (post-race) state, so the row would slip through and the + // immutability trigger would raise check_violation, aborting the + // entire bulk UPDATE and leaving legitimately stuck rows unrecovered. + // + // Defense-in-depth: explicitly exclude terminal status values. The + // partial guard makes a successful sweep on a mixed batch safe even + // when one row terminalized mid-flight. + const { data, error } = await supabase + .from('webhook_deliveries') + .update({ + status: 'failed', + next_attempt_at: now.toISOString(), + error: 'recovered_from_in_flight_timeout', + }) + .eq('status', 'in_flight') + .not('status', 'in', '(delivered,dead)') + .lt('updated_at', stuckBefore.toISOString()) + .select('id') + + if (error) { + log.warn('stuck in_flight recovery failed', { code: error.code }) + return + } + if (data && data.length > 0) { + log.warn('recovered stuck in_flight rows', { count: data.length }) + } +} + +async function claimDueDeliveries( + supabase: SupabaseClient, + batchSize: number, + now: Date, +): Promise { + // PostgREST cannot express FOR UPDATE SKIP LOCKED through the JS client. + // The cleaner long-term shape is a SQL claim function — tracked for a + // follow-up commit. Until then we SELECT candidate rows, then UPDATE + // with a CAS guard and `.select('id')` to learn which rows the UPDATE + // actually claimed. The dispatch loop runs ONLY against the intersection + // of (selected, claimed) — so an overlapping cron tick that picked up + // the same SELECT can never double-deliver: at most one tick wins the + // CAS update for any given row. + // + // Per-minute Vercel cron has best-effort single-instance semantics, but + // the documented contract is "at-least-once" not "at-most-once" — under + // load (e.g. a 50-row batch with mostly slow receivers > 60s) the next + // tick can fire while this one is still running, so the CAS-then- + // intersect pattern is load-bearing, not defensive. + const { data, error } = await supabase + .from('webhook_deliveries') + .select('id, webhook_id, company_id, event_type, payload, previous_attributes, api_version, attempts') + .in('status', ['pending', 'failed']) + .lte('next_attempt_at', now.toISOString()) + // Skip dangling rows (webhook deleted between enqueue and dispatch). + // The webhook_deliveries.webhook_id FK is ON DELETE SET NULL + // (migration 20260515170000) so terminal rows survive webhook deletion + // for BFNAR 2013:2 kap 8 § audit retention; non-terminal rows for a + // deleted webhook have no receiver to deliver to and stay dormant in + // the audit trail. + .not('webhook_id', 'is', null) + .order('next_attempt_at', { ascending: true }) + .limit(batchSize) + + if (error || !data) { + log.error('claim due deliveries failed', error as Error) + return [] + } + if (data.length === 0) return [] + + const candidates = data as DueDelivery[] + const candidateIds = candidates.map((d) => d.id) + + const { data: claimed, error: updateErr } = await supabase + .from('webhook_deliveries') + .update({ status: 'in_flight' }) + .in('id', candidateIds) + .in('status', ['pending', 'failed']) // CAS guard + .select('id') + + if (updateErr) { + log.error('claim deliveries update failed', updateErr as Error) + return [] + } + + // Trust the UPDATE's returned set as authoritative — anything not in + // `claimed` was lost to a competing tick (or had its status flipped + // out from under us between SELECT and UPDATE). + const claimedIds = new Set(((claimed ?? []) as { id: string }[]).map((r) => r.id)) + return candidates.filter((d) => claimedIds.has(d.id)) +} + +async function loadWebhooksByIds( + supabase: SupabaseClient, + ids: string[], +): Promise> { + // Include company_id so the dispatch loop can assert that the delivery + // row's company_id matches the webhook's — defense in depth against a + // poisoned delivery row pointing at another tenant's webhook + // (compromised service-role path, faulty INSERT in a future code path, + // etc.). The DB trigger added in 20260515190000 enforces the same + // invariant at INSERT time; this is the application-layer mirror. + const { data, error } = await supabase + .from('webhooks') + .select('id, company_id, webhook_url, secret') + .in('id', ids) + + if (error || !data) { + log.error('webhook lookup for dispatch failed', error as Error) + return new Map() + } + return new Map((data as WebhookForDelivery[]).map((w) => [w.id, w])) +} + +async function markDelivered( + supabase: SupabaseClient, + id: string, + outcome: DeliveredOutcome, +): Promise { + const { error } = await supabase + .from('webhook_deliveries') + .update({ + status: 'delivered', + delivered_at: new Date().toISOString(), + attempts: outcome.attempts, + response_status: outcome.responseStatus, + response_body: outcome.responseBody, + response_headers: outcome.responseHeaders, + error: null, + }) + .eq('id', id) + if (error) log.warn('mark delivered update failed', { id, code: error.code }) +} + +async function markFailedForRetry( + supabase: SupabaseClient, + id: string, + priorAttempts: number, + outcome: FailedOutcome, + now: Date, +): Promise { + const nextAttemptIndex = priorAttempts // 0-indexed lookup into RETRY_BACKOFF_SECONDS + const backoffSeconds = RETRY_BACKOFF_SECONDS[Math.min(nextAttemptIndex, RETRY_BACKOFF_SECONDS.length - 1)] + const nextAttemptAt = new Date(now.getTime() + backoffSeconds * 1000) + + const { error } = await supabase + .from('webhook_deliveries') + .update({ + status: 'failed', + attempts: priorAttempts + 1, + next_attempt_at: nextAttemptAt.toISOString(), + response_status: outcome.responseStatus ?? null, + response_body: outcome.responseBody ?? null, + response_headers: outcome.responseHeaders ?? null, + error: outcome.error, + }) + .eq('id', id) + if (error) log.warn('mark failed-for-retry update failed', { id, code: error.code }) +} + +async function markDead( + supabase: SupabaseClient, + id: string, + reason: string, + outcome?: AttemptOutcome, +): Promise { + // delivered_at means "the receiver acknowledged the event". For dead + // rows (HTTP 410, attempts exhausted, webhook deleted, cross-tenant + // mismatch, unsafe URL) the receiver did NOT acknowledge — leaving + // delivered_at NULL keeps the audit semantics clean. An auditor + // querying `WHERE delivered_at IS NOT NULL` correctly sees only + // genuinely delivered rows. The terminal-state timestamp lives on + // `updated_at` (auto-stamped by the table's BEFORE UPDATE trigger). + const { error } = await supabase + .from('webhook_deliveries') + .update({ + status: 'dead', + attempts: outcome && 'attempts' in outcome ? outcome.attempts : undefined, + response_status: outcome && 'responseStatus' in outcome ? outcome.responseStatus : null, + response_body: outcome && 'responseBody' in outcome ? outcome.responseBody : null, + response_headers: outcome && 'responseHeaders' in outcome ? outcome.responseHeaders : null, + error: reason, + }) + .eq('id', id) + if (error) log.warn('mark dead update failed', { id, code: error.code }) +} + +async function disableWebhook( + supabase: SupabaseClient, + webhookId: string, + reason: string, +): Promise { + const { error } = await supabase + .from('webhooks') + .update({ + disabled_at: new Date().toISOString(), + disabled_reason: reason, + active: false, + }) + .eq('id', webhookId) + if (error) log.warn('webhook auto-disable failed', { webhookId, code: error.code }) +} + +// ────────────────────────────────────────────────────────────────────── +// HTTP attempt +// ────────────────────────────────────────────────────────────────────── + +type DeliveredOutcome = { + kind: 'delivered' + attempts: number + responseStatus: number + responseBody: string | null + responseHeaders: Record | null +} + +type FailedOutcome = { + kind: 'failed' + attempts: number + responseStatus: number | null + responseBody: string | null + responseHeaders: Record | null + error: string +} + +type DeadOutcome = { + kind: 'dead' + reason: string + disableWebhook: boolean + attempts: number + responseStatus: number | null + responseBody: string | null + responseHeaders: Record | null + error?: string +} + +type AttemptOutcome = DeliveredOutcome | FailedOutcome | DeadOutcome + +async function attemptDelivery(args: { + delivery: DueDelivery + webhook: WebhookForDelivery + fetchImpl: typeof fetch + now: Date +}): Promise { + const { delivery, webhook, fetchImpl, now } = args + const attempts = delivery.attempts + 1 + const requestId = `whdel_${delivery.id}` + + const body = JSON.stringify({ + id: delivery.id, + type: delivery.event_type, + api_version: delivery.api_version, + created: Math.floor(now.getTime() / 1000), + data: { object: delivery.payload }, + previous_attributes: delivery.previous_attributes, + }) + + // Re-validate the URL at dispatch time as defense in depth — DNS records + // can change between webhook creation and dispatch (DNS rebinding, + // hijack, A-record swap to internal IP), so the create-time check alone + // is insufficient. A failure here marks the delivery dead with a + // distinct reason so the operator can investigate without thinking it's + // a transient receiver issue. + const urlCheck = await validateWebhookUrl(webhook.webhook_url) + if (!urlCheck.ok) { + return { + kind: 'dead', + reason: `url_unsafe:${urlCheck.reason}`, + disableWebhook: true, + attempts, + responseStatus: null, + responseBody: null, + responseHeaders: null, + error: urlCheck.detail, + } + } + + const { header } = signPayload({ body, secret: webhook.secret, timestamp: Math.floor(now.getTime() / 1000) }) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + let response: Response + try { + response = await fetchImpl(webhook.webhook_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gnubok-Signature': header, + 'X-Gnubok-Event': delivery.event_type, + 'X-Gnubok-Delivery': delivery.id, + 'X-Gnubok-Api-Version': delivery.api_version, + 'X-Request-Id': requestId, + 'User-Agent': 'gnubok-webhook/1', + }, + body, + signal: controller.signal, + // Reject 3xx responses entirely. Following a redirect would let a + // receiver bounce the dispatcher to a private/internal address + // AFTER the SSRF guard (which validated the original webhook_url's + // hostname) has cleared. Receivers that legitimately move endpoints + // should ask integrators to update the webhook URL. + redirect: 'error', + }) + } catch (err) { + clearTimeout(timeout) + const message = err instanceof Error ? err.message : String(err) + + // Distinguish redirect-rejection errors from generic transport + // failures. With redirect: 'error' the runtime fetch throws when the + // receiver returns 3xx — that's an SSRF-bypass attempt (or a + // misconfigured receiver), not a transient failure. Treating it as + // 'failed' would burn 8 retry attempts over ~72h before going dead. + // Mirror the HTTP 410 treatment: terminal + auto-disable so the + // operator surfaces the misbehaving receiver immediately. + // + // Node's undici (the runtime fetch) raises 'unexpected redirect' + // / 'redirect mode is set to error' messages; check both shapes + // since the exact wording has changed across Node versions. + const isRedirectError = /redirect/i.test(message) + if (isRedirectError) { + return { + kind: 'dead', + reason: 'redirect_blocked', + disableWebhook: true, + attempts, + responseStatus: null, + responseBody: null, + responseHeaders: null, + error: message.length > 500 ? `${message.slice(0, 497)}...` : message, + } + } + + return { + kind: 'failed', + attempts, + responseStatus: null, + responseBody: null, + responseHeaders: null, + error: message.length > 500 ? `${message.slice(0, 497)}...` : message, + } + } + + // Keep the abort timeout armed across the body read — a slow body + // stream can stall the entire dispatch batch otherwise. Clear only + // after readBoundedText returns (or aborts). + let responseBody: string | null + try { + responseBody = await readBoundedText(response) + } finally { + clearTimeout(timeout) + } + const responseHeaders = headersToObject(response.headers) + + // HTTP 410 — receiver explicitly asks us to stop. Auto-disable the + // webhook + mark this delivery dead. + if (response.status === 410) { + return { + kind: 'dead', + reason: 'http_410_gone', + disableWebhook: true, + attempts, + responseStatus: 410, + responseBody, + responseHeaders, + } + } + + if (response.status >= 200 && response.status < 300) { + return { + kind: 'delivered', + attempts, + responseStatus: response.status, + responseBody, + responseHeaders, + } + } + + return { + kind: 'failed', + attempts, + responseStatus: response.status, + responseBody, + responseHeaders, + error: `HTTP ${response.status}`, + } +} + +// Content-Type prefixes for which we persist response_body verbatim. Other +// types (text/html error pages, application/octet-stream, ...) get dropped +// because they routinely echo PII back from receiver-side error renderers +// (Art.32(1)(b), A.8.12). A null body is just as useful for debugging +// when the operator can see the response_status and response_headers. +const SAFE_BODY_CONTENT_TYPE_PREFIXES = ['text/plain', 'application/json'] + +async function readBoundedText(response: Response): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + const isSafe = SAFE_BODY_CONTENT_TYPE_PREFIXES.some((p) => contentType.startsWith(p)) + if (!isSafe) { + // Drain the body so the connection can be reused, but discard the bytes. + try { await response.text() } catch { /* ignore */ } + return null + } + try { + const text = await response.text() + if (text.length <= MAX_RESPONSE_BODY_BYTES) return text + return text.slice(0, MAX_RESPONSE_BODY_BYTES) + } catch { + return null + } +} + +// Allowlist for response_headers persistence. Receiver-side headers like +// Set-Cookie, Authorization, WWW-Authenticate, internal tracing, and +// vendor x-* headers can carry credentials or sensitive identifiers; we +// don't need them for delivery diagnostics. (CC7.2 / Art.32(1)(b)) +// +// 'server' is deliberately NOT in the allowlist (A.8.12): it carries no +// diagnostic value but routinely leaks receiver infrastructure version +// strings (nginx/1.21.6, Apache/2.4.41, ...) into a multi-tenant audit +// table. +const SAFE_RESPONSE_HEADERS = new Set([ + 'content-type', + 'content-length', + 'date', + 'x-request-id', + 'cf-ray', +]) + +function headersToObject(headers: Headers): Record { + const obj: Record = {} + headers.forEach((v, k) => { + if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) { + obj[k] = v + } + }) + return obj +} + +export const __TESTING__ = { + RETRY_BACKOFF_SECONDS, + MAX_ATTEMPTS, + REQUEST_TIMEOUT_MS, + MAX_RESPONSE_BODY_BYTES, +} diff --git a/lib/webhooks/handler.ts b/lib/webhooks/handler.ts new file mode 100644 index 00000000..321538c3 --- /dev/null +++ b/lib/webhooks/handler.ts @@ -0,0 +1,188 @@ +/** + * Webhook event-bus handler. + * + * Subscribes to every CoreEventType the v1 API surface emits and converts + * each emission into N rows in `webhook_deliveries` — one per active webhook + * subscribed to (company_id, event_type). The dispatcher cron picks them + * up at next-minute boundary and POSTs to the receiver. + * + * Wired from lib/init.ts via registerWebhookHandler() so every API route + * that calls ensureInitialized() gets the subscription wired exactly once. + * + * Design notes: + * - We do NOT block the emitting route on delivery insert: the handler + * runs inside Promise.allSettled in the bus (see lib/events/bus.ts), so + * a DB insert failure is logged but doesn't crash the emitter. + * - We capture `previous_attributes` only for events whose payload carries + * both a prior and current shape. Phase 6 PR-1 emits null for everything + * — adding the diff is a follow-up that requires touching each route's + * emit() call site to capture the prior row. + * - Service-role client because this code runs from the bus, outside any + * authenticated Supabase context. + */ + +import { eventBus } from '@/lib/events/bus' +import type { CoreEventType } from '@/lib/events/types' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { createLogger } from '@/lib/logger' +import { API_V1_VERSION } from '@/lib/api/v1/version' + +const log = createLogger('webhooks/handler') + +/** + * Set of event types that the v1 webhook surface delivers. Restricted to the + * resource-state-change events that are useful to external integrations; + * MCP telemetry events and internal-only flows (event_log writes, etc.) are + * deliberately excluded. + * + * Adding a new event type to this set is a public-API change — bump + * API_V1_VERSION + add to the changelog when you do. + */ +const PUBLIC_WEBHOOK_EVENTS = new Set([ + 'invoice.created', + 'invoice.sent', + 'invoice.paid', + 'credit_note.created', + 'customer.created', + 'supplier.created', + 'supplier_invoice.registered', + 'supplier_invoice.approved', + 'supplier_invoice.paid', + 'supplier_invoice.credited', + 'supplier_invoice.uncredited', + 'transaction.categorized', + 'transaction.reconciled', + 'journal_entry.committed', + 'journal_entry.reversed', + 'journal_entry.corrected', + 'period.locked', + 'period.unlocked', + 'period.year_closed', + 'salary_run.created', + 'salary_run.approved', + 'salary_run.booked', + 'agi.generated', + 'document.uploaded', +]) + +let registered = false + +/** + * Subscribe the webhook handler to every event in PUBLIC_WEBHOOK_EVENTS. + * Idempotent — safe to call from ensureInitialized() across hot reloads. + */ +export function registerWebhookHandler(): void { + if (registered) return + registered = true + + for (const eventType of PUBLIC_WEBHOOK_EVENTS) { + eventBus.on(eventType, async (payload) => { + // payload type depends on eventType but every variant carries + // companyId — the only field we structurally need here. + const companyId = (payload as { companyId?: string }).companyId + if (!companyId) { + // Surface as an error: every CoreEvent payload variant types + // companyId as required, so a missing value indicates an emit-site + // bug that silently breaks webhook delivery for that event. Logging + // at error level ensures it shows up in monitoring rather than + // disappearing into routine warn-noise. + log.error('event missing companyId — webhook fanout skipped', new Error('missing companyId'), { eventType }) + return + } + + try { + await fanOutToWebhooks({ + eventType, + companyId, + payload: minimisePayload(payload as Record), + }) + } catch (err) { + log.error('webhook fanout failed', err as Error, { eventType, companyId }) + } + }) + } + + log.info('webhook handler registered', { eventCount: PUBLIC_WEBHOOK_EVENTS.size }) +} + +/** + * Drop fields from the in-process event payload that have no value to an + * external webhook receiver. Currently strips: + * - userId: an internal Supabase auth.users.id UUID — no value to the + * receiver, identifies the gnubok-side actor not the resource. The + * companyId stays (it's the tenant scope, useful for multi-tenant + * receivers). + * + * Centralising the projection here means a future tightening (e.g. + * stripping personnummer fields from payroll payloads) lands in one + * place rather than per-emit-site. GDPR Art.5(1)(c) data minimisation. + */ +export function minimisePayload(payload: Record): Record { + const projected: Record = {} + for (const [key, value] of Object.entries(payload)) { + if (key === 'userId') continue + projected[key] = value + } + return projected +} + +/** + * Look up active webhooks for (companyId, eventType) and insert one + * webhook_deliveries row per match. Pending rows are picked up by the + * dispatcher cron at next-minute boundary. + */ +async function fanOutToWebhooks(args: { + eventType: string + companyId: string + payload: Record +}): Promise { + const supabase = createServiceClientNoCookies() + + const { data: webhooks, error: fetchErr } = await supabase + .from('webhooks') + .select('id, secret, api_version_pinned') + .eq('company_id', args.companyId) + .eq('event_type', args.eventType) + .eq('active', true) + .is('disabled_at', null) + + if (fetchErr) { + log.error('webhook lookup failed', fetchErr as Error, { + companyId: args.companyId, + eventType: args.eventType, + }) + return + } + if (!webhooks || webhooks.length === 0) return + + // Synthesise a correlation id for the fanout batch. The event bus is + // async — by the time we reach here the originating route's request + // context is gone, so we can't recover the live request_id. A fresh + // 'whfan_' keeps the BFNAR 2013:2 kap 8 § behandlingshistorik + // requirement satisfied (the column is never NULL on a fresh insert) + // and lets a per-fanout audit query group the rows that came from the + // same emission. Threading the originating request_id into the event + // payload itself is a future-direction improvement. + const fanoutId = `whfan_${crypto.randomUUID()}` + + const rows = webhooks.map((w) => ({ + webhook_id: (w as { id: string }).id, + company_id: args.companyId, + event_type: args.eventType, + payload: args.payload, + api_version: (w as { api_version_pinned: string }).api_version_pinned ?? API_V1_VERSION, + // previous_attributes is null in Phase 6 PR-1; populated in a follow-up + // when each route's emit() call captures the prior row. + previous_attributes: null, + request_id: fanoutId, + })) + + const { error: insertErr } = await supabase.from('webhook_deliveries').insert(rows) + if (insertErr) { + log.error('webhook_deliveries insert failed', insertErr as Error, { + companyId: args.companyId, + eventType: args.eventType, + webhookCount: rows.length, + }) + } +} diff --git a/lib/webhooks/signing.ts b/lib/webhooks/signing.ts new file mode 100644 index 00000000..5654a7b3 --- /dev/null +++ b/lib/webhooks/signing.ts @@ -0,0 +1,142 @@ +/** + * Webhook signature generation + verification. + * + * Signature header (Stripe-style): + * X-Gnubok-Signature: t=,v1= + * + * Where the signed payload is: + * `${t}.${rawBody}` + * + * The `t` (unix timestamp in seconds) is included in the signed payload + * so receivers can implement replay-window checks. We default to a 5-minute + * tolerance on the verify side; receivers can pick their own. + * + * Why HMAC-SHA256 (not Ed25519): every Node/Python/Go/Ruby stdlib has it, + * receivers can verify without adding a dep. Asymmetric signing buys nothing + * for outbound webhooks where the receiver has no use for verifying the + * signer's identity beyond "this is the secret you set on creation". + */ + +import crypto from 'crypto' + +const ALGORITHM = 'sha256' + +export interface SignedHeaderParts { + /** Unix seconds. */ + t: number + /** Hex-encoded HMAC-SHA256(t + "." + body, secret). */ + v1: string +} + +/** + * Generate the value of the `X-Gnubok-Signature` header for an outbound + * delivery. + */ +export function signPayload(args: { + body: string + secret: string + /** Override for tests. Defaults to current unix-seconds. */ + timestamp?: number +}): { header: string; parts: SignedHeaderParts } { + const t = args.timestamp ?? Math.floor(Date.now() / 1000) + const v1 = crypto + .createHmac(ALGORITHM, args.secret) + .update(`${t}.${args.body}`) + .digest('hex') + return { + header: `t=${t},v1=${v1}`, + parts: { t, v1 }, + } +} + +/** + * Parse a signature header into its components. Returns null if malformed. + * Used by the receiver-side example in the docs cookbook (Phase 6 PR-2); + * exported here so a single canonical implementation lives in this file. + */ +export function parseSignatureHeader(header: string): SignedHeaderParts | null { + const parts = header.split(',').map((s) => s.trim()) + let t: number | null = null + let v1: string | null = null + for (const p of parts) { + const eq = p.indexOf('=') + if (eq === -1) continue + const k = p.slice(0, eq) + const v = p.slice(eq + 1) + if (k === 't') { + const parsed = Number.parseInt(v, 10) + if (Number.isFinite(parsed)) t = parsed + } else if (k === 'v1') { + v1 = v + } + } + if (t === null || !v1) return null + return { t, v1 } +} + +/** + * Verify a signature against a raw body. Constant-time comparison. + * Returns true if the signature is valid AND within the tolerance window. + * + * Use this in the cookbook examples and in the :test endpoint's loopback + * verification. + */ +export function verifySignature(args: { + body: string + header: string + secret: string + /** Tolerance window in seconds. Defaults to 300 (5 min). */ + toleranceSeconds?: number + /** Override for tests. Defaults to current unix-seconds. */ + now?: number +}): boolean { + const parsed = parseSignatureHeader(args.header) + if (!parsed) return false + + const tolerance = args.toleranceSeconds ?? 300 + const now = args.now ?? Math.floor(Date.now() / 1000) + if (Math.abs(now - parsed.t) > tolerance) return false + + const expected = crypto + .createHmac(ALGORITHM, args.secret) + .update(`${parsed.t}.${args.body}`) + .digest('hex') + + // timingSafeEqual requires equal-length buffers — return false (not throw) + // for length mismatch, the common case for a forged signature. + // + // Compare buffer lengths AFTER decoding rather than hex-string lengths: + // `Buffer.from(v1, 'hex')` silently drops invalid hex bytes, so a v1 that + // is the right hex length (64 chars for SHA-256) but contains non-hex + // characters decodes to a SHORTER buffer than `expected`. Without this + // check the timingSafeEqual call throws RangeError instead of returning + // false, exposing a crash path to any caller passing a malformed header. + const expectedBuf = Buffer.from(expected, 'hex') + const actualBuf = Buffer.from(parsed.v1, 'hex') + if (expectedBuf.length !== actualBuf.length) return false + return crypto.timingSafeEqual(expectedBuf, actualBuf) +} + +/** + * Generate a fresh webhook secret. 32 bytes of crypto-random hex (256 bits + * of entropy, 64-character output). Returned to the caller exactly once on + * webhook creation; we do not store the plaintext anywhere except the + * `webhooks.secret` column (used for signing on every outbound delivery). + * + * **Documented Security Decision (OWASP V14.2 / ISO 27001:2022 A.8.24):** + * `webhooks.secret` is stored in plaintext rather than hashed. This is + * unavoidable for outbound HMAC signing — the signing operation needs the + * original byte sequence on every delivery, so a one-way hash would + * preclude signing. Stripe, GitHub, Slack, and Twilio all follow the same + * pattern for the same reason. Defense-in-depth comes from the + * service-role-only INSERT/UPDATE/DELETE on `webhooks` (no anon/auth + * write path), the column-level select projection on every read endpoint + * (the row never includes `secret` outside the create response), and + * Supabase encryption-at-rest. Re-evaluate if/when KMS-backed signing + * becomes available without per-call latency cost. + * + * Receivers use this same value verbatim when verifying signatures. + */ +export function generateWebhookSecret(): string { + return crypto.randomBytes(32).toString('hex') +} diff --git a/lib/webhooks/url-guard.ts b/lib/webhooks/url-guard.ts new file mode 100644 index 00000000..1c8df121 --- /dev/null +++ b/lib/webhooks/url-guard.ts @@ -0,0 +1,186 @@ +/** + * Webhook URL safety guard. + * + * SSRF mitigation for the dispatcher: a webhook receiver URL is supplied by + * the caller, and the dispatcher POSTs HMAC-signed payloads to it from the + * Vercel function's network position. Without validation, a malicious + * caller could direct the dispatcher at internal addresses (cloud metadata + * endpoints at 169.254.169.254, kube-internal services at 10.x, loopback, + * etc.) and exfiltrate signed payloads or probe internal infrastructure. + * + * Two-layer defense: + * 1. At create / update time the v1 routes call `assertSafeWebhookUrl` + * and reject the request with VALIDATION_ERROR if the URL fails. + * 2. At dispatch time the dispatcher calls the same helper before each + * HTTP request — DNS records can change between creation and + * dispatch (rebind attacks, DNS hijack), so the create-time check + * alone is insufficient. + * + * Errors carry a stable `reason` string so the route can surface a + * structured details object and the dispatcher can stamp it on the + * delivery's error column. + */ + +import { promises as dns } from 'node:dns' + +export type WebhookUrlValidationReason = + | 'invalid_url' + | 'non_https_scheme' + | 'dns_lookup_failed' + | 'no_dns_records' + | 'private_address' + | 'loopback_address' + | 'link_local_address' + | 'cgnat_address' + | 'metadata_address' + +export interface WebhookUrlValidationError { + ok: false + reason: WebhookUrlValidationReason + detail: string +} + +export interface WebhookUrlValidationOk { + ok: true + hostname: string + /** All A/AAAA records resolved at validation time. Every entry is publicly routable. */ + resolvedAddresses: string[] +} + +export type WebhookUrlValidationResult = WebhookUrlValidationOk | WebhookUrlValidationError + +/** + * Validate that the URL is HTTPS and that EVERY A/AAAA record for the + * hostname resolves to a publicly-routable address. Returns a + * discriminated result rather than throwing so call sites can surface a + * clean validation error envelope. + * + * Multi-record enumeration (vs single dns.lookup) closes a round-robin + * DNS bypass: a hostname with two A records [public, private] returns + * either non-deterministically per call. Single-lookup validation could + * return the public IP at create time and the private IP at dispatch + * time. Resolving ALL records and rejecting if ANY is unsafe forecloses + * that path. A separate DNS-rebinding window (between dispatch-time + * validation and the actual fetch) remains; closing that requires a + * custom HTTPS agent that pins the resolved IP — tracked for follow-up. + */ +export async function validateWebhookUrl( + rawUrl: string, + opts?: { resolve4?: typeof dns.resolve4; resolve6?: typeof dns.resolve6 }, +): Promise { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return { ok: false, reason: 'invalid_url', detail: 'URL did not parse.' } + } + + if (parsed.protocol !== 'https:') { + return { + ok: false, + reason: 'non_https_scheme', + detail: `webhook_url must use https:// (got ${parsed.protocol}).`, + } + } + + const resolve4 = opts?.resolve4 ?? dns.resolve4 + const resolve6 = opts?.resolve6 ?? dns.resolve6 + + // Resolve A and AAAA in parallel. Each returns an array of address + // strings or throws ENODATA / ENOTFOUND when there are no records of + // that family. Treat a per-family ENODATA as "no records" rather than + // a hard failure — the other family may still resolve. + const [v4Result, v6Result] = await Promise.allSettled([ + resolve4(parsed.hostname), + resolve6(parsed.hostname), + ]) + + const addresses: string[] = [] + let hardFailure: Error | null = null + for (const r of [v4Result, v6Result]) { + if (r.status === 'fulfilled') { + addresses.push(...r.value) + } else { + const code = (r.reason as { code?: string } | null)?.code + // ENODATA / ENOTFOUND for one family is normal (e.g. v6-only or + // v4-only host). Other errors (server failure, timeout) propagate. + if (code !== 'ENODATA' && code !== 'ENOTFOUND') { + hardFailure = r.reason instanceof Error ? r.reason : new Error(String(r.reason)) + } + } + } + + if (addresses.length === 0) { + return { + ok: false, + reason: hardFailure ? 'dns_lookup_failed' : 'no_dns_records', + detail: hardFailure + ? `DNS lookup failed for ${parsed.hostname}: ${hardFailure.message}` + : `No A/AAAA records for ${parsed.hostname}.`, + } + } + + for (const address of addresses) { + const classification = classifyAddress(address) + if (classification !== 'public') { + return { + ok: false, + reason: classification, + detail: `Resolved address ${address} for ${parsed.hostname} is not publicly routable (${classification}).`, + } + } + } + + return { ok: true, hostname: parsed.hostname, resolvedAddresses: addresses } +} + +type AddressClass = + | 'public' + | 'loopback_address' + | 'private_address' + | 'link_local_address' + | 'cgnat_address' + | 'metadata_address' + +/** + * Map an IPv4 or IPv6 address string to a safety class. Returns 'public' + * only when the address falls outside every known unsafe range we care + * about for SSRF prevention. + */ +function classifyAddress(address: string): AddressClass { + // IPv4 + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address) + if (v4) { + const o = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10)) + // Cloud metadata endpoint — explicit class so we surface it distinctly. + // 169.254.169.254 is AWS/GCP/Azure/Hetzner; classify before the broader + // 169.254.0.0/16 link-local check. + if (o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) { + return 'metadata_address' + } + if (o[0] === 169 && o[1] === 254) return 'link_local_address' + if (o[0] === 127) return 'loopback_address' + if (o[0] === 10) return 'private_address' + if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return 'private_address' + if (o[0] === 192 && o[1] === 168) return 'private_address' + if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return 'cgnat_address' + // 0.0.0.0/8 — "this network", treat as loopback-equivalent. + if (o[0] === 0) return 'loopback_address' + return 'public' + } + + // IPv6 — minimal classification. Lower-case for case-insensitive match. + const v6 = address.toLowerCase() + if (v6 === '::1' || v6 === '0:0:0:0:0:0:0:1') return 'loopback_address' + if (v6 === '::' || v6 === '0:0:0:0:0:0:0:0') return 'loopback_address' + // ::ffff:0:0/96 — IPv4-mapped IPv6. Re-classify the embedded IPv4. + const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(v6) + if (mapped) return classifyAddress(mapped[1]) + // fc00::/7 — unique local + if (/^f[cd]/.test(v6)) return 'private_address' + // fe80::/10 — link-local + if (/^fe[89ab]/.test(v6)) return 'link_local_address' + return 'public' +} + +export const __TESTING__ = { classifyAddress } diff --git a/supabase/migrations/20260515170000_webhooks_v2.sql b/supabase/migrations/20260515170000_webhooks_v2.sql new file mode 100644 index 00000000..a495dcd0 --- /dev/null +++ b/supabase/migrations/20260515170000_webhooks_v2.sql @@ -0,0 +1,217 @@ +-- Migration: webhooks_v2 +-- +-- Phase 6 PR-1 substrate. Repurposes the legacy `automation_webhooks` table +-- (table existed from the early schema sync but was never wired to a delivery +-- pipeline) into the v1 `webhooks` registration table, and adds a new +-- `webhook_deliveries` queue + audit table that the per-minute dispatcher +-- cron consumes via FOR UPDATE SKIP LOCKED. +-- +-- Design (per .claude/plans/research-analyze-and-create-tranquil-moon.md +-- §"Phase 6 — Webhook hardening + docs polish"): +-- +-- webhooks: one row per (company, event_type, url). The legacy +-- UNIQUE (company_id, event_type) is dropped — multiple receivers per +-- event are valid (Stripe pattern). HMAC signing secret, api_version +-- pin (Stripe pattern), and disable bookkeeping live on this row. +-- +-- webhook_deliveries: one row per outbound POST attempt batch (the +-- same row carries `attempts` across retries; a fresh row per retry +-- would inflate storage 7x). State machine: +-- pending → in_flight → delivered (success) +-- → failed (will retry — bumps attempts + +-- next_attempt_at) +-- → dead (terminal, attempts exhausted +-- OR receiver returned 410) +-- +-- Retry policy (enforced in lib/webhooks/dispatcher.ts): +-- 1m, 5m, 30m, 2h, 12h, 24h, 48h — 7 attempts, ~72h total, exponential. +-- HTTP 410 from receiver → auto-disable webhook + mark delivery `dead`. +-- +-- Audit immutability: terminal-status delivery rows (delivered, dead) are +-- write-locked by trigger. The legal basis varies by event type: +-- +-- - For accounting-event deliveries (journal_entry.committed, +-- journal_entry.reversed, journal_entry.corrected, period.locked, +-- period.year_closed, salary_run.booked, agi.generated, invoice.paid, +-- supplier_invoice.paid): immutability is required by BFL 7 kap 1 § +-- (räkenskapsinformation retention, 7 years after the calendar year +-- the räkenskapsår ended) AND BFNAR 2013:2 kap 8 § (behandlingshistorik +-- integrity). +-- +-- - For non-accounting deliveries (customer.created, document.uploaded, +-- transaction.categorized, webhook.test): immutability is required by +-- gnubok's operational audit-log integrity policy. BFL/BFNAR do NOT +-- apply to these rows — the trigger applies the same lock as a +-- uniform audit-trail policy, not as a statutory obligation. +-- +-- The trigger does not differentiate by event type because per-row +-- runtime classification adds no defensive value (the operational policy +-- is the strict superset). Same pattern lives on `audit_log` and is +-- queued for `operations` (Phase 5 carry-over). + +-- ────────────────────────────────────────────────────────────────────── +-- 1. webhooks — drop legacy unique, add new columns +-- ────────────────────────────────────────────────────────────────────── + +-- The legacy table guards rename with a safe IF EXISTS path so this +-- migration is idempotent in dev/test environments where webhooks_v2 may +-- have been applied + reverted manually. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'automation_webhooks') + AND NOT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'webhooks') THEN + ALTER TABLE public.automation_webhooks RENAME TO webhooks; + END IF; +END $$; + +-- Drop the one-event-per-company-per-row UNIQUE — the v1 contract allows +-- multiple receivers to subscribe to the same event_type (different +-- environments, fan-out to multiple downstream services). +ALTER TABLE public.webhooks + DROP CONSTRAINT IF EXISTS automation_webhooks_company_id_event_type_key; + +-- Index on the legacy active-only filter — we keep it but rename the +-- index so future inspections show the post-rename name. +DROP INDEX IF EXISTS public.idx_automation_webhooks_company_event; + +ALTER TABLE public.webhooks + ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT 'webhook', + ADD COLUMN IF NOT EXISTS description text, + ADD COLUMN IF NOT EXISTS secret text, + ADD COLUMN IF NOT EXISTS created_by_api_key_id uuid REFERENCES public.api_keys(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS api_version_pinned text NOT NULL DEFAULT '2026-05-12', + ADD COLUMN IF NOT EXISTS disabled_at timestamptz, + ADD COLUMN IF NOT EXISTS disabled_reason text; + +-- secret defaults NULL on existing rows (none in production) but is +-- mandatory for new rows. The route generates the secret server-side on +-- POST and returns it once; we cannot generate via DEFAULT because the +-- raw value must be returned in the response and never re-readable. +-- Backfill placeholder so the NOT NULL constraint can be added without +-- breaking dev-environment rows. +UPDATE public.webhooks SET secret = encode(gen_random_bytes(32), 'hex') + WHERE secret IS NULL; + +ALTER TABLE public.webhooks + ALTER COLUMN secret SET NOT NULL; + +-- Replacement index — supports the dispatcher's per-event lookup. +CREATE INDEX IF NOT EXISTS idx_webhooks_company_event_active + ON public.webhooks (company_id, event_type) + WHERE disabled_at IS NULL AND active = true; + +-- ────────────────────────────────────────────────────────────────────── +-- 2. webhook_deliveries — outbound delivery queue + audit +-- ────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.webhook_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tenancy. company_id is denormalised onto the delivery row (also + -- recoverable via webhook_id → webhooks.company_id) so RLS + the + -- worker query don't have to join. + -- + -- webhook_id is nullable + ON DELETE SET NULL so a webhook DELETE + -- preserves the delivery audit trail (BFL 7 kap 1 § retention + + -- BFNAR 2013:2 kap 8 § behandlingshistorik integrity for accounting + -- events: journal_entry.committed, period.locked, salary_run.booked, + -- agi.generated, ...). The dispatcher filters webhook_id IS NOT NULL + -- so dangling rows go dormant in the audit trail rather than retrying + -- against nothing. + webhook_id uuid REFERENCES public.webhooks(id) ON DELETE SET NULL, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + + -- Payload identity + event_type text NOT NULL, + payload jsonb NOT NULL, + previous_attributes jsonb, + api_version text NOT NULL, + + -- Lifecycle + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_flight', 'delivered', 'failed', 'dead')), + attempts int NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + + -- Last response capture (overwritten per attempt; full per-attempt + -- forensic log is out of scope for v1 — open a new row only if the + -- caller hits :retry on a `dead` row). + response_status int, + response_body text, + response_headers jsonb, + error text, + + -- Audit + request_id text, + created_at timestamptz NOT NULL DEFAULT now(), + delivered_at timestamptz +); + +ALTER TABLE public.webhook_deliveries ENABLE ROW LEVEL SECURITY; + +-- Members of the company can read their company's deliveries. +-- All writes go through the service-role dispatcher / route handlers; no +-- anon/authenticated INSERT/UPDATE/DELETE policy. +CREATE POLICY "webhook_deliveries_select" + ON public.webhook_deliveries FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +-- Worker pickup: oldest due deliveries with status pending or failed. +-- (failed = scheduled for retry; in_flight = currently being attempted by +-- a worker, do not re-pick. The worker uses FOR UPDATE SKIP LOCKED so the +-- partial WHERE narrows the candidate set.) +CREATE INDEX idx_webhook_deliveries_due + ON public.webhook_deliveries (next_attempt_at) + WHERE status IN ('pending', 'failed'); + +-- Per-webhook listing: GET /webhooks/{id}/deliveries. +CREATE INDEX idx_webhook_deliveries_webhook_created + ON public.webhook_deliveries (webhook_id, created_at DESC); + +-- Per-company listing (future surface). +CREATE INDEX idx_webhook_deliveries_company_created + ON public.webhook_deliveries (company_id, created_at DESC); + +-- ────────────────────────────────────────────────────────────────────── +-- 3. Immutability trigger — terminal-status delivery rows are write-locked +-- ────────────────────────────────────────────────────────────────────── +-- +-- BFL 7 kap 1 § (retention, accounting-event rows) + BFNAR 2013:2 kap 8 § +-- (behandlingshistorik integrity, all rows): an audit row that records the +-- outcome of a system event becomes immutable once finalised. For +-- webhook deliveries the terminal states are `delivered` and `dead`. +-- `failed` is NOT terminal (the dispatcher will mutate it back to +-- `in_flight` and then to one of the terminal states or back to +-- `failed` with bumped attempts). +-- +-- The :retry route bypasses the trigger by going through a service-role +-- function that re-opens the row by INSERT-ing a fresh delivery row +-- pointing at the same payload, NOT by mutating the terminal row in place. + +CREATE OR REPLACE FUNCTION public.enforce_webhook_delivery_immutability() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF OLD.status IN ('delivered', 'dead') THEN + RAISE EXCEPTION 'webhook_deliveries row in terminal status (%) is immutable', OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_webhook_delivery_immutability + BEFORE UPDATE ON public.webhook_deliveries + FOR EACH ROW EXECUTE FUNCTION public.enforce_webhook_delivery_immutability(); + +-- ────────────────────────────────────────────────────────────────────── +-- 4. updated_at trigger on webhooks (table predates updated_at trigger; +-- the legacy migration installed `set_updated_at` already — leave it +-- in place). +-- ────────────────────────────────────────────────────────────────────── + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260515180000_webhook_deliveries_retention.sql b/supabase/migrations/20260515180000_webhook_deliveries_retention.sql new file mode 100644 index 00000000..e3eff560 --- /dev/null +++ b/supabase/migrations/20260515180000_webhook_deliveries_retention.sql @@ -0,0 +1,33 @@ +-- Migration: webhook_deliveries_retention +-- +-- Originally added in PR #496 round 1 to ALTER the webhook_deliveries.webhook_id +-- FK from ON DELETE CASCADE to ON DELETE SET NULL (BFNAR 2013:2 kap 8 § +-- behandlingshistorik + BFL 7 kap retention). +-- +-- In round 3 the FK declaration was folded directly into migration +-- 20260515170000 (clean schema state for fresh installs). This file is +-- retained for migration-history continuity — Supabase preview branches +-- track the set of applied remote migrations and fail reconciliation if +-- a previously-applied filename disappears locally. +-- +-- The body below is fully idempotent: +-- - On a fresh install: 170000 creates the FK with SET NULL; this +-- migration's ALTER is a no-op (DROP IF EXISTS + ADD with the same +-- constraint shape). +-- - On a preview branch that applied the original 170000 (CASCADE) + +-- this 180000 (the original ALTER): the column is already nullable +-- and the FK is already SET NULL; ALTER is a no-op. +-- - Idempotent retro-application is intentional so neither path +-- diverges from the canonical post-migration schema state. + +ALTER TABLE public.webhook_deliveries + ALTER COLUMN webhook_id DROP NOT NULL; + +ALTER TABLE public.webhook_deliveries + DROP CONSTRAINT IF EXISTS webhook_deliveries_webhook_id_fkey; + +ALTER TABLE public.webhook_deliveries + ADD CONSTRAINT webhook_deliveries_webhook_id_fkey + FOREIGN KEY (webhook_id) REFERENCES public.webhooks(id) ON DELETE SET NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql b/supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql new file mode 100644 index 00000000..9c75015d --- /dev/null +++ b/supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql @@ -0,0 +1,111 @@ +-- Migration: webhook_deliveries_db_guards +-- +-- Round-2 review on PR #496. Two DB-level invariants the application can +-- never bypass: (1) audit-log integrity policy (all rows) + BFL 7 kap 1 § +-- retention (accounting-event rows specifically) extend to DELETE on +-- terminal rows; (2) webhook_deliveries.company_id MUST match its parent +-- webhooks.company_id at INSERT time. +-- +-- Both are belt-and-braces alongside existing application-layer guards: +-- - Migration 20260515170000 declares the webhook_id FK with +-- ON DELETE SET NULL so a webhook DELETE preserves the delivery +-- audit trail. But a privileged operator (or a future bug) could +-- still issue a direct DELETE on a delivery row. The new +-- BEFORE DELETE trigger forecloses that path for terminal rows. +-- - The dispatcher's loadWebhooksByIds + cross-tenant assertion already +-- blocks dispatch when company_id mismatches at the application layer. +-- The new BEFORE INSERT trigger blocks the mismatch from being +-- written in the first place — closes the window where a compromised +-- service-role caller could enqueue a delivery against another +-- tenant's webhook. + +-- ────────────────────────────────────────────────────────────────────── +-- 1. BEFORE DELETE — block hard-delete of terminal-status rows +-- ────────────────────────────────────────────────────────────────────── +-- +-- The 20260515170000 migration installed an enforce_webhook_delivery_immutability +-- trigger BEFORE UPDATE only. Direct DELETE bypassed it. Adding a +-- BEFORE DELETE counterpart for the same predicate. +-- +-- Note: the function from migration 170000 is reused for the UPDATE path +-- (single source of truth for the terminal-row predicate). We define a +-- thin DELETE-specific function here that calls the same predicate. + +CREATE OR REPLACE FUNCTION public.block_webhook_delivery_terminal_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF OLD.status IN ('delivered', 'dead') THEN + RAISE EXCEPTION + 'webhook_deliveries row in terminal status (%) cannot be deleted (audit-log integrity policy; accounting-event rows additionally fall under BFL 7 kap 1 § retention)', + OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN OLD; +END; +$$; + +CREATE TRIGGER block_webhook_delivery_terminal_delete + BEFORE DELETE ON public.webhook_deliveries + FOR EACH ROW EXECUTE FUNCTION public.block_webhook_delivery_terminal_delete(); + +-- ────────────────────────────────────────────────────────────────────── +-- 2. BEFORE INSERT — assert delivery.company_id == parent webhook.company_id +-- ────────────────────────────────────────────────────────────────────── +-- +-- A delivery row whose company_id doesn't match its parent webhook's is +-- structurally invalid: it would either (a) display under the wrong +-- tenant's GET /webhooks/{id}/deliveries call, (b) cause the dispatcher +-- to sign with the wrong tenant's secret, or (c) leak existence of one +-- tenant's webhook to another. +-- +-- The dispatcher's application-layer cross-tenant assertion catches case +-- (b); this trigger forecloses cases (a) and (c) at write time. +-- +-- webhook_id IS NULL bypasses the check — those are dangling rows from +-- webhook DELETE under the round-1 ON DELETE SET NULL FK and have no +-- parent to compare against; the trigger leaves them alone. + +CREATE OR REPLACE FUNCTION public.assert_webhook_delivery_company_match() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + parent_company_id uuid; +BEGIN + IF NEW.webhook_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT company_id INTO parent_company_id + FROM public.webhooks + WHERE id = NEW.webhook_id; + + IF parent_company_id IS NULL THEN + -- The webhook row doesn't exist. Either the FK will fail (if it's + -- a real bad reference) or this is a race; let the FK constraint + -- surface the error rather than masking it here. + RETURN NEW; + END IF; + + IF NEW.company_id IS DISTINCT FROM parent_company_id THEN + RAISE EXCEPTION + 'webhook_deliveries.company_id (%) does not match parent webhooks.company_id (%) for webhook_id %', + NEW.company_id, parent_company_id, NEW.webhook_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER assert_webhook_delivery_company_match + BEFORE INSERT ON public.webhook_deliveries + FOR EACH ROW EXECUTE FUNCTION public.assert_webhook_delivery_company_match(); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260515200000_webhook_deliveries_updated_at.sql b/supabase/migrations/20260515200000_webhook_deliveries_updated_at.sql new file mode 100644 index 00000000..7c0f46a4 --- /dev/null +++ b/supabase/migrations/20260515200000_webhook_deliveries_updated_at.sql @@ -0,0 +1,29 @@ +-- Migration: webhook_deliveries_updated_at +-- +-- Round-4 review on PR #496 — swedish-compliance bot caught that +-- `recoverStuckInFlight` in lib/webhooks/dispatcher.ts queries +-- `webhook_deliveries.updated_at` but the column was never declared. +-- Without an auto-stamped updated_at the in_flight recovery sweep +-- silently returns no rows and stuck deliveries stall forever, +-- breaking the BFNAR 2013:2 kap 8 § audit-log completeness guarantee +-- (every delivery row must reach a terminal state). +-- +-- Fix: +-- 1. Add the column with NOT NULL DEFAULT now() so existing rows get +-- a timestamp at backfill time. +-- 2. Reuse the project-wide update_updated_at_column() trigger +-- function so every UPDATE auto-stamps the column. +-- +-- The column lands AFTER the immutability triggers from migrations +-- 170000 and 190000, so an UPDATE on a terminal row still hits the +-- BEFORE UPDATE check_violation guard before the trigger has a chance +-- to bump updated_at — no audit-row mutation can occur. + +ALTER TABLE public.webhook_deliveries + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +CREATE OR REPLACE TRIGGER webhook_deliveries_updated_at + BEFORE UPDATE ON public.webhook_deliveries + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +NOTIFY pgrst, 'reload schema'; diff --git a/vercel.json b/vercel.json index 2741d9b9..527ecffe 100644 --- a/vercel.json +++ b/vercel.json @@ -43,6 +43,10 @@ { "path": "/api/extensions/skatteverket/agi/kvittenser/cron", "schedule": "0 */2 * * *" + }, + { + "path": "/api/webhooks/dispatch/cron", + "schedule": "* * * * *" } ] }