Files
accounted/lib/webhooks/dispatcher.ts
T
Jakob Wennberg b94ed3bec2 feat(api): cookbooks + webhook audit_log + secret rotation (PR-500 carry-overs) (#501)
* docs(api): ship 4 cookbook recipes (close docs polish backlog)

Promotes the four placeholder cookbook entries to full narrative recipes
matching the Stripe-grade quality bar set by quickstart + webhooks.
Closes the docs follow-up bucket from the PR-500 description's deferred
list.

Recipes:

- ingest-bank-transactions: bank-file upload (CSV / CAMT.053 auto-detect)
  → async poll → list uncategorised → suggest-categories → categorize
  (single + batch) → match-invoice / match-supplier-invoice. Multicurrency
  notes covering Riksbanken FX lookup and the kontantmetoden partial-
  payment guard.

- file-vat-declaration: GET /reports/vat-declaration → rutor 05–62
  walkthrough → GL reconciliation block → 2026-04-01 livsmedel 12% → 6%
  transition explicitly covered (delivery_date supply-date rule) → voucher-
  gap pre-flight → period lock workflow → manual Skatteverket Mina Sidor
  submission with confirmation-reference capture → EU / reverse-charge
  / import handling.

- run-payroll-and-agi: draft → calculate → approve → mark-paid → book →
  generate-agi state machine. Per-step idempotency, strict-mode book
  failure semantics, förmånsbeskattning + bilförmån + bruttolöne­avdrag
  vs nettolöneavdrag ordering. AGI XML download for manual Mina Sidor
  upload (direct API submission requires BankID via the Skatteverket
  extension, not the public REST surface).

- year-end-closing: IB/UB continuity check per BFL 5 kap → voucher-gap
  pre-flight → missing-documents pre-flight → lock (reversible) → year-
  end async operation (resultatdisposition + periodiseringsfond +
  överavskrivningar + bolagsskatt + opening-balance batch) → close
  (irreversible per BFL 5 kap 8 §, typed-phrase confirmation) →
  årsredovisning + INK2/NE generation. Brutet räkenskapsår variant
  documented.

Each cookbook follows the same shape as the existing quickstart and
webhooks recipes — concrete curl commands, response samples, common
pitfalls, next-steps cross-links. Lengths are deliberately uneven: the
year-end recipe is longest because the consequences of getting it
wrong are most severe (BFL violations, irreversible close).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): V16 audit_log entries for webhook lifecycle + secret rotation endpoint

Two intertwined changes that together close the "real audit attribution
gap in actively-used routes" item from the PR description.

1. POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret

   New endpoint that issues a fresh HMAC signing secret and invalidates
   the previous one immediately. Returns the new secret EXACTLY ONCE in
   the response, mirroring the create-time contract. Required scope:
   webhooks:manage. Idempotency-Key mandatory.

   Rotation is instant — no grace period. Documented workflow: stage the
   new secret on the receiver side (separate config slot, not yet active)
   → POST /rotate-secret → activate the new secret on the receiver →
   POST /webhooks/{id}/test to verify. A "previous_secret" column with
   TTL-based grace window (Stripe-style) is the natural follow-up; the
   instant-rotation shape ships first because it closes the "secret
   leaked, need to rotate now" use case with minimum new surface.

   The route is wired into load-routes.ts and lib/auth/scopes.ts. Spec
   snapshot updated.

