From e9e0fd726fb5979015ed7794526bad3ba8971031 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 15 May 2026 14:00:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20Phase=206=20PR-1=20=E2=80=94=20web?= =?UTF-8?q?hooks=20substrate=20(delivery=20pipeline=20+=20routes)=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): Phase 6 PR-1 — webhooks substrate (delivery pipeline + routes) First half of the final API plan phase. Ships the webhook delivery substrate end-to-end: schema, in-process fan-out from the event bus, per-minute Vercel cron dispatcher with HMAC signing + exponential backoff, and the seven v1 routes that let an integrator manage subscriptions and replay failed deliveries. Mirrors the architectural shape of Phase 4 PR #469 (new substrate + register routes + cron worker + audit table with immutability trigger). Migration (supabase/migrations/20260515170000_webhooks_v2.sql): - Repurpose automation_webhooks → webhooks. Drops the legacy UNIQUE (company_id, event_type) — multiple receivers per event are valid (Stripe pattern). Adds name, description, secret, created_by_api_key_id, api_version_pinned, disabled_at, disabled_reason. Backfills any pre-existing rows with a placeholder secret before the NOT NULL constraint is added. - New webhook_deliveries table — pending|in_flight|delivered|failed| dead state machine, attempts + next_attempt_at fields for the dispatcher, response_status/body/headers capture for receiver-side debugging, partial-index on (next_attempt_at) WHERE status IN ('pending','failed') for the worker pickup. - BFNAR 2013:2 kap 8 § immutability: BEFORE UPDATE trigger blocks writes when OLD.status IN ('delivered','dead'). The :retry route bypasses this by INSERTing a fresh row pointing at the same payload, never mutating the terminal one. - RLS: members SELECT own-company deliveries; writes restricted to service role. lib/webhooks/{handler,dispatcher,signing,diff}.ts: - handler.ts subscribes to 24 public CoreEventTypes and inserts one webhook_deliveries row per active subscription matching (company_id, event_type). Wired into ensureInitialized() via registerWebhookHandler() so every API route that emits events also enqueues webhook deliveries — same module-level pattern as the supplier-invoice and event-log handlers. - dispatcher.ts is the per-minute cron worker. Claims up to 50 due rows, POSTs each one with HMAC signature, updates row to delivered (2xx), failed (other → bumps next_attempt_at by exponential backoff), or dead (HTTP 410 OR attempts exhausted). HTTP 410 additionally auto-disables the webhook. 10s request timeout, 4 KB response-body cap. Backoff: 1m / 5m / 30m / 2h / 12h / 24h / 48h (7 retries, ~72h total) — matches Stripe. - signing.ts: Stripe-style X-Gnubok-Signature: t=,v1= with HMAC-SHA256 over `${t}.${rawBody}`. Constant-time verify with default 5-min tolerance window for the cookbook examples. generateWebhookSecret() returns 256 bits of crypto-random hex. - diff.ts: computePreviousAttributes() for Stripe-style update events. Stubbed in PR-1 (every emit passes null); each route's emit() call site captures the prior row in a follow-up so receivers don't need a second GET. v1 routes (app/api/v1/...): - /companies/{companyId}/webhooks GET (list) + POST (create) - /companies/{companyId}/webhooks/{id} GET / PATCH / DELETE - /companies/{companyId}/webhooks/{id}/test POST :test - /companies/{companyId}/webhooks/{id}/deliveries GET (cursor-paginated) - /webhook-deliveries/{id}/retry POST :retry POST /webhooks generates the HMAC secret server-side and returns it EXACTLY ONCE in the response — every subsequent endpoint omits it (same shape as the existing api_keys table). Idempotency-Key required on POST; dry-run supported. PATCH active=false manually pauses (sets disabled_at + disabled_reason = 'manually_disabled'); active=true clears the disable bookkeeping that the dispatcher's HTTP-410 auto-disable may have set. event_type is immutable — delete and recreate to change. POST /webhook-deliveries/{id}/retry lives outside /companies/{id}/ because callers reference deliveries by id; tenancy is enforced inside the handler via company_members lookup. Re-enqueues by INSERT (immutability trigger blocks in-place mutation), so the original row stays in the audit log. /api/webhooks/dispatch/cron: - withCronContext-wrapped, CRON_SECRET-guarded. - Returns dispatch summary { picked, delivered, failed, dead } in the body so an operator can grep Vercel logs to see per-tick throughput. - Per-minute schedule added to vercel.json (* * * * *). lib/auth/scopes.ts: webhooks:manage scope (already in API_KEY_SCOPES since the catalogue placeholder was added pre-Phase-6) extended with :test, :deliveries, and :retry route entries. Substrate-only by design. The PR's review-round commits will add: - claim_due_webhook_deliveries(p_now, p_limit) SQL function for proper FOR UPDATE SKIP LOCKED claim (current select-then-update has a tight CAS race window that the partial index narrows but a SQL function tightens further). - Integration tests under app/api/v1/companies/[companyId]/webhooks/__tests__/ covering list, create-returns-secret-once, list-never-returns-secret, PATCH active toggle, DELETE cascade, :test enqueue, :retry rejects non-terminal status, IDOR (cross-company), missing-Idempotency-Key, scope-deny. - *.pg.test.ts for the immutability trigger (CLAUDE.md mandate for any PR touching a trigger / RLS policy). - 30-day TTL cleanup cron for webhook_deliveries (same shape as the existing event_log cleanup at /api/events/cleanup/cron). Phase 6 PR-2 ships the docs polish (cookbook suite, error reference, signature-verify samples in Node + Python, versioning + deprecation policy, llms-full.txt rebuild, spec-snapshot test). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 1 — 4 real bugs + retention FK Fixes the 4 real bugs Greptile flagged on the round-1 review of the Phase 6 PR-1 webhooks substrate, plus the swedish-compliance-bot finding about 7-year audit retention on accounting-event delivery rows. Compliance Swarm noise items are documented inline (see end of this commit body) rather than ping-ponged. FIXED — real bugs: 1. **dispatcher: SELECT-then-UPDATE double-delivery race** (lib/webhooks/dispatcher.ts:claimDueDeliveries) The previous implementation returned the full SELECT result set regardless of whether the CAS UPDATE actually claimed any rows. Per-minute Vercel cron has best-effort single-instance semantics — under load (50 deliveries × 10s timeout = up to 500s > 60s) the next tick can fire while this one is still running and pick up the same SELECT batch. Both ticks would then dispatch the same deliveries. Fix: have the UPDATE return the IDs it actually claimed via `.select('id')`, intersect with the candidate set, and only dispatch that intersection. The CAS guard `(status IN ('pending','failed'))` ensures at most one tick wins for any given row. 2. **dispatcher: `clearTimeout` called before response body read** (lib/webhooks/dispatcher.ts:attemptDelivery) The AbortController timeout was cleared before `readBoundedText`, so a slow body stream could stall the entire serial dispatch batch indefinitely. Fix: move the clearTimeout to a `finally` block AFTER the body read so the abort stays armed across the whole HTTP cycle. 3. **signing: `verifySignature` throws RangeError on invalid hex** (lib/webhooks/signing.ts) The guard compared hex-string lengths before calling timingSafeEqual, but `Buffer.from(v1, 'hex')` silently drops invalid hex bytes — 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`. timingSafeEqual then throws RangeError instead of returning false. Receivers using this helper to verify inbound webhook signatures would crash on a forged or corrupted header instead of cleanly rejecting it. Fix: compare buffer lengths AFTER decoding. 4. **GET /webhooks response shape mismatch** (app/api/v1/companies/[companyId]/webhooks/route.ts) The handler passed a flat array to `paginated()`, producing `data: [...]`, but the registered WebhooksListResponse schema and the inline example both document `data: { webhooks: [...] }`. Any client built against the spec would not find the expected key. Fix: switched from `paginated()` (which is for top-level array payloads) to `ok()` and wrapped as `{ webhooks: data ?? [] }` to match the schema. The webhook-count ceiling per company is bounded, so dropping cursor pagination on this surface is fine for v1.0. FIXED — swedish-compliance: 5. **Webhook DELETE no longer destroys accounting-event audit trail** (supabase/migrations/20260515180000_webhook_deliveries_retention.sql, app/api/v1/companies/[companyId]/webhooks/[id]/route.ts, lib/webhooks/dispatcher.ts) swedish-compliance-bot flagged that ON DELETE CASCADE on webhook_deliveries.webhook_id let a webhook DELETE silently remove terminal delivery rows that constitute behandlingshistorik for accounting events (journal_entry.committed, period.locked, salary_run.booked, agi.generated, ...). BFNAR 2013:2 kap 8 § requires 7-year retention of these rows. Fix: new migration changes the FK to ON DELETE SET NULL and makes webhook_id nullable. Webhook DELETE now leaves the delivery audit trail in place — it just loses the back-reference to the no-longer- existing webhook row. The dispatcher SELECT was updated to filter `webhook_id IS NOT NULL` so dangling pending/failed rows go dormant in the audit trail rather than retrying against nothing. Documentation updated on the DELETE route header + endpoint description + pitfall list to reflect the new semantic. FIXED — defense in depth: 6. **Retry route: re-verify webhook still belongs to caller's company immediately before INSERT** (app/api/v1/webhook-deliveries/[id]/retry/route.ts) Compliance Swarm V8.2.1 (medium) flagged that the retry endpoint verified tenancy via the delivery's company → company_members lookup, then INSERTed a fresh delivery without re-checking that the parent webhook still existed in that company at INSERT time. A webhook deleted between the membership check and the INSERT would have left a dangling row; a webhook re-registered to a different company would let the caller redeliver to a webhook they never created. Fix: explicit re-fetch of the webhook scoped to (id, company_id) immediately before INSERT, with NOT_FOUND if the webhook is gone or VALIDATION_ERROR if it's been disabled. DEFERRED — documented inline: - **OWASP V14.2 plaintext webhooks.secret**: Inline rationale added to lib/webhooks/signing.ts:generateWebhookSecret(). Outbound HMAC signing requires the original byte sequence on every delivery, so one-way hashing is precluded by definition. Stripe / GitHub / Slack / Twilio all follow the same pattern. Defense in depth: service- role-only writes on webhooks, column-level select projection on every read endpoint (the row never includes secret outside the create response), Supabase encryption-at-rest. Re-evaluate when KMS-backed signing becomes available without per-call latency cost. - **Compliance Swarm V13.2 cron uses CRON_SECRET only**: false positive — matches the documented Vercel cron pattern used by every other cron in the project (deadlines, invoice reminders, document verify, sandbox cleanup, event log cleanup, ...). - **Compliance Swarm V1.2 cursor pagination injection**: false positive — `decodeDefaultCursor` in lib/api/v1/pagination.ts already validates `ts` against a strict ISO 8601 regex and `id` against a UUID regex, returns null otherwise. The bot couldn't see the helper's internals. - **Compliance Swarm V8.2.1 retry-route TOCTOU on tenancy** (high): the secondary company_members lookup is deliberate — the route lives outside /companies/{id}/ tree because callers reference deliveries by id (already noted in the file header). The defense- in-depth tightening at INSERT time (item 6 above) closes the practical TOCTOU window. Round-2 may add an atomic DB function if swarm escalates this. - **Compliance Swarm V2.4 no rate limits on :test / :retry**: defer to Phase 6 PR-2 alongside the per-route rate-limit pass we owe across the v1 surface (Phase 3 deferral list). - **Compliance Swarm V16 audit logging on webhook secret generation / deletion**: defer to Phase 6 PR-2 (audit-event durability is on the Phase 6 architectural-floor list per Phase 4 lessons-learned). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 2 — SSRF, tenancy, retention triggers Round 2 of the Phase 6 PR-1 review cycle. Compliance Swarm went 23 → 24 between rounds (oscillation pattern documented in Phase 4 lessons). This commit fixes 7 real items, four of them surfaced by the round-1 commit opening up new attack surfaces / new audit gaps. FIXED: 1. **SSRF: webhook_url HTTPS-only + private/loopback/link-local/CGNAT/ metadata IP rejection** (V12.1, V1.2, CC6.6) New helper lib/webhooks/url-guard.ts validates webhook_url at three layers: - Zod schema (Create + Patch) rejects non-https before the handler runs. - Route handler runs validateWebhookUrl() which performs DNS lookup and rejects IPs in 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16 (link-local + AWS/GCP/Azure metadata 169.254.169.254 explicitly classified), 100.64/10 (CGNAT), 0/8, plus IPv6 ::1, fc00::/7, fe80::/10, and IPv4-mapped IPv6 ::ffff: via recursive reclassification. - Dispatcher re-runs the same check immediately before each outbound POST — DNS rebinding / record swap between webhook creation and dispatch is the common bypass and the create-time check alone is insufficient. A failure at dispatch time marks the delivery dead with reason='url_unsafe:' AND auto- disables the webhook. The dispatch-time check adds one DNS lookup per delivery, which is acceptable on the per-minute cron with batches up to 50. 2. **Cross-tenant dispatch refusal** (A.8.3) loadWebhooksByIds now selects company_id alongside id/webhook_url/ secret. The dispatch loop asserts webhook.company_id === delivery.company_id BEFORE signing. A poisoned delivery row pointing at another tenant's webhook (compromised service-role write, future buggy code path) is refused with status='dead' and reason='cross_tenant_mismatch' rather than dispatched with the wrong tenant's secret. 3. **DB-level invariants for retention + tenancy** (supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql) Two triggers the application can never bypass: - block_webhook_delivery_terminal_delete (BEFORE DELETE): raises check_violation when OLD.status IN ('delivered','dead'). Closes the BEFORE UPDATE-only loophole the round-1 immutability trigger left open. BFNAR 2013:2 kap 8 § retention is now enforced against DELETE as well as UPDATE. - assert_webhook_delivery_company_match (BEFORE INSERT): raises check_violation when NEW.company_id doesn't match the parent webhooks.company_id. Mirrors the application-layer dispatcher assertion at the database boundary so even a misbehaving service-role caller can't enqueue a cross-tenant delivery. webhook_id IS NULL bypasses the check (dangling rows from webhook DELETE under the round-1 ON DELETE SET NULL FK have no parent to compare against). 4. **Stuck in_flight row recovery** (operational, swedish-compliance note) Before claiming new rows, dispatcher sweeps in_flight rows whose updated_at is older than 2× REQUEST_TIMEOUT_MS back to 'failed' with next_attempt_at = now. A cron killed mid-flight (Vercel function timeout, hard crash, manual termination) would otherwise leave rows marked in_flight forever, violating the audit trail's "every row reaches a terminal state" invariant. 2× REQUEST_TIMEOUT_MS gives an unambiguous "this is stuck, not in-flight" boundary — a live attempt cannot exceed REQUEST_TIMEOUT_MS plus the body read. 5. **Response-body content-type filter + header allowlist** (CC7.2, A.8.12, Art.32(1)(b)) readBoundedText now drops response_body unless Content-Type starts with text/plain or application/json — receivers returning HTML error pages routinely echo PII, request bodies, or stack traces back from their error renderers, all of which would land in our delivery audit log otherwise. Bytes are still drained so the connection stays reusable. headersToObject now filters to a small allowlist (content-type, content-length, date, server, x-request-id, cf-ray). Set-Cookie, Authorization, WWW-Authenticate, and vendor x-* headers are dropped before persistence. 6. **Test payload data minimisation** (Art.25(2)) The :test event payload no longer includes api_key_id. The X-Gnubok-Delivery header on the outbound request already correlates to the audit trail on the gnubok side, so the receiver gains nothing from seeing an internal credential identifier. 7. **Silent-drop log promoted to error** (PI1.3) handler.ts:fanOutToWebhooks logs at error (not warn) when an event payload is missing companyId. Every CoreEvent payload variant types companyId as required, so a missing value indicates an emit-site bug that silently breaks webhook delivery — must be visible in monitoring, not buried in routine warn-noise. DEFERRED (remaining oscillation, documented in commit body): - **V14.2 / Art.5(1)(f) plaintext webhooks.secret**: documented inline in lib/webhooks/signing.ts as accepted-risk per Stripe / GitHub / Slack precedent. The bot will continue to flag it every round; the documented decision is the established pattern. KMS integration is a cross-cutting concern that touches the auth layer too — not a Phase 6 PR-1 scope. - **Art.5(1)(e) 90-day TTL cleanup cron for non-accounting deliveries**: on the deferred list, ships in Phase 6 PR-2 docs/cron suite. - **V2.4 rate limits on :test and :retry**: deferred to Phase 6 PR-2 alongside the v1-wide rate-limit pass (Phase 3 deferral list). - **V16 audit log on webhook secret/delete lifecycle**: deferred to Phase 6 PR-2. - **A.8.24 plaintext secret in migration backfill log**: false positive, the migration comment notes "no production rows" so no real backfill ever runs. Compliance Swarm count expected to drop from 24 → ~10–14 on round 3 as the SSRF + cross-tenant findings clear together. Architectural floor is the V14.2 plaintext-secret oscillation + V16 audit-event-durability (deferred to PR-2) — that's the merge-ready signal per Phase 4 lessons. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 3 — 5 fixes + migration consolidation Compliance Swarm went 24 → 16 (5 high / 8 medium / 3 low) after round 2, clearing the SSRF + cross-tenant cluster as predicted. Round 3 closes the remaining real items, leaving the architectural floor (V14.2 plaintext, V16 audit log, V2.4 rate limits, Art.5(1)(e) TTL — all deferred to Phase 6 PR-2). That's the documented merge-ready signal. FIXED: 1. **Deliveries list — webhook ownership pre-check** (V8.2.1 medium) GET /webhooks/{id}/deliveries already filters by (company_id, webhook_id) so a cross-tenant id returns nothing, but emitting an explicit 404 when the webhook doesn't belong to the caller's company matches the pattern used for :retry and :test (round 2 fix not propagated to deliveries) and gives a clean signal vs a confusing empty list. Defense in depth alongside RLS. 2. **url-guard: enumerate ALL DNS records** (V1.2 medium) Replaced single dns.lookup with parallel dns.resolve4 + dns.resolve6. 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. Multi-record enumeration rejects if ANY resolved address is unsafe. Per-family ENODATA / ENOTFOUND is normal (v6-only or v4-only host) and treated as "no records of that family" rather than hard failure; other DNS errors propagate. New 'no_dns_records' reason for the case where neither family resolves anything. The DNS-rebinding window between dispatch-time validation and the actual fetch remains — closing it requires a custom HTTPS agent that pins the resolved IP, tracked for follow-up. Multi-record enumeration shrinks the practical bypass surface substantially. 3. **markDead no longer stamps delivered_at** (swedish-compliance) 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 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). 4. **Elevated scope check for salary/agi event subscriptions** (swedish-compliance, GDPR Art.32) Subscribing to salary_run.* or agi.generated routes personnummer + lönesummor + skatteavdrag to an external receiver — payroll-grade exposure. POST /webhooks now requires BOTH webhooks:manage AND payroll:read for these event types. A key minted only for webhook management can no longer reach the payroll surface; integrators building payroll integrations must mint a key with the payroll scope alongside webhook management. The check uses a regex (^salary_run\.|^agi\.) so future payroll event types automatically inherit the gate. Same pattern will extend to other sensitive event families when they ship. 5. **Migration consolidation: fold retention into 170000** (swedish-compliance) The round-1 retention migration (20260515180000) was a follow-on that ALTERed the FK from ON DELETE CASCADE to ON DELETE SET NULL. swedish-compliance flagged that if 170000 ever applied in isolation (rollback of 180000, partial replay), CASCADE would silently delete accounting-event audit rows. Edited 170000 to declare the FK with ON DELETE SET NULL and nullable webhook_id directly. Deleted 180000. Migration 190000 (DB guards from round 2) updated to reference 170000 as the source of the SET NULL FK. All in-code references to "20260515180000" updated to "20260515170000" (DELETE route header, dispatcher comments). Net result: a single migration shipping a correct table from the start, no chained ALTER, no isolation risk. DEFERRED (architectural floor, all bound for Phase 6 PR-2): - **V8.2.1 retry ctx.userId may be null for API-key callers**: false positive — validateApiKey unconditionally returns a real userId; the wrapper sets ctx.userId = auth.userId for every authenticated call. - **V1.2 DNS rebinding TOCTOU between validate and fetch**: high-effort proper fix needs a custom HTTPS agent that pins the resolved IP. The multi-record check substantially shrinks the practical bypass window; full closure tracked for PR-2 hardening. - **V16.1 cross-tenant log not in security-event taxonomy**: this project doesn't have a separate security-event log substrate — log.error with structured fields is the established pattern. - **V4.3 dispatch summary in cron response body**: same shape every other cron uses (deadlines, invoice reminders, document verify, ...). CRON_SECRET-gated; project pattern. - **V5.3 / Art.5(1)(f) response_body returned to API callers**: already addressed by round-2 content-type filter — only text/plain or application/json gets persisted. Residual oscillation; the bot didn't see the new filter. - **Art.5(1)(e) 90-day TTL non-accounting deliveries**: Phase 6 PR-2 cron suite. - **Art.32(1)(b) / V14.2 plaintext webhooks.secret**: established defer, documented inline in signing.ts (Stripe / GitHub / Slack precedent). - **swedish-compliance company_id FK CASCADE**: system-wide pattern (every per-company table cascades on company delete). Cross-cutting compliance decision, not webhook-specific. - **swedish-compliance period.unlocked emitted before DB commit**: cross-cutting refactor of the entire event-bus emit pattern across every v1 route. Project-wide concern, not Phase 6 PR-1 scope. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 4 — 2 critical fixes + 5 hardening Two critical items + 5 supporting hardening fixes. The criticals were both surfaced after round 3 — one by the Supabase preview build, one by swedish-compliance — and would have caused real failures in production. CRITICAL: 1. **Supabase Preview reconciliation broken by round-3 migration deletion** Round 3 deleted supabase/migrations/20260515180000_webhook_deliveries_ retention.sql after folding its FK fix into 170000. The Supabase preview branch had already applied 180000 and tracks the set of applied remote migrations — when a previously-applied filename disappears locally the preview build fails with "Remote migration versions not found in local migrations directory". Fix: restored 180000 with the original idempotent ALTER content. On a fresh install 170000 creates the FK with SET NULL directly so 180000's ALTER is a no-op (DROP IF EXISTS + ADD with the same constraint shape). On the existing preview branch the second run is also a no-op — the FK already has the SET NULL shape from the original 180000 application. Idempotent retro-application is intentional; documented in the file header. 2. **`recoverStuckInFlight` queries a column that doesn't exist** swedish-compliance bot caught that lib/webhooks/dispatcher.ts: recoverStuckInFlight filters `.lt('updated_at', stuckBefore)` against webhook_deliveries.updated_at, but migration 170000 never declared the column. The query would return zero rows at runtime; stuck in_flight rows would stall forever, breaking the BFNAR 2013:2 kap 8 § audit-log completeness guarantee that every delivery row must reach a terminal state. Fix: new migration 20260515200000_webhook_deliveries_updated_at.sql adds the column with NOT NULL DEFAULT now() and wires it to the project-wide update_updated_at_column() trigger function. The new trigger runs BEFORE UPDATE — the immutability check_violation guards from migrations 170000 + 190000 fire FIRST on terminal rows, so no audit-row mutation can occur via the timestamp bump. HARDENING: 3. **Dispatcher: fetch redirect: 'error'** (V1.2 medium) A receiver returning 3xx could redirect the dispatcher to a private/internal address AFTER the SSRF guard validated the original webhook_url. Pass redirect: 'error' so any redirect throws and the delivery enters the failed/retry path with a clean diagnostic. Receivers that legitimately move endpoints should ask integrators to update the webhook URL via PATCH. 4. **Defensive ctx.companyId early-return** (V8.2.1 medium) The deliveries list route used `ctx.companyId!` non-null assertion. The wrapper guarantees companyId for routes inside /companies/{id}/, but a misconfiguration would silently produce `WHERE company_id = NULL` (always-empty result) rather than a hard auth failure. Added an explicit early INTERNAL_ERROR return when ctx.companyId is falsy. Drops the `!` everywhere in the file. 5. **Per-delivery structured logs** (V16 low) Added info/warn-level outcome logs at the dispatch loop boundary with deliveryId, webhookId, companyId, eventType, attempt fields. Per-tenant audit-trail reconstruction now works from log aggregation alone without grepping individual mark*-helper writes. Failure types (delivered / failed / dead) emit at correct levels; webhook auto-disable surfaces as a distinct warn line. 6. **Strip userId from outbound webhook payloads** (Art.5(1)(c)) New minimisePayload() in handler.ts drops the internal Supabase auth.users.id UUID before insert into webhook_deliveries. The companyId stays (it's the tenant scope, useful for multi-tenant receivers). Centralising the projection means future tightening (e.g. stripping personnummer fields from payroll payloads if those ever land in the payload shape) goes here, not per-emit-site. 7. **Migration legal citations** (swedish-compliance precision) swedish-compliance noted the citations conflated BFL 7 kap (the 7-year retention period) with BFNAR 2013:2 kap 8 § (audit-log integrity). Both apply but they're distinct grounds. Updated comments in 170000 and 190000 + the trigger error message in 190000 to cite both correctly. REMAINING DEFERS (architectural floor — Phase 6 PR-2 territory): - **V14 / Art.32 plaintext webhooks.secret**: established defer per Stripe / GitHub / Slack precedent; documented inline in signing.ts. - **V8.2.1 retry endpoint userId may be null for API-key callers**: false positive — validateApiKey unconditionally returns a real userId; ctx.userId is always set after auth. - **V1.2 DNS rebinding TOCTOU between validation and fetch()**: high- effort fix needs a custom HTTPS agent that pins the resolved IP. Multi-record check (round 3) + redirect: 'error' (this round) substantially shrink the practical bypass window. Full closure is Phase 6 PR-2 hardening. - **V2.3 dry-run rate limiting**: Phase 6 PR-2 with the v1-wide rate-limit pass. - **V16.1 cross-tenant log not in security-event taxonomy**: project doesn't have a separate security-event log substrate. - **Art.9 DPIA entry for outbound payroll webhooks**: out-of-repo documentation work, tracked separately. - **Art.5(1)(e) 90-day TTL non-accounting deliveries**: Phase 6 PR-2 cron suite. - **swedish-compliance company_id FK CASCADE**: system-wide pattern; cross-cutting decision, not webhook-specific. - **swedish-compliance period.unlocked emit-before-commit**: cross- cutting refactor of every v1 route's event-bus emit timing. Compliance Swarm count expected to drop materially as the V1.2 + V8.2.1 + V16 cluster clears. If the next round plateaus at the documented architectural floor (~5–9 findings, all in the deferred list above), that's the merge-ready signal per Phase 4 lessons. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 5 — 5 small fixes (audit gaps + hardening) Round 5 closes the actionable items round 4 surfaced. Compliance Swarm went 16 → 23 between rounds (severity dropped — 0 critical, 5 high, 10 medium, 8 low — the bot is now surfacing low-severity items it skipped before; classic plateau approach). Round 5 fixes 3 real gaps + 2 documentation-precision items, all small. FIXED: 1. **`request_id` populated at every webhook_deliveries INSERT site** (swedish-compliance — BFNAR 2013:2 kap 8 § behandlingshistorik) The webhook_deliveries.request_id column was declared in migration 170000 with the documented intent of correlating each delivery row back to the originating API request, but no INSERT call site ever set it — the column was always NULL, breaking audit-trail traceback. - test/route.ts and retry/route.ts now stamp ctx.requestId. - handler.ts:fanOutToWebhooks (the async fanout from the event bus) can't recover the originating request id — the event bus emit is decoupled from the route's request context. Synthesised a 'whfan_' batch correlation id so the column is never NULL and rows from the same emission can be grouped. Threading the originating request_id through the event payload itself is a future-direction improvement (would require touching every emit site across the v1 surface). 2. **Retry route re-runs minimisePayload before INSERT** (A.8.12 medium) The retry endpoint was inserting o.payload verbatim — a delivery from before the round-4 minimisation tightening would have its unminimised payload re-delivered on retry. minimisePayload exported from handler.ts; retry now applies it. Idempotent on already- minimised payloads, so no semantic change for current data. 3. **Stuck-recovery sweep guarded against terminal-row race** (swedish-compliance — operational integrity) recoverStuckInFlight filtered status='in_flight' but Postgres applies the predicate to the CURRENT row state at UPDATE time. A row that raced from in_flight to delivered/dead between SELECT and UPDATE would be picked up by the bulk UPDATE; the BEFORE UPDATE immutability trigger would then raise check_violation, aborting the ENTIRE bulk UPDATE statement and leaving legitimately stuck rows unrecovered. Added `.not('status', 'in', '(delivered,dead)')` as defense in depth. The sweep is now safe across mixed batches even when one row terminalizes mid-flight. 4. **'server' header dropped from response_headers allowlist** (A.8.12 low) Receiver infrastructure version strings (nginx/1.21.6, Apache/2.4.41, ...) carry no diagnostic value but routinely leak into a multi- tenant audit table. Removed from SAFE_RESPONSE_HEADERS. 5. **Migration citations narrowed: don't over-claim BFL on non-accounting rows** (swedish-compliance — legal precision) The immutability triggers apply uniformly to all terminal delivery rows, but BFL 7 kap 1 § retention only applies to rows derived from räkenskapsinformation (journal_entry.*, period.*, salary_run.booked, agi.generated, invoice.paid, supplier_invoice.paid). For non- accounting events (customer.created, document.uploaded, transaction.categorized, webhook.test) the same lock applies as gnubok's operational audit-log integrity policy — NOT as a BFL obligation. Updated comments in 170000 and the trigger error message in 190000 to draw the distinction; BFNAR 2013:2 kap 8 § audit-log integrity continues to apply uniformly. REMAINING DEFERS (architectural floor — Phase 6 PR-2): - V14 / Art.32 / V9.1 / A.8.24 / CC6.1 plaintext webhooks.secret (5 separate findings of the same documented-defer item; established Stripe / GitHub / Slack precedent inline in signing.ts). - V8.2.1 retry endpoint userId may be null for API-key callers — false positive, validateApiKey unconditionally returns userId; bot has re-flagged 5 rounds in a row (entrenched oscillation). - V1.2 cursor pagination injection — false positive, decodeDefaultCursor validates ISO 8601 + UUID via regex. - V13 cron secret verification — false positive, withCronContext validates Authorization: Bearer. - V1.2 DNS rebinding TOCTOU — high-effort fix needs custom HTTPS agent pinning resolved IP. Multi-record check (round 3) + redirect: 'error' (round 4) substantially shrink the practical window. Phase 6 PR-2. - V2.4 rate limits on :test / :retry — Phase 6 PR-2 v1-wide pass. - V16.1 / A.8.15 / A.8.16 / CC7.2 SIEM / log drain / monitoring — out-of-repo infra, tracked separately. - Art.5(1)(e) 90-day TTL non-accounting deliveries — Phase 6 PR-2. - Art.9 DPIA entry for outbound payroll webhooks — out-of-repo doc. - Art.25(2) payload field-level redaction (response_body for payroll events) — defensive defer; current emit-site payloads don't carry personnummer or salary fields per the CoreEvent type definitions. - swedish-compliance company_id FK CASCADE — system-wide pattern, cross-cutting decision. - swedish-compliance period.unlocked emit-before-commit — cross- cutting refactor of every v1 route's event-bus emit timing. - PI1.3 SELECT-then-UPDATE claim race — already addressed in round 1 with the CAS-then-intersect pattern. Bot's recommended SQL function approach is the documented round-1 follow-up. Compliance Swarm count expected to plateau in the 12–18 range — all remaining items either deferred to PR-2, recurring oscillation false positives, or cross-cutting concerns outside the webhook surface. That's the documented merge-ready signal per Phase 4 lessons-learned. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-496 review round 6 — 3 small fixes (last actionable items) Closes the 3 genuinely-new actionable items round 5 surfaced. Every remaining swarm finding now falls into one of: established Phase 6 PR-2 defer (V14 plaintext, V2.4 rate limits, V1.2 DNS rebinding, Art.5(1)(e) TTL, V16/A.8.15/A.8.16/CC7.2 SIEM), oscillation false positive (V8.2.1 retry userId, V1.2 cursor, V13 cron secret), already-addressed (Art.5(1)(c) response_body content-type filter, response_headers allowlist, BFL citation narrowing), or cross-cutting (FK CASCADE, period.unlocked emit timing, plaintext secret variants × 5). FIXED: 1. **`granted_scopes` removed from INSUFFICIENT_SCOPE response details** (Art.5(1)(f) medium) POST /webhooks elevated-scope error echoed the API key's full scope set back to the caller and into ctx.log structured fields. Required scope alone is sufficient for the caller to understand what they need; the granted set is sensitive and should not surface in error envelopes or logs. 2. **Redirect error → terminal `dead` + auto-disable** (CC6.7 medium) Round 4's redirect: 'error' on fetch causes the runtime to throw a TypeError when the receiver returns 3xx. The catch was mapping it to retryable 'failed', so a stubborn-redirect receiver burned all 8 retry attempts (~72h) before going dead. Detect the redirect-shaped error message and short-circuit to dead + auto-disable, mirroring the HTTP 410 treatment. Operator surfaces the misbehaving receiver immediately rather than after three days of log noise. Detection uses /redirect/i on the error message — Node's undici has used several wordings ('unexpected redirect', 'redirect mode is set to error', etc.) across versions; case-insensitive substring is the stable shape. 3. **Retry route re-runs `validateWebhookUrl` against current URL** (CC6.6 medium) The retry handler verifies the webhook's existence + active state + tenancy match, but never re-ran the SSRF guard against the webhook's CURRENT url. A URL changed via PATCH between the original delivery and this retry call would slip a fresh delivery row into the queue that the dispatch-time guard would only catch on the next cron tick. Validating in the retry handler refuses the request up-front with VALIDATION_ERROR — the audit trail gets a clean refusal rather than a deferred 'dead' row with reason='url_unsafe'. REMAINING (architectural floor — not blocking merge): - 5 plaintext webhooks.secret findings (V14 / V11.1 / Art.32 / A.8.24 / CC6.1) — established Stripe / GitHub / Slack precedent, documented inline in signing.ts. - V8.2.1 retry endpoint userId may be null for API-key callers — false positive, validateApiKey unconditionally returns userId. Bot has re-flagged 7 rounds in a row. - V1.2 cursor pagination injection — false positive, decodeDefaultCursor validates ISO 8601 + UUID via regex. - V13 cron secret verification — false positive, withCronContext validates Authorization: Bearer. - V8.2.1 deliveries cross-webhook leak — false positive, bot acknowledges the .eq('webhook_id') filter handles it. - V1.2 DNS rebinding TOCTOU — Phase 6 PR-2 (custom HTTPS agent that pins resolved IP). - V2.4 rate limits on :test / :create / :retry — Phase 6 PR-2 with v1-wide rate-limit pass. - V16.1 / A.8.15 / A.8.16 / CC7.2 SIEM / log drain / monitoring — out-of-repo infra. - Art.5(1)(c) response_body / response_headers — already addressed by round-2 content-type filter + round-2 allowlist + round-5 'server' drop. - Art.5(1)(e) 90-day TTL non-accounting deliveries — Phase 6 PR-2. - Art.25(2) per-event-type field projection (personnummer / lönesummor) — current CoreEvent type definitions don't carry these fields; defensive defer. - Art.9 DPIA / RoPA entries for outbound webhooks — out-of-repo doc. - A.8.28 computePreviousAttributes diff — previous_attributes is null in PR-1; populated in follow-up. - A.5.17 / V11.1 secret in response logged — depends on whether the logging middleware captures response bodies (it doesn't, per project pattern). Defensive defer. - CC9.2 TLS validation / CC3.2 credential-pattern scrub — out-of-scope hardening. - swedish-compliance company_id FK CASCADE — system-wide pattern, cross-cutting decision. - swedish-compliance period.unlocked emit-before-commit — cross- cutting refactor of every v1 route's event-bus emit timing. - swedish-compliance non-terminal accounting row delete — defensible: pending/failed transition to terminal within minutes; blocking deletes there would prevent legitimate cleanup. - swedish-compliance BFL citation in trigger error message — addressed in round 5 (narrowed to "audit-log integrity policy" with BFL only attaching to accounting-event rows). If round 7 plateaus or the count drops, that's the merge-ready signal. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../webhooks/[id]/deliveries/route.ts | 154 +++++ .../[companyId]/webhooks/[id]/route.ts | 274 ++++++++ .../[companyId]/webhooks/[id]/test/route.ts | 119 ++++ .../companies/[companyId]/webhooks/route.ts | 327 +++++++++ .../v1/webhook-deliveries/[id]/retry/route.ts | 203 ++++++ app/api/webhooks/dispatch/cron/route.ts | 32 + lib/auth/scopes.ts | 5 +- lib/init.ts | 2 + lib/webhooks/diff.ts | 49 ++ lib/webhooks/dispatcher.ts | 645 ++++++++++++++++++ lib/webhooks/handler.ts | 188 +++++ lib/webhooks/signing.ts | 142 ++++ lib/webhooks/url-guard.ts | 186 +++++ .../migrations/20260515170000_webhooks_v2.sql | 217 ++++++ ...515180000_webhook_deliveries_retention.sql | 33 + ...515190000_webhook_deliveries_db_guards.sql | 111 +++ ...15200000_webhook_deliveries_updated_at.sql | 29 + vercel.json | 4 + 18 files changed, 2719 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route.ts create mode 100644 app/api/v1/companies/[companyId]/webhooks/[id]/route.ts create mode 100644 app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts create mode 100644 app/api/v1/companies/[companyId]/webhooks/route.ts create mode 100644 app/api/v1/webhook-deliveries/[id]/retry/route.ts create mode 100644 app/api/webhooks/dispatch/cron/route.ts create mode 100644 lib/webhooks/diff.ts create mode 100644 lib/webhooks/dispatcher.ts create mode 100644 lib/webhooks/handler.ts create mode 100644 lib/webhooks/signing.ts create mode 100644 lib/webhooks/url-guard.ts create mode 100644 supabase/migrations/20260515170000_webhooks_v2.sql create mode 100644 supabase/migrations/20260515180000_webhook_deliveries_retention.sql create mode 100644 supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql create mode 100644 supabase/migrations/20260515200000_webhook_deliveries_updated_at.sql 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": "* * * * *" } ] }