Taak 2.2 — Platform-endpoints en webhook-verzending
Status: uitgevoerd, 1 september 2026. Scope:
/api/platform/*opsite-template,events.tsin@platform/contract, en het versturen van HMAC-gesigneerde webhooks. Geen webhook-ontvangst op de master (2.3, inclusief de idempotentie-tabel), geen provisioning (2.5), geen Coolify-deploy van de master, geen backup-werk. Op uitdrukkelijk verzoek (26 augustus 2026) blijft deze taak gericht op een lokaal werkend prototype; deployment en backups staan als losse punten in Taken en blijven daar. Wijkt de uitvoering af, werk dit document bij en noteer de afwijking bij 2.2 in Taken.
Uitgangspunt uit CONTRACT.md
Section titled “Uitgangspunt uit CONTRACT.md”Dit is al vastgelegd, niet opnieuw te beslissen:
- Endpoints op de site:
GET /api/platform/health,GET /api/platform/stats,POST /api/platform/revalidate, afgeschermd met headerX-Platform-Key. /healthmoet werken zonder databaseverbinding en die status apart rapporteren.- Webhook-headers:
X-Platform-Tenant,X-Platform-Timestamp,X-Platform-Signature= HMAC-SHA256 overtimestamp + "." + body. - Verzenden is fire-and-forget met een korte timeout; een bezoeker mag nooit falen omdat een webhook niet aankomt.
- Events dragen geen contentinhoud en geen persoonsgegevens.
Keuzes, met reden
Section titled “Keuzes, met reden”Geen master-ontvanger bouwen, wel bewijzen dat de verzending klopt. 2.3 (“Webhook-ontvangst … met idempotentie en retry-afhandeling”) bestaat nog niet en hoort niet vervroegd te worden — zelfde regel als 2.1 expliciet “geen platform-endpoints” uitsloot. Bewijs voor 2.2 komt daarom uit een wegwerp-HTTP-listener (script, niet gecommit als app-route) die headers en body terugprint, plus een geïsoleerde test van de handtekening-functie zelf (zelfde secret + timestamp + body → zelfde HMAC, en een gemanipuleerde body faalt de vergelijking). Dat is genoeg om “verzenden werkt correct” aan te tonen zonder op de ontvangkant vooruit te lopen.
Secrets als platte astro:env-secrets, geen per-tenant provisioning.
CONTRACT.md beschrijft uiteindelijk een per-site sleutel die versleuteld in de
master-database staat — maar de master schrijft pas tenant-rijen vanaf 2.5.
Voor nu: PLATFORM_API_KEY en PLATFORM_WEBHOOK_SECRET als nieuwe
context: "server", access: "secret"-velden, zelfde patroon als
BETTER_AUTH_SECRET. Éen sleutel per site-container, net als vandaag.
PLATFORM_WEBHOOK_URL optioneel, silently skipped wanneer leeg. Zelfde
patroon als RESEND_API_KEY (mail wordt overgeslagen zonder config). Zonder
een draaiende ontvanger — die is er lokaal pas als je 2.3 zelf ook bouwt of de
wegwerp-listener aanzet — moet de site gewoon blijven werken. Voorkomt ook dat
een lege .env.example-waarde straks een site in productie laat proberen te
posten naar niets.
/revalidate roept purgeWholeSite(cache) aan. Bijgewerkt 27 augustus
2026: de aanname hieronder (geen cachelaag, dus een no-op stub) klopt niet
meer — apps/site-template/src/lib/cache.ts bestaat inmiddels, zie
Cache layer. /revalidate hoeft dus geen eigen
invalidatielogica te verzinnen, alleen de bestaande PUBLIC_TAG-purge achter
de X-Platform-Key-check te hangen.
Niet alle vijf event-types nu aangesloten. content.published,
content.deleted, form.submitted en user.login hebben een duidelijke,
bestaande trigger (CMS-publiceren/verwijderen, contactformulier, inloggen).
site.error vraagt een generieke error-hook door de hele app en hoort beter
bij een taak die foutafhandeling als geheel aanpakt — als los punt genoteerd,
niet stilzwijgend overgeslagen.
Retry-wachtrij met exponentiële backoff blijft buiten scope. CONTRACT.md noemt ’m, maar een lokale wachtrij met backoff-persistentie is precies het soort werk dat hoort bij “productie-robuustheid”, niet bij een lokaal werkend prototype. Fire-and-forget met een timeout dekt de eis dat een bezoeker nooit faalt; de wachtrij is een los punt.
Stappen
Section titled “Stappen”1. Contract — packages/contract/src/events.ts
Section titled “1. Contract — packages/contract/src/events.ts”-
platformEventTypeSchema: enum van de vier aangesloten types (zie hierboven),site.errorals vijfde erbij maar ongebruikt is prima — dit is het schema, niet de bekabeling. -
platformEventEnvelopeSchema:eventId(UUID),tenantId,occurredAt, plus een discriminated union optypevoor de payload per CONTRACT.md.tenantIdviatenantSlugSchema. - Exporteren uit
src/index.ts.
2. Site-secrets — apps/site-template/astro.config.mjs
Section titled “2. Site-secrets — apps/site-template/astro.config.mjs”-
PLATFORM_API_KEY(server/secret, verplicht — beschermt de nieuwe endpoints net alsBETTER_AUTH_SECRETde sessies beschermt). -
PLATFORM_WEBHOOK_SECRET(server/secret, verplicht). -
PLATFORM_WEBHOOK_URL(server/secret, optioneel — skip-gedrag zoalsRESEND_API_KEY). -
.env.examplebijwerken met dezelfdeopenssl rand -base64 32-hint alsBETTER_AUTH_SECRET. Dockerfile-placeholders enturbo.jsonpassThroughEnvmeegenomen, zelfde patroon alsBETTER_AUTH_SECRET.
3. Afscherming — apps/site-template/src/lib/platform/require-platform-key.ts
Section titled “3. Afscherming — apps/site-template/src/lib/platform/require-platform-key.ts”- Eén helper die
X-Platform-KeytegenPLATFORM_API_KEYvergelijkt in constante tijd (timingSafeEqual, niet===— zelfde soort les alssafeRedirectPath) en anders 401 gooit. Elke route hieronder roept ’m aan, zelfde grens-patroon alsrequireCmsAdmin.
4. Endpoints — apps/site-template/src/pages/api/platform/
Section titled “4. Endpoints — apps/site-template/src/pages/api/platform/”-
health.ts(GET): probeert een lichte query (select 1) via een eenmaligepostgres(DATABASE_URL, { max: 1, connect_timeout: 2 }), daarnaend(). Rapporteert{ status: "ok", database: "ok" | "unreachable", version }— 200 ook als de database onbereikbaar is, metdatabase: "unreachable"in de body.versionuitpackage.jsonvan de site-app. NietgetDb. -
stats.ts(GET): teltpages,services,news; laatste mutatie alsmax(greatest(created_at, updated_at))over die drie tabellen.audit_logis niet gebruikt. DB-fout hier is 500. -
revalidate.ts(POST):await purgeWholeSite(context.cache)uit../lib/cache, dan 200 met{ status: "ok" }. - Alle drie de JSON-responses zetten
Cache-Control: no-store.
5. Webhook verzenden — apps/site-template/src/lib/platform/send-event.ts
Section titled “5. Webhook verzenden — apps/site-template/src/lib/platform/send-event.ts”-
signEnvelope(secret, timestamp, body)— pure functie, apart exporteerbaar zodat de test ’m los kan aanroepen. -
sendPlatformEvent(event): bouwt de envelope, signeert,fetchmetAbortSignal.timeout(...)(korte timeout per contract), vangt elke fout af en logt ’m — nooit een throw die een request-handler raakt. No-op wanneerPLATFORM_WEBHOOK_URLleeg is.
6. Bekabeling
Section titled “6. Bekabeling”-
content.published/content.deleted: bij publiceren/verwijderen incms-pages.ts,cms-services.ts,cms-news.ts. Alleen bij de overgang naarpublished, niet bij een later Opslaan van een al gepubliceerde rij.content.deletedop elke geslaagde delete, ook drafts. -
form.submitted: in de contact-Action, na een geslaagdeform_submissions-insert. Niet bij honeypot. -
user.login: Better Authhooks.afterincreateAuth(), na een geslaagde/sign-in/emailviactx.context.newSession. Types in better-auth 1.6.26 exposenhooks.afterennewSession.[...all].tsis ongewijzigd (rate-limit plus handler).
7. Documentatie
Section titled “7. Documentatie”- Contract:
events.tsvan “komt in 2.2” naar “bestaat sinds 2.2”; noteren datsite.errorwel geschematiseerd maar nog niet verzonden wordt. - Datamodel: geen wijziging verwacht (geen nieuwe tabellen in 2.2 — de idempotentie-tabel hoort bij 2.3).
- Taken: 2.2 afvinken met wat er is gebouwd en wat bewust is uitgesteld.
Klaar wanneer
Section titled “Klaar wanneer”-
pnpm turbo run typecheckenpnpm turbo run buildgroen. -
curlzonderX-Platform-Key→ 401 op alle drie de endpoints. -
curlmét geldige key →/health200 metdatabase: "ok".probeDatabase("postgres://u:p@127.0.0.1:1/none")geeft"unreachable"vianode --test(postgres-container blijft up). -
/statsteltpageCount: 3,serviceCount: 2,newsCount: 1tegen de lokaletenant_demo(komt overeen met de seed). - Geïsoleerde
signEnvelope-test: zelfde input → zelfde hex; gemuteerde body faalttimingSafeEqual. Listener:postSignedWebhooknaarhttp.createServerinsend-event.test.ts(headers + HMAC), geen CMS-publiceren. - Een mislukte
fetchnaarPLATFORM_WEBHOOK_URL(bijv. verkeerde poort) laat de CMS-actie zelf gewoon slagen — bewijst fire-and-forget.postSignedWebhookmet throwingfetchen HTTP 500 resolved innode --test; never throws. - Regressie:
site-templatetypecheck groen.
Buiten scope, wel vastgelegd
Section titled “Buiten scope, wel vastgelegd”- Webhook-ontvangst op de master, idempotentie, retry-backoff — taak 2.3.
- Per-tenant sleutelbeheer via de master-database — komt met 2.5 (provisioning); tot dan is één secret per site-container voldoende.
- Coolify-deploy van de master en de bijbehorende env/secrets daar — los punt in Taken, uitdrukkelijk niet nu.
site.error-events — los punt, hoort bij een taak die foutafhandeling als geheel aanpakt.