Ga naar inhoud

Taak 2.2 — Platform-endpoints en webhook-verzending

Status: uitgevoerd, 1 september 2026. Scope: /api/platform/* op site-template, events.ts in @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.

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 header X-Platform-Key.
  • /health moet werken zonder databaseverbinding en die status apart rapporteren.
  • Webhook-headers: X-Platform-Tenant, X-Platform-Timestamp, X-Platform-Signature = HMAC-SHA256 over timestamp + "." + 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.

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.

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.error als vijfde erbij maar ongebruikt is prima — dit is het schema, niet de bekabeling.
  • platformEventEnvelopeSchema: eventId (UUID), tenantId, occurredAt, plus een discriminated union op type voor de payload per CONTRACT.md. tenantId via tenantSlugSchema.
  • 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 als BETTER_AUTH_SECRET de sessies beschermt).
  • PLATFORM_WEBHOOK_SECRET (server/secret, verplicht).
  • PLATFORM_WEBHOOK_URL (server/secret, optioneel — skip-gedrag zoals RESEND_API_KEY).
  • .env.example bijwerken met dezelfde openssl rand -base64 32-hint als BETTER_AUTH_SECRET. Dockerfile-placeholders en turbo.json passThroughEnv meegenomen, zelfde patroon als BETTER_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-Key tegen PLATFORM_API_KEY vergelijkt in constante tijd (timingSafeEqual, niet === — zelfde soort les als safeRedirectPath) en anders 401 gooit. Elke route hieronder roept ’m aan, zelfde grens-patroon als requireCmsAdmin.

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 eenmalige postgres(DATABASE_URL, { max: 1, connect_timeout: 2 }), daarna end(). Rapporteert { status: "ok", database: "ok" | "unreachable", version }200 ook als de database onbereikbaar is, met database: "unreachable" in de body. version uit package.json van de site-app. Niet getDb.
  • stats.ts (GET): telt pages, services, news; laatste mutatie als max(greatest(created_at, updated_at)) over die drie tabellen. audit_log is 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, fetch met AbortSignal.timeout(...) (korte timeout per contract), vangt elke fout af en logt ’m — nooit een throw die een request-handler raakt. No-op wanneer PLATFORM_WEBHOOK_URL leeg is.
  • content.published / content.deleted: bij publiceren/verwijderen in cms-pages.ts, cms-services.ts, cms-news.ts. Alleen bij de overgang naar published, niet bij een later Opslaan van een al gepubliceerde rij. content.deleted op elke geslaagde delete, ook drafts.
  • form.submitted: in de contact-Action, na een geslaagde form_submissions-insert. Niet bij honeypot.
  • user.login: Better Auth hooks.after in createAuth(), na een geslaagde /sign-in/email via ctx.context.newSession. Types in better-auth 1.6.26 exposen hooks.after en newSession. [...all].ts is ongewijzigd (rate-limit plus handler).
  • Contract: events.ts van “komt in 2.2” naar “bestaat sinds 2.2”; noteren dat site.error wel 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.
  • pnpm turbo run typecheck en pnpm turbo run build groen.
  • curl zonder X-Platform-Key → 401 op alle drie de endpoints.
  • curl mét geldige key → /health 200 met database: "ok". probeDatabase("postgres://u:p@127.0.0.1:1/none") geeft "unreachable" via node --test (postgres-container blijft up).
  • /stats telt pageCount: 3, serviceCount: 2, newsCount: 1 tegen de lokale tenant_demo (komt overeen met de seed).
  • Geïsoleerde signEnvelope-test: zelfde input → zelfde hex; gemuteerde body faalt timingSafeEqual. Listener: postSignedWebhook naar http.createServer in send-event.test.ts (headers + HMAC), geen CMS-publiceren.
  • Een mislukte fetch naar PLATFORM_WEBHOOK_URL (bijv. verkeerde poort) laat de CMS-actie zelf gewoon slagen — bewijst fire-and-forget. postSignedWebhook met throwing fetch en HTTP 500 resolved in node --test; never throws.
  • Regressie: site-template typecheck groen.
  • 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.