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/siteop de master, HMAC- en timestamp-check, idempotentie-tabel, schrijven naaractivity, 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), geensite.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.
Uitgangspunt op main
Section titled “Uitgangspunt op main”Dit is al gebouwd, niet opnieuw te beslissen:
- Envelope en types in
packages/contract/src/events.ts. Discriminated union optype.eventIdis UUID,tenantIdis een slug viatenantSlugSchema. - Site stuurt via
signEnvelope/postSignedWebhook: HMAC-SHA256 overtimestamp + "." + body, headersX-Platform-Tenant,X-Platform-Timestamp,X-Platform-Signature. Timeout 2 s. Elke fout wordt ingeslikt. LegePLATFORM_WEBHOOK_URLis een no-op. sendPlatformEventmaakt per aanroep een nieuweventId. Zonder outbox is een herhaalde CMS-actie dus een nieuw event, geen retry.- Master heeft
activity(nullabletenant_id→tenants) ensite_stats. Geen receipts-tabel. Geen tenant-rijen tot 2.4. Geenwebhook_secret-kolom. - Master-auth:
requireAdminSessionop 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-lozecurlnaar 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.
Vastgestelde versies
Section titled “Vastgestelde versies”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.
Data shapes
Section titled “Data shapes”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.typeoccurred_at= envelope.occurredAttenant_id= lookup optenants.slug, anders nullpayload= variantvelden plustenantSlugeneventId, 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.
Keuzes, met reden
Section titled “Keuzes, met reden”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.
Stappen
Section titled “Stappen”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.
1. Master-schema — webhook_receipts
Section titled “1. Master-schema — webhook_receipts”- Tabel in
packages/db/src/master/schema.tszoals hierboven. -
pnpm --filter @platform/db run generate:masteren de SQL committen. - Datamodel: de zin “komt in 2.3” vervangen door de echte kolommen.
- Tenant-migraties blijven vrij van master-tabellen (
drizzle-kit checkop de tenant-config).
2. Secret — apps/master-dashboard
Section titled “2. Secret — apps/master-dashboard”-
runtimeConfig.platformWebhookSecretinnuxt.config.ts, gevuld doorNUXT_PLATFORM_WEBHOOK_SECRET. -
.env.examplemet dezelfdeopenssl rand -base64 32-hint. -
turbo.jsonpassThroughEnv:NUXT_PLATFORM_WEBHOOK_SECRET. - Scoped rule
master-dashboard.mdc: de nieuwe key noemen naast de bestaandeNUXT_*.
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). -
timingSafeEqualop gelijke byte-lengte; verschillende lengte is mismatch, geen throw. -
node --testnaast het bestand: zelfde vector alssend-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_idnull). - Transactie:
insert webhook_receipts on conflict do nothing returning. Geen returning-rij → al verwerkt, 200{ status: "ok" }zonder tweedeactivity. Wel een rij →activityinsert, optioneelsite_stats.last_activity_at. Commit. Daarna 200. - Onverwachte DB-fout → 500, zodat de outbox retriet.
-
Cache-Control: no-store. - Bewijs: Origin-loze
curl(geenOrigin-header) is níet 403.
5. Tenant-schema — webhook_outbox
Section titled “5. Tenant-schema — webhook_outbox”- Tabel in
packages/db/src/schema.tszoals hierboven. Jsonb getypeerd alsPlatformEventEnvelope. -
pnpm --filter @platform/db exec drizzle-kit generateen 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/”-
sendPlatformEventblijft fire-and-forget naar de caller. Na geldige envelope: insert outbox (on conflict event_id do nothing), daarna éénpostSignedWebhook. Succes → rij weg. 4xx (niet 5xx) → rij weg (gift payload). Netwerk/5xx →attempt_count++,next_attempt_at = now + min(2^n, cap) seconden,last_errorzetten. -
postSignedWebhookmoet 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. EensetIntervalin het Node-proces (Coolify = één container) plus een aanroep vanuitsendPlatformEventis genoeg. Idle site zonder interval zou nooit drainen. - Lege
PLATFORM_WEBHOOK_URL: geen insert, geen drain-send.
7. Documentatie
Section titled “7. Documentatie”- 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.
Klaar wanneer
Section titled “Klaar wanneer”-
pnpm turbo run typecheckenpnpm turbo run buildgroen. - Master-migratie:
webhook_receiptsbestaat inplatform_master. Tenant-migratie:webhook_outboxbestaat intenant_demo. -
node --testop de HMAC-helper: vector match, gemuteerde body faalt, timestamp buiten venster faalt. - Origin-loze
curlzonder headers → 401. -
curlmet geldige HMAC (zelfde formule alssignEnvelope) → 200 en éénactivity-rij.tenant_idnull zolang er geentenants-rij is. Tweede POST met hetzelfdeeventId→ 200 en nog steeds éénactivity-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_URLnaar de master, CMS-publicatie → outbox leeg na succes,activity.type = content.published. Afwijking: bewezen viaenqueuePlatformEvent+drainWebhookOutbox(zelfde pad alssendPlatformEvent), 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, éénactivity. - Outbox-rij ouder dan 24 uur verdwijnt zonder send (unit/helper-test
met geïnjecteerde klok of
created_atin het verleden). - Regressie:
node --test apps/site-template/src/lib/platform/*.test.tsen de CSRF-tests blijven groen.requireAdminSessionwordt niet aangeroepen vanuit de webhook-route (grep in review).
Buiten scope, wel vastgelegd
Section titled “Buiten scope, wel vastgelegd”- Klantenoverzicht, detail, activiteit-UI — taak 2.4. Deze taak schrijft
activity; 2.4 leest hem. - Per-tenant
webhook_secret_encrypteden provisioning van env — 2.5. De verify-helper neemtsecretal als argument zodat 2.5 de lookup wisselt, niet de crypto. - Coolify-deploy van de master — los punt in Taken.
site.errorverzenden — 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 = degradedna 24 uur stilte — 2.4.- Master in de Coolify-backuplijst — los punt, ongewijzigd.
- Live
PLATFORM_WEBHOOK_URLop pilot/rb-media — pas zinvol als de master ergens luistert. Tot de master-deploy blijft de URL leeg, zoals 2.2.