b94ed3bec2
* 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öneavdrag 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>
625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
/**
|
||
* 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,
|
||
}
|