Ga naar inhoud

Taak 2.3 — Webhook-ontvangst, idempotentie en retry

Status: uitgevoerd, 3 september 2026. (2.2 gemerged, inclusief CSRF-skip op /api/platform/*). Scope: POST /api/webhooks/site op de master, HMAC- en timestamp-check, idempotentie-tabel, schrijven naar activity, en de site-outbox die 2.2 bewust liet liggen. Geen klanten-UI (2.4), geen per-tenant secrets in de master-database (2.5), geen site.error-verzending, geen Coolify-deploy van de master. Wijkt de uitvoering af, werk dit document bij en noteer de afwijking bij 2.3 in Taken.

Dit is al gebouwd, niet opnieuw te beslissen:

  • Envelope en types in packages/contract/src/events.ts. Discriminated union op type. eventId is UUID, tenantId is een slug via tenantSlugSchema.
  • Site stuurt via signEnvelope / postSignedWebhook: HMAC-SHA256 over timestamp + "." + body, headers X-Platform-Tenant, X-Platform-Timestamp, X-Platform-Signature. Timeout 2 s. Elke fout wordt ingeslikt. Lege PLATFORM_WEBHOOK_URL is een no-op.
  • sendPlatformEvent maakt per aanroep een nieuw eventId. Zonder outbox is een herhaalde CMS-actie dus een nieuw event, geen retry.
  • Master heeft activity (nullable tenant_idtenants) en site_stats. Geen receipts-tabel. Geen tenant-rijen tot 2.4. Geen webhook_secret-kolom.
  • Master-auth: requireAdminSession op data-routes. Vue-middleware is navigatie, geen beveiliging. server/api/* is Nitro en loopt niet door die middleware.
  • Les uit PR #13: een Origin-loze machine-POST werd op de site 403 door CSRF. De master heeft geen vergelijkbare check in nuxt.config.ts, maar het bewijs voor 2.3 is een Origin-loze curl naar de webhook. Faalt die met 403, dan is dat dezelfde klasse bug en hoort die in deze taak.

Contract v0 blijft richting. Waar hij botst met de code op main, wint de code en wordt het contract bijgewerkt.

Geen nieuwe packages. Pins blijven die van 2.1/2.2, afgelezen uit de package.json op main (niet opnieuw via npm view gezet, want er is niets te scaffolden):

Pakket Pin Waar
nuxt 4.5.2 apps/master-dashboard
better-auth 1.6.26 master + @platform/db
drizzle-orm / drizzle-kit 0.45.2 / 0.31.10 @platform/db
h3 via Nuxt 4.5.2 readRawBody voor de HMAC over de ruwe body
zod workspace @platform/contract envelope-parse

Testen zoals 2.2: node --test naast de helper, geen testrunner erbij.

Drie vormen, daarna pas handlers.

1. Envelope (bestaat). PlatformEventEnvelope. De ontvanger parst de ruwe body hiermee ná HMAC. Intern daarna vertrouwen.

2. webhook_receipts (master, nieuw). Idempotentie-sleutel is event_id (de UUID uit de envelope), niet een extra surrogate.

Kolom Type Toelichting
event_id uuid, pk envelope.eventId
tenant_id uuid, null, FK tenants gezet als slug bestaat, anders null
received_at timestamptz eerste succesvolle ontvangst

Plus created_at / updated_at. Geen payload hier: die staat in activity. Unique is de pk.

3. webhook_outbox (tenant-database, nieuw). Zelfde envelope, zelfde event_id, tot de master 2xx geeft of de rij ouder is dan 24 uur.

Kolom Type Toelichting
id uuid, pk
event_id uuid, unique zelfde UUID als de envelope
envelope jsonb gevalideerde envelope, niet de gesigneerde HTTP-request
attempt_count int
next_attempt_at timestamptz
last_error text, null

Plus timestamps. Retry herberekent timestamp en HMAC. Een oude handtekening overleeft het replay-venster van vijf minuten niet. De outbox bewaart dus JSON, geen headers.

activity-rij bij verwerking:

  • type = envelope.type
  • occurred_at = envelope.occurredAt
  • tenant_id = lookup op tenants.slug, anders null
  • payload = variantvelden plus tenantSlug en eventId, zodat 2.4 iets kan tonen zonder tenant-rij

Geen site_stats.page_count in deze taak. Events dragen geen tellingen. last_activity_at wél bijwerken als tenant_id bekend is (upsert op de bestaande unique). Onbekende slug: skip, geen ghost-site_stats.

Eén shared secret via runtimeConfig, geen kolom op tenants. 2.2 zette PLATFORM_WEBHOOK_SECRET als container-env omdat de master nog geen tenant-rijen schrijft. 2.5 is de taak die keys in de master-database zet. Een lege webhook_secret_encrypted-kolom nu is dezelfde fout die 2.1 bewust vermeed met de receipts-tabel. Master krijgt NUXT_PLATFORM_WEBHOOK_SECRET, zelfde waarde als de site lokaal. Lookup per slug komt in 2.5; houd de verify-helper daar al een secret-argument voor.

Verworpen: JSON-map slug→secret in env (extra vorm, nog steeds niet 2.5). Verworpen: secret op tenants zonder schrijfpad (2.4/2.5).

Onbekende slug is geen 401. activity.tenant_id is nullable precies voor events zonder tenant-rij. Lokaal bestaan die rijen pas in 2.4. HMAC blijft de grens. Header-X-Platform-Tenant moet gelijk zijn aan body.tenantId; mismatch is 401. Auto-insert van een tenant is 2.4/2.5.

Retry is twee kanten, niet alleen “duplicate HTTP is safe”. De taaknaam zegt ontvangst + idempotentie + retry-afhandeling. 2.2 zette de wachtrij buiten scope en wees naar 2.3 (het plan) én noemde hem een los punt (de keuzes). CONTRACT.md zet de wachtrij op de site, maximaal een etmaal, exponentiële backoff. Zonder outbox maakt sendPlatformEvent bij elke aanroep een nieuw eventId, en een timeout na succes op de master wordt nooit dezelfde envelope. De receipts-tabel is dan alleen nuttig bij identieke HTTP-retries, die fetch niet doet.

Dus: master behandelt redelivery (zelfde eventId → 200, geen tweede activity). De site houdt een outbox bij. Fire-and-forget naar de bezoeker blijft: outbox-insert en drain gooien nooit naar een CMS-actie of contactformulier.

Verworpen: alleen master-idempotentie (events verdwijnen als de master uit staat). Verworpen: in-memory retry (weg bij restart, nieuw eventId als sendPlatformEvent opnieuw loopt).

Transactionele outbox, niet try-then-enqueue. Eerst envelope in webhook_outbox (zelfde eventId), dan meteen één send, daarna drain op next_attempt_at. Crash tussen CMS-write en send verliest het event niet. Lege PLATFORM_WEBHOOK_URL: geen insert, huidige no-op.

HMAC-formule niet naar @platform/contract. 2.2 hield crypto uit dat package. Geen nieuw package. Master krijgt verifySignedWebhook met dezelfde formule als signEnvelope, timingSafeEqual, en dezelfde test- vectoren (secret / timestamp / body → hex). Geen import uit site-template (dat trekt astro:env mee).

Ruwe body voor HMAC, nooit readBody() eerst. De site signeert JSON.stringify van de envelope. De master hasht de bytes zoals ze binnenkomen. Herbepakken van JSON breekt de handtekening.

4xx niet retrien, 5xx en netwerk wel. Ongeldige JSON of schema na geldige HMAC is 400: opnieuw sturen helpt niet. Foute HMAC of timestamp of tenant-mismatch is 401. Alleen 5xx en netwerkfouten gaan terug de outbox in. Duplicate is 200 { status: "ok" }, zodat een retry na timeout stopt.

Geen UI, geen degraded-status, geen periodieke /stats-pull. 2.4 toont activiteit. CONTRACT’s “24 uur geen events én geen healthcheck → degraded” is dashboardlogica. Reconciliatie-pull is de vangnet-zin in het contract, niet deze taak.

Geen Better Auth op deze route. HMAC is de grens. Niet requireAdminSession aanroepen.

Volgorde is twee verifieerbare eenheden. Eerst de ontvanger (curl bewijs), dan de outbox (dicht-poort → herstel). Niet omgekeerd: de drain heeft een 2xx-doel nodig.

  • Tabel in packages/db/src/master/schema.ts zoals hierboven.
  • pnpm --filter @platform/db run generate:master en de SQL committen.
  • Datamodel: de zin “komt in 2.3” vervangen door de echte kolommen.
  • Tenant-migraties blijven vrij van master-tabellen (drizzle-kit check op de tenant-config).
  • runtimeConfig.platformWebhookSecret in nuxt.config.ts, gevuld door NUXT_PLATFORM_WEBHOOK_SECRET.
  • .env.example met dezelfde openssl rand -base64 32-hint.
  • turbo.json passThroughEnv: NUXT_PLATFORM_WEBHOOK_SECRET.
  • Scoped rule master-dashboard.mdc: de nieuwe key noemen naast de bestaande NUXT_*.

3. Verify-helper — apps/master-dashboard/server/utils/webhooks.ts

Section titled “3. Verify-helper — apps/master-dashboard/server/utils/webhooks.ts”

Pure functies, testbaar zonder Nuxt:

  • signEnvelope / verifySignedWebhook (of alleen verify + dezelfde hex-formule). Timestamp als unix-seconden-string. Weiger als de timestamp ouder is dan 300 s. Weiger ook een timestamp meer dan 60 s in de toekomst (CONTRACT noemt alleen “ouder”; een verre toekomst zou het replay-venster anders omzeilen).
  • timingSafeEqual op gelijke byte-lengte; verschillende lengte is mismatch, geen throw.
  • node --test naast het bestand: zelfde vector als send-event.test.ts; gemuteerde body faalt; oude timestamp faalt; toekomst > 60 s faalt.

4. Route — apps/master-dashboard/server/api/webhooks/site.post.ts

Section titled “4. Route — apps/master-dashboard/server/api/webhooks/site.post.ts”

POST /api/webhooks/site, zoals Contract.

  • readRawBody(event) eerst. Ontbrekende body → 400.
  • Headers aanwezig? Anders 401. Signature verifiëren tegen platformWebhookSecret. Falende HMAC of timestamp → 401, geen onderscheid in de body (geen oracle).
  • JSON parse + platformEventEnvelopeSchema.safeParse. Fout → 400.
  • X-Platform-Tenant === envelope.tenantId, anders 401.
  • Lookup tenants.slug. Geen rij is oké (tenant_id null).
  • Transactie: insert webhook_receipts on conflict do nothing returning. Geen returning-rij → al verwerkt, 200 { status: "ok" } zonder tweede activity. Wel een rij → activity insert, optioneel site_stats.last_activity_at. Commit. Daarna 200.
  • Onverwachte DB-fout → 500, zodat de outbox retriet.
  • Cache-Control: no-store.
  • Bewijs: Origin-loze curl (geen Origin-header) is níet 403.
  • Tabel in packages/db/src/schema.ts zoals hierboven. Jsonb getypeerd als PlatformEventEnvelope.
  • pnpm --filter @platform/db exec drizzle-kit generate en committen.
  • Datamodel tenant-sectie bijwerken.

6. Site-outbox — apps/site-template/src/lib/platform/

Section titled “6. Site-outbox — apps/site-template/src/lib/platform/”
  • sendPlatformEvent blijft fire-and-forget naar de caller. Na geldige envelope: insert outbox (on conflict event_id do nothing), daarna één postSignedWebhook. Succes → rij weg. 4xx (niet 5xx) → rij weg (gift payload). Netwerk/5xx → attempt_count++, next_attempt_at = now + min(2^n, cap) seconden, last_error zetten.
  • postSignedWebhook moet de caller de uitkomst geven (ok / 4xx / 5xx / throw). De huidige void-en-slik-alles-vorm kan intern blijven voor tests, maar de outbox moet het verschil zien. Bestaande tests (throwing fetch, HTTP 500, listener) blijven groen en mogen niet gaan throwen naar CMS-acties.
  • Drain: rijen met next_attempt_at <= now() en jonger dan 24 uur. Ouder dan 24 uur: delete. Geen job-framework. Een setInterval in het Node-proces (Coolify = één container) plus een aanroep vanuit sendPlatformEvent is genoeg. Idle site zonder interval zou nooit drainen.
  • Lege PLATFORM_WEBHOOK_URL: geen insert, geen drain-send.
  • Contract: ontvanger bestaat sinds 2.3; replay-venster en “toekomst > 60 s”; outbox op de tenant-database; 4xx niet retrien; secret blijft container-env tot 2.5. Statusregel “v0, provisioneel” mag blijven tot 2.5 de per-site keys echt zet.
  • Architectuur: één zin dat site → master nu een echte route is, niet alleen een contractzin.
  • Taken: 2.3 afvinken met wat er gebouwd is en wat bewust is blijven liggen.
  • pnpm turbo run typecheck en pnpm turbo run build groen.
  • Master-migratie: webhook_receipts bestaat in platform_master. Tenant-migratie: webhook_outbox bestaat in tenant_demo.
  • node --test op de HMAC-helper: vector match, gemuteerde body faalt, timestamp buiten venster faalt.
  • Origin-loze curl zonder headers → 401.
  • curl met geldige HMAC (zelfde formule als signEnvelope) → 200 en één activity-rij. tenant_id null zolang er geen tenants-rij is. Tweede POST met hetzelfde eventId → 200 en nog steeds één activity-rij.
  • Header-tenant ≠ body-tenantId → 401, geen receipt.
  • Timestamp 400 s in het verleden → 401.
  • Ongeldige JSON met geldige HMAC over die bytes → 400, geen receipt.
  • Site: PLATFORM_WEBHOOK_URL naar de master, CMS-publicatie → outbox leeg na succes, activity.type = content.published. Afwijking: bewezen via enqueuePlatformEvent + drainWebhookOutbox (zelfde pad als sendPlatformEvent), niet via een CMS-klik in de browser.
  • Site: URL naar een dichte poort, publicatie slaagt voor de redacteur, outbox-rij blijft, attempt_count >= 1. Master aanzetten, drain, rij weg, één activity.
  • Outbox-rij ouder dan 24 uur verdwijnt zonder send (unit/helper-test met geïnjecteerde klok of created_at in het verleden).
  • Regressie: node --test apps/site-template/src/lib/platform/*.test.ts en de CSRF-tests blijven groen. requireAdminSession wordt niet aangeroepen vanuit de webhook-route (grep in review).
  • Klantenoverzicht, detail, activiteit-UI — taak 2.4. Deze taak schrijft activity; 2.4 leest hem.
  • Per-tenant webhook_secret_encrypted en provisioning van env — 2.5. De verify-helper neemt secret al als argument zodat 2.5 de lookup wisselt, niet de crypto.
  • Coolify-deploy van de master — los punt in Taken.
  • site.error verzenden — schema bestaat, ontvanger accepteert het, emit blijft een los punt.
  • Periodieke pull van /api/platform/stats — contractvangnet, niet nodig om ontvangst te bewijzen.
  • tenant_status = degraded na 24 uur stilte — 2.4.
  • Master in de Coolify-backuplijst — los punt, ongewijzigd.
  • Live PLATFORM_WEBHOOK_URL op pilot/rb-media — pas zinvol als de master ergens luistert. Tot de master-deploy blijft de URL leeg, zoals 2.2.