2. V16 audit_log entries on every webhook lifecycle mutation

   The audit_log column shape (user_id, company_id, action, table_name,
   record_id, actor_id, old_state, new_state, description) is exactly
   what V16 / Art.32(1)(b) / A.8.24 audit-trail requirements call for.
   Wired entries on:

   - POST /webhooks (create) — action INSERT, new_state captures the
     row WITHOUT the secret (signing material must not land in the
     audit trail; only secret-event metadata).
   - PATCH /webhooks/:id (update) — action UPDATE, before/after pair so
     reviewers can reconstruct exactly what changed.
   - DELETE /webhooks/:id (delete) — action DELETE, old_state snapshot
     so the row's prior state survives the delete.
   - POST /webhooks/:id/rotate-secret — action SECURITY_EVENT, new_state
     carries the event marker only (no secret value).
   - dispatcher.disableWebhook (auto-disable on HTTP 410 / redirect /
     url_unsafe) — action SECURITY_EVENT, before/after capturing the
     disable cause for SIEM correlation.

   actor_id is set to ctx.apiKeyId on caller-driven entries so the
   audit row points back to the specific API key that triggered the
   change (PR-500 round-1 CC6.3 finding: actor attribution via
   created_by_api_key_id alone leaves a gap if a key is deleted —
   keeping the actor_id in audit_log closes that).

   4 new integration tests cover the rotate-secret happy path, 404,
   401 unauthorized, and Idempotency-Key required. The existing
   webhook integration tests continue to pass because the audit_log
   inserts fall through to the default mock response (no-op) without
   disturbing the per-table queues.

39 integration tests pass on the webhook surface (+4 vs round-2).
Total: 3588 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 1 — correctness + Swedish compliance

Round 1 of review fixes. Two real correctness bugs Greptile caught, two
audit-trail gaps, and four Swedish-compliance errors in the cookbook
prose. Compliance Swarm has 17 findings (0 blocking); the 4 architectural
items (secret-at-rest encryption, dedicated rotate scope, rate-limit on
rotation, URL redaction) remain deferred with rationale.

Greptile (3 / 3 — all addressed):

1. rotate-secret silent 0-row UPDATE — fixed by adding
   `.select('id').maybeSingle()` to the UPDATE and returning NOT_FOUND
   when no row was touched. Closes the TOCTOU window between the
   existence check and the secret update; a concurrent DELETE no
   longer hands the caller a freshly-generated secret that no webhook
   in the database matches.

2. DELETE handler audit_log silently skipped when prior snapshot is
   null — fixed by writing the audit row UNCONDITIONALLY with
   `old_state: prior ?? null` and a degraded description when the
   snapshot is unavailable. A successful DELETE now always produces
   exactly one audit row (CC6.3 attribution contract).

3. Typo "bookslut" → "bokslut" in year-end-closing.ts.

Compliance Swarm code-quality items addressed:

4. PATCH new_state now derived from the DB-confirmed returned `data`
   with an explicit field allowlist, not from the request-body-derived
   `update` object (A.8.11 / V16.1.1). Closes the gap where a future
   trigger that rejects a field would leave the audit trail out of
   sync with the actual stored state.

5. All four route-side audit_log inserts (create, update, delete,
   rotate-secret) now capture the insert error and emit a structured
   warning via ctx.log; mirrors the dispatcher pattern (CC7.2).

6. Dispatcher null-user_id path now emits a structured warning instead
   of silently skipping the audit_log entry — SIEM can alert on the
   gap (CC7.2 / V16.1.1 / A.8.15).

Swedish compliance (cookbook content fixes — all real errors):

