feat(api): Phase 6 PR-1 — webhooks substrate (delivery pipeline + routes) (#496)

* 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=<unix>,v1=<hex>
  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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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:<v4> 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:<class>' 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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_<uuid>' 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-15 14:00:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 1163fd3bee
commit e9e0fd726f
18 changed files with 2719 additions and 1 deletions
@@ -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=<uuid>.
*
* 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,
})
},
)
@@ -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<string, unknown> = { ...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 })
},
)
@@ -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 },
)
},
)
@@ -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<string, unknown>), secret }, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -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<string, unknown>
previous_attributes: Record<string, unknown> | 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 },
)
},
)
+32
View File
@@ -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 })
})
+4 -1
View File
@@ -181,12 +181,15 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'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 {
+2
View File
@@ -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
+49
View File
@@ -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<T extends Record<string, unknown>>(
prior: T | null | undefined,
current: T | null | undefined,
): Record<string, unknown> | null {
if (!prior || !current) return null
const diff: Record<string, unknown> = {}
// 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
}
}
+645
View File
@@ -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<number> = [
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<string, unknown>
previous_attributes: Record<string, unknown> | 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<DispatchSummary> {
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<void> {
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<DueDelivery[]> {
// 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<Map<string, WebhookForDelivery>> {
// 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<void> {
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<void> {
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<void> {
// 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<void> {
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<string, string> | null
}
type FailedOutcome = {
kind: 'failed'
attempts: number
responseStatus: number | null
responseBody: string | null
responseHeaders: Record<string, string> | null
error: string
}
type DeadOutcome = {
kind: 'dead'
reason: string
disableWebhook: boolean
attempts: number
responseStatus: number | null
responseBody: string | null
responseHeaders: Record<string, string> | null
error?: string
}
type AttemptOutcome = DeliveredOutcome | FailedOutcome | DeadOutcome
async function attemptDelivery(args: {
delivery: DueDelivery
webhook: WebhookForDelivery
fetchImpl: typeof fetch
now: Date
}): Promise<AttemptOutcome> {
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<string | null> {
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<string, string> {
const obj: Record<string, string> = {}
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,
}
+188
View File
@@ -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<CoreEventType>([
'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<string, unknown>),
})
} 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<string, unknown>): Record<string, unknown> {
const projected: Record<string, unknown> = {}
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<string, unknown>
}): Promise<void> {
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_<uuid>' 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,
})
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Webhook signature generation + verification.
*
* Signature header (Stripe-style):
* X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>
*
* 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')
}
+186
View File
@@ -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<WebhookUrlValidationResult> {
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 }
@@ -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';
@@ -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';
@@ -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';
@@ -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';
+4
View File
@@ -43,6 +43,10 @@
{
"path": "/api/extensions/skatteverket/agi/kvittenser/cron",
"schedule": "0 */2 * * *"
},
{
"path": "/api/webhooks/dispatch/cron",
"schedule": "* * * * *"
}
]
}