7. VAT cookbook ruta 06 label corrected: "Övrig försäljning (ej
   skattepliktig)" → "Momspliktig försäljning som inte ingår i ruta 05"
   (Skatteverket's verbatim label). The old label conflated exempt vs
   zero-rated supplies and would cause integrators to omit export /
   EU zero-rated sales from box 06.

8. Livsmedel rate-change framing rewritten: leads with the supply-date
   rule (ML 1 kap 3 §) as the decisive date, not invoice_date. The
   old opening sentence ("invoices created with invoice_date >=
   2026-04-01 book to 2631") was wrong on its face — a copy-paste
   reader would mis-book pre-cutover deliveries invoiced in April at
   the new 6% rate.

9. Reverse-charge EU 2645 note adds the blandad-verksamhet caveat:
   "Net zero impact on cash flow" only holds when full avdragsrätt
   applies; partial avdragsrätt requires proportional restriction
   per HFD 2023 ref. 45.

10. Payroll cookbook age bounds corrected: "under-25 / over-66" →
    "18-22 years old (born 2003-2007) / 67+ from 2026", per Prop.
    2025/26:66. The old bounds would cause integrators to apply the
    reduced rate (20.81%) to 23-24-year-olds who must pay 31.42%,
    producing non-compliant AGI files.

11. Payroll cookbook BAS 2615 corrected to 2731 (Avräkning sociala
    avgifter). 2615 is "Utgående moms vid import" in BAS 2026 — using
    it for the payroll liability would misclassify a payroll payable
    as an import-VAT payable and break moms reconciliation.

12. Year-end cookbook periodiseringsfond cap base corrected: IL 30
    kap 5 § cap is on taxable profit BEFORE the periodiseringsfond
    deduction itself (and after schablonintäkt is added back). Note
    on materiellt samband (BFNAR 2016:10 kap 13) added — the
    reservation is BOOKED on 2110-2139, not declaration-only.

Deferred to follow-ups (architectural / out of scope for round 1):

- Secret-at-rest encryption (CC6.1 / Art.5(1)(f)): PR-1 architectural
  carryover, applies to existing webhooks.secret column too.
- Dedicated `webhooks:rotate` scope (CC6.3 informational): introduces
  friction without closing a real gap when the only caller-driven
  action gated by `webhooks:manage` is the rotation itself.
- Per-route rate-limit on :rotate-secret (Art.32 abuse case): part
  of the wider per-route rate-limit pass already on the deferred list.
- webhook_url redaction in audit_log (Art.5(1)(c)): URLs are admin-
  supplied configuration values with no expected sensitive params;
  truncation would degrade audit value for legitimate review.

23 webhook integration tests pass locally (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 2 — atomic mutations + audit completeness + cookbook compliance

Round 2 of review fixes. Compliance Swarm flagged refinements to the
round-1 fixes; Swedish-compliance had a fresh batch of cookbook items
(including a self-contradiction in payroll pitfalls I missed last
round). All addressed.

Code changes — atomicity + audit completeness:

1. rotate-secret collapsed to a single UPDATE … RETURNING (V8.2.1).
   The preflight existence-check SELECT was redundant after round 1
   added .select().maybeSingle() on the UPDATE — the same null-row
   signal indicates non-existence, but in one round trip with no
   TOCTOU window. RETURNING `name` so the audit_log description still
   carries a human identifier without a second read.

2. DELETE handler collapsed to atomic .delete().select().maybeSingle()
   (V8.2.1). Eliminates the pre-read TOCTOU window entirely. A 0-row
   delete (already-deleted webhook) still returns 204 — idempotent
   DELETE — and the audit entry captures the attempt with old_state:
   null. Description discriminates the two cases ("deleted: name" vs
   "delete attempted on missing id").

3. Cache-Control: no-store, no-cache, must-revalidate, private on
   the rotate-secret response (Art.25). The HMAC secret is sensitive
   credential material returned exactly once; this header prevents
   any intermediary (CDN, proxy, gateway access log, browser cache)
   from persisting the response body in a store with a different
   retention policy than intended.

4. Dispatcher auto-disable now writes the audit_log entry
   UNCONDITIONALLY (A.8.15 / V16.1.1 / CC7.2). Previously a null
   prior snapshot or a legacy null user_id caused the audit row to
   be silently skipped — only a warn log was emitted. Now writes
   user_id=NULL when unavailable (post-multi-tenant-refactor schema
   allows it; row is invisible under user RLS but queryable under
   service-role review, which is correct for system-initiated
   SECURITY_EVENT records). Description discriminates the snapshot-
   available / snapshot-unavailable cases.

Swedish compliance — cookbook content fixes (all real errors):

5. VAT cookbook rounding rule corrected: SFL 22 kap 1 § mandates
   TRUNCATION of öre (Math.floor for positive amounts), not half-up
   rounding. Last round mislabeled this as "Math.round (half-up)";
   the SRU filing skill is canonical and uses truncation. Using
   Math.round would produce values that differ from Skatteverket's
   expectations and cause GL-reconciliation mismatches at the öre
   level.

6. VAT reconciliation block now includes 2614 (Utgående moms vid
   omvänd skattskyldighet, matches ruta 30). The previous list of
   2611/2621/2631/2641/2645 omitted 2614; a reconciliation that
   skips it would show rutor_match_gl: true even when the 2614
   balance is non-zero and un-reconciled.

7. Livsmedel rate-change adds a one-sentence caveat for continuous/
   subscription supplies — the supply-date framing in round 1 was
   too tight for cases where multiple deliveries roll up into a
   subscription. Confirms against ML 1 kap 3 § rather than
   assuming a single delivery date is decisive.

8. Payroll pitfalls bullet contradicted step 2 — "Employees under 26
   (2024 rule for 2026 birth year ≥ 2001)" rewritten to match step 2:
   "18–22 years old at the start of 2026 (born 2003–2007) AND 67+
   from 2026". An integrator reading only the pitfalls section
   would have applied the reduced rate too broadly, producing
   underpaid arbetsgivaravgifter and a non-compliant AGI.

9. Year-end periodiseringsfond cap now states schablonintäkt explicitly:
   1.94% × outstanding prior-year balance (SLR + 1% for 2026) is
   ADDED to taxable income before the 25% cap is computed. Last
   round mentioned the "BEFORE the periodiseringsfond deduction"
   ordering but elided the schablonintäkt step; omitting it
   produces a cap that's too low when prior-year reserves exist.

10. Year-end SRU format characterization corrected: SRU is plain text
    encoded in ISO 8859-1, NOT XML. iXBRL (XML-based) is the
    Bolagsverket digital annual-report format — a separate artefact
    for a separate authority. Round 1 conflated them.

Deferred (architectural / out of scope, documented in commit):

- Audit-log dead-letter queue / SIEM alert escalation (Art.32 /
  A.8.15): infra setup, not code-PR scope. The warn-on-failure path
  is the in-process surface; durable delivery is a SRE/SIEM concern.
- Secret encryption at rest (CC6.1): PR-1 architectural carryover.
- webhook_url + description redaction in audit_log (Art.5(1)(c)):
  URLs are admin-supplied configuration values; redaction would
  degrade audit reconstructibility without closing a real PII gap.
- PATCH old_state TOCTOU via Postgres function (CC6.3): the read-
  then-write pattern produces an append-only audit row capturing
  the read state; the small race window is non-load-bearing for
  audit purposes and a stored-procedure refactor exceeds the
  cost/value.

23 webhook integration tests pass locally (no regressions). Type-check
clean for all changed files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-501 review round 3 — real cookbook tax errors + cache-control on create

Round 3 closes two tax-impact errors in the cookbooks plus the
consistency gap on the create response. Compliance Swarm's remaining
findings are recurring architectural carryovers or oscillation against
prior rounds.

Real cookbook errors (would mislead integrators):

1. Schablonintäkt rate corrected. Round 2 hardcoded 1.94% — that's the
   2024 rate (SLR 0.94% + 1%). For 2026 SLR is 2.55%, so the rate is
   3.55%. A wrong rate produces a too-low add-back, a too-high
   periodiseringsfond cap, and an IL 30 kap compliance error for any
   integrator copying the cookbook number. Rewrite to describe the
   formula (SLR + 1%, where SLR is the Riksbank statslåneränta on
   30 Nov of the preceding year) with the 2026 figure as an example,
   and note the engine reads the canonical rate from `tax_rates`.

2. SRU format is a TWO-file pair, not one. Round 2 correctly said
   "plain text encoded in ISO 8859-1 (NOT XML)" but described it as a
   single file. Skatteverket requires both INFO.SRU (metadata header)
   AND BLANKETTER.SRU (declaration body) uploaded together — a
   single-file upload is rejected by their validation. Fix the prose
   to describe the two-file pair explicitly.

Code consistency:

3. POST /webhooks (create) now returns the same
   `Cache-Control: no-store, no-cache, must-revalidate, private` +
   `Pragma: no-cache` headers as the rotate-secret endpoint (A.8.12).
   Both endpoints return the HMAC secret exactly once; both need the
   same intermediary-cache prevention.

Smaller cookbook refinements (round 3 bot follow-ups):

4. VAT reconciliation block now includes 2615 (Utgående moms vid
   import, matches ruta 60) — the previous list covered 2611-2645
   but omitted import VAT. A reconciliation that skips 2615 would
   show rutor_match_gl: true falsely for any importer.

5. Service supply-date fallback statement qualified to "one-off
   service supplies where delivery and invoice coincide" — long-
   running service contracts (subscriptions, maintenance) have
   per-delprestation skattskyldighet and need an explicit
   delivery_date per billing cycle.

6. Payroll elder-reduction boundary clarified: "67 years or older
   AT THE START OF the income year (1 January 2026)" — a 66-year-
   old whose 67th birthday falls in February does NOT qualify in
   2026. Prevents misreading the pithy "67+ from 2026" as a
   birthday-during-year rule.

Bot oscillation (skipping with rationale documented here for posterity):

- Compliance Swarm Art.25 now asks to REMOVE webhook_url from DELETE
  old_state — direct contradiction with CC6.3's round-1 ask for
  complete attribution. webhook_url is admin-supplied configuration,
  not PII; keeping it preserves audit reconstructibility.

- Swedish-compliance flags the unconditional re-delete audit row as
  "polluting" the behandlingshistorik — direct contradiction with
  Compliance Swarm V8.2.1 + CC6.3 round-1 / round-2 asks for
  unconditional writes. The audit_log is operational, not BFL
  räkenskapsinformation (which lives on journal_entries and
  related tables under explicit immutability triggers). Audit
  trail completeness wins over BFL purity for this table.

Architectural carryovers (already documented in earlier commit
bodies as deferred to follow-up PRs):

- Secret encryption at rest (CC6.1, recurring)
- Audit-log dead-letter / SIEM alerting (Art.32 / A.8.15, infra)
- webhook_url userinfo stripping (A.8.11 low — URLs are admin-
  configured, no expected credentials; validating at registration
  would be a registration-time concern, not audit-time)

23 webhook integration tests pass. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:31:47 +02:00

625 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { pinnedHttpsFetch, type PinnedFetchResult } from './pinned-fetch'
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 pinned-fetch implementation. */
pinnedFetchImpl?: typeof pinnedHttpsFetch
}): Promise<DispatchSummary> {
const batchSize = args.batchSize ?? 50
const now = args.now ?? new Date()
const pinnedFetchImpl = args.pinnedFetchImpl ?? pinnedHttpsFetch
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,
pinnedFetchImpl,
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)
// Under READ COMMITTED (Postgres default), UPDATE re-evaluates the WHERE
// clause against each row's current value when it acquires the row lock.
// A row that raced from 'in_flight' to 'delivered'/'dead' between scan
// and lock will fail status='in_flight' on re-evaluation and be skipped
// entirely — the immutability trigger never fires, so a mid-flight
// terminal flip cannot abort the bulk update.
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')
.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[]> {
// Atomic FOR UPDATE SKIP LOCKED claim via the SQL function shipped in
// migration 20260515220000. PostgREST can't express SKIP LOCKED through
// the JS client, so the function form is the documented entry point —
// see the migration comment for the full rationale (one round trip,
// no CAS contention, rows locked by a concurrent tick are simply
// invisible to the second caller).
//
// All filter semantics from the previous JS path are preserved inside
// the function: status IN ('pending','failed'), next_attempt_at <= now,
// webhook_id IS NOT NULL, ORDER BY next_attempt_at ASC, LIMIT batchSize.
const { data, error } = await supabase.rpc('claim_due_webhook_deliveries', {
p_batch_size: batchSize,
p_now: now.toISOString(),
})
if (error) {
log.error('claim_due_webhook_deliveries rpc failed', error as Error)
return []
}
return (data ?? []) as DueDelivery[]
}
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> {
// Snapshot before the disable so the audit entry can record the prior
// state. Service-role read; bypasses RLS.
const { data: prior } = await supabase
.from('webhooks')
.select('user_id, company_id, name, active, disabled_at, disabled_reason')
.eq('id', webhookId)
.maybeSingle()
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 })
return
}
// V16 security event log. Auto-disable is a privileged action taken by
// the dispatcher (not a human caller), so actor_id is null. The
// audit_log entry is written UNCONDITIONALLY — even when prior is null
// or prior.user_id is null — because the SECURITY_EVENT must produce
// a durable record (A.8.15 / V16.1.1 / CC7.2). The audit_log.user_id
// column is nullable post-multi-tenant-refactor (20260330130000), so
// a system-initiated event can legitimately write user_id=NULL. Such
// rows are invisible under the user-scoped SELECT policy but remain
// queryable under service-role review, which is appropriate for
// system-initiated events.
//
// The reason discriminates between the three auto-disable paths
// (http_410_gone / redirect_blocked / url_unsafe:<class>) so SIEM
// tooling can alert on systematic patterns.
const p = prior as {
user_id: string | null
company_id: string | null
name: string
active: boolean
disabled_at: string | null
disabled_reason: string | null
} | null
const { error: auditErr } = await supabase.from('audit_log').insert({
user_id: p?.user_id ?? null,
company_id: p?.company_id ?? null,
action: 'SECURITY_EVENT',
table_name: 'webhooks',
record_id: webhookId,
actor_id: null,
description: p
? `Webhook auto-disabled by dispatcher: ${reason} (was "${p.name}")`
: `Webhook auto-disabled by dispatcher: ${reason} (prior snapshot unavailable)`,
old_state: p
? { active: p.active, disabled_at: p.disabled_at, disabled_reason: p.disabled_reason }
: null,
new_state: { active: false, disabled_reason: reason, disabled_at: new Date().toISOString() },
})
if (auditErr) {
log.warn('audit_log insert failed for webhook auto-disable', {
webhookId,
reason,
code: auditErr.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
pinnedFetchImpl: typeof pinnedHttpsFetch
now: Date
}): Promise<AttemptOutcome> {
const { delivery, webhook, pinnedFetchImpl, 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,
})
const { header } = signPayload({
body,
secret: webhook.secret,
timestamp: Math.floor(now.getTime() / 1000),
})
// pinnedHttpsFetch performs DNS validation AND opens the socket against
// the validated IP in a single call. The previous shape (separate
// validateWebhookUrl + fetch calls) left a DNS-rebinding window between
// the two — closed here. SNI + Host header continue to carry the
// original hostname so receiver-side TLS + vhost routing still work.
const result = await pinnedFetchImpl(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,
timeoutMs: REQUEST_TIMEOUT_MS,
maxResponseBytes: MAX_RESPONSE_BODY_BYTES,
})
switch (result.kind) {
case 'unsafe_url':
return {
kind: 'dead',
reason: `url_unsafe:${result.reason}`,
disableWebhook: true,
attempts,
responseStatus: null,
responseBody: null,
responseHeaders: null,
error: result.detail,
}
case 'redirect_blocked':
return {
kind: 'dead',
reason: 'redirect_blocked',
disableWebhook: true,
attempts,
responseStatus: result.status,
responseBody: null,
responseHeaders: null,
error: truncateError(result.detail),
}
case 'timeout':
case 'transport_error':
return {
kind: 'failed',
attempts,
responseStatus: null,
responseBody: null,
responseHeaders: null,
error: truncateError(result.detail),
}
case 'ok': {
const responseHeaders = filterResponseHeaders(result.headers)
const responseBody = isSafeContentType(result.headers['content-type'] ?? '')
? result.body
: null
// HTTP 410 — receiver explicitly asks us to stop. Auto-disable.
if (result.status === 410) {
return {
kind: 'dead',
reason: 'http_410_gone',
disableWebhook: true,
attempts,
responseStatus: 410,
responseBody,
responseHeaders,
}
}
if (result.status >= 200 && result.status < 300) {
return {
kind: 'delivered',
attempts,
responseStatus: result.status,
responseBody,
responseHeaders,
}
}
return {
kind: 'failed',
attempts,
responseStatus: result.status,
responseBody,
responseHeaders,
error: `HTTP ${result.status}`,
}
}
}
}
function truncateError(message: string): string {
return message.length > 500 ? `${message.slice(0, 497)}...` : message
}
// 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']
function isSafeContentType(contentType: string): boolean {
const lower = contentType.toLowerCase()
return SAFE_BODY_CONTENT_TYPE_PREFIXES.some((p) => lower.startsWith(p))
}
// 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 filterResponseHeaders(headers: Record<string, string>): Record<string, string> {
const obj: Record<string, string> = {}
for (const [k, v] of Object.entries(headers)) {
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,
}