Ga naar inhoud

Contract master ↔ klantsite

Status: v0, provisioneel. Opgesteld in fase 0 op basis van wat fase 2 nodig zal hebben. Details zullen wijzigen zodra het master dashboard daadwerkelijk gebouwd wordt. Behandel dit als richting, niet als vastgestelde API.

Master dashboard en klantsites zijn losse deployments, maar delen één definitie van types en schema’s: @platform/contract. Elke wijziging aan de koppeling begint daar, niet in een van de apps.

packages/contract/src/
├── theme.ts # themeSchema
├── blocks.ts # block-union + per-blok-schema's
├── content.ts # page, service, news, testimonial
├── platform.ts # tenant, domain, deployment, activity (bestaat sinds 2.1)
└── events.ts # webhook-events master ↔ site (bestaat sinds 2.2)

platform.ts bevat ook tenantSlugSchema: dezelfde regel die tenant.ts gebruikt voor tenantId en die provision-tenant-db.sh in bash herhaalt. activity.type is er bewust géén enum. Die waardes komen uit events.ts.

Regel: als een type beide kanten raakt, staat het hier. Nooit lokaal herdefiniëren.

Richting Mechanisme Wanneer
Master → site HTTP-verzoek met API-key Op verzoek: statusopvraging, herindexering, cache-invalidatie
Site → master HMAC-gesigneerde webhook Bij gebeurtenissen: content gepubliceerd, gebruiker ingelogd, formulier ontvangen
Master → Coolify Coolify v4 API met bearer-token Provisioning en deploys
Master → Cloudflare Cloudflare for SaaS API Custom hostnames en TLS

Statistieken worden gepusht door de site, niet gepolld door de master. Reden: de master hoeft dan geen databasetoegang tot elke tenant te hebben. Een periodieke pull blijft bestaan als reconciliatie voor het geval webhooks zijn gemist.

Master → site. Header X-Platform-Key met een per-site gegenereerde sleutel. De sleutel staat als env-variabele in de site-container en versleuteld in de master-database.

Site → master. Elke webhook draagt:

  • X-Platform-Tenant — de tenant-slug
  • X-Platform-Timestamp — unix-seconden als decimale string
  • X-Platform-Signature — HMAC-SHA256 over timestamp + "." + body, lowercase hex, met het per-site secret (webhook_secret_encrypted in de master, anders het gedeelde NUXT_PLATFORM_WEBHOOK_SECRET)

De master weigert verzoeken met een timestamp ouder dan vijf minuten of meer dan zestig seconden in de toekomst (replay-bescherming) en vergelijkt handtekeningen in constante tijd.

mTLS is voor deze schaal overkill.

Alle onder /api/platform/, afgeschermd met X-Platform-Key.

Methode Pad Doel
GET /api/platform/health Draait de site, is de database bereikbaar, welke versie draait er
GET /api/platform/stats Aantal pagina’s, diensten, nieuwsitems, laatste mutatie
POST /api/platform/revalidate Cache leegmaken na een externe wijziging

/health moet werken zonder databaseverbinding en die status apart rapporteren, zodat de master onderscheid kan maken tussen “site plat” en “database plat”.

Methode Pad Doel
POST /api/webhooks/site Ontvangt gebeurtenissen van klantsites (bestaat sinds 2.3)
GET / POST /api/tenants Klantenoverzicht en een ledger-rij aanmaken (bestaat sinds 2.4)
GET / PATCH /api/tenants/:slug Detail met activiteit en handmatige statuswijziging (bestaat sinds 2.4)
POST /api/tenants/:slug/provision Idempotente database-/secrets-/Coolify-provisioning (bestaat sinds 2.5)
POST /api/tenants/:slug/domains Custom hostname registreren (201 nieuw, 200 hervat). Leeg CF-token: unregistered + CNAME (sinds 2.7)
POST /api/tenants/:slug/domains/:id/verify Cloudflare-status pollen; geen Coolify-PATCH, geen SITE_DOMAIN-cutover
DELETE /api/tenants/:slug/domains/:id Custom hostname weg (CF DELETE, rij weg, Coolify-lijst herberekend)
POST /api/tenants/:slug/database Bestaande tenant-database koppelen; 409 als er al een staat, 422 bij verkeerde databasenaam, onbereikbaar of site.tenant_id van een andere slug (sinds 2.8)
GET /api/migrations Schemastand per tenant (live) tegen de gebundelde migraties, plus de laatste 10 runs (sinds 2.8)
POST /api/migrations/runs Migraties toepassen op alle tenants met een database, of { slugs } als subset; 200 met de run, 409 als er al een run loopt (sinds 2.8)

De ontvanger zoekt de tenant op slug, kiest precies één HMAC-sleutel (per-tenant ciphertext als die kolom niet null is, anders NUXT_PLATFORM_WEBHOOK_SECRET), weigert timestamps ouder dan vijf minuten of meer dan zestig seconden in de toekomst, en parst daarna platformEventEnvelopeSchema. X-Platform-Tenant moet gelijk zijn aan envelope.tenantId. Een al verwerkt eventId geeft 200 zonder tweede activity-rij. Zodra webhook_secret_encrypted is gezet, faalt een handtekening met het gedeelde secret.

De tenant-routes vereisen een master-dashboard-sessie en retourneren nooit *_encrypted kolommen. Aanmaken registreert alleen de klant met status provisioning. POST /api/tenants/:slug/provision accepteert provisioning en failed, weigert active/suspended met 409, en antwoordt 202 met het browser-veilige detail. Zonder Coolify-token stopt de run na secrets (tenant.provision_partial); status blijft provisioning. Een fout ná start van de orchestrator schrijft tenant.provision_failed. Alleen een Coolify start-fout zet tenants.status=failed — niet CoolifyDisabledError. Het detailscherm toont een Provisioneren-knop bij provisioning en failed.

Custom domains (2.7) schrijven één domains-rij type: custom. Het dashboard leest een state-union (unregistered / pending / active / failed); preview-rijen blijven state: preview. POST …/domains is idempotent op hostname. 400 bij ongeldige hostname, 409 bij een hostname van een andere tenant, een preview-rij, of een Cloudflare-account buiten onze zone, 502 bij Cloudflare HTTP. active is edge-TLS, niet canonieke SITE_DOMAIN. Het CMS blijft op admin.{previewHost} tot een latere cutover. Leeg NUXT_CF_API_TOKEN is geen 503: de rij blijft staan en de CNAME komt uit NUXT_CF_CNAME_TARGET.

Migraties (2.8): platformMigrationOverviewSchema en platformMigrationRunSchema zijn de leesvormen, tenantSchemaStateSchema de state-union per tenant (no_database / unreachable / blocked / pending / up_to_date). De runner leest de schemastand live uit de tenant-database en slaat alleen runs op. Een run stopt niet bij één mislukte tenant; failedCount > 0 is het signaal. blocked (ahead, diverged, identity_mismatch) wordt nooit gemigreerd. Het antwoord op POST /api/tenants/:slug/database bevat, zoals elk tenant-antwoord, nooit de DSN; het detail draagt alleen hasDatabase: boolean.

type PlatformEvent =
| { type: 'content.published'; entity: 'page' | 'service' | 'news'; entityId: string; slug: string }
| { type: 'content.deleted'; entity: 'page' | 'service' | 'news'; entityId: string }
| { type: 'form.submitted'; formType: string }
| { type: 'user.login'; userId: string }
| { type: 'site.error'; message: string; context?: Json }

Elke event heeft daarnaast tenantId, occurredAt en een eventId (UUID) voor idempotentie: de master negeert een eventId die al verwerkt is.

Events bevatten geen contentinhoud en geen persoonsgegevens uit formulierinzendingen. Alleen metadata. De inzending zelf blijft in de tenant-database.

site.error staat op het schema sinds 2.2 maar wordt nog niet verzonden. Bekabeling van content.published, content.deleted, form.submitted en user.login zit in de site. Een generieke error-hook hoort bij een latere taak.

  • De site laat nooit een bezoeker falen omdat een webhook niet aankomt. Verzenden gebeurt fire-and-forget met een korte timeout.
  • Mislukte webhooks gaan in webhook_outbox op de tenant-database en worden opnieuw geprobeerd met exponentiële backoff, maximaal een etmaal. Retry herberekent timestamp en HMAC. 4xx stopt (ongeldige payload of HMAC); 5xx en netwerkfouten retrien.
  • Sinds 2.4 toont de master quiet als niet-opgeslagen aandachtssignaal bij een actieve of mislukte tenant zonder activiteit in de laatste 24 uur. degraded blijft gereserveerd voor de combinatie van stilte en een ontbrekende/mislukte healthcheck, zodra provisioning een hostname en platform-key beschikbaar maakt.

@platform/contract krijgt een expliciet versienummer. Bij een breaking change:

  1. Nieuwe vorm toevoegen naast de oude.
  2. Alle klantsites uitrollen.
  3. Oude vorm verwijderen.

Sites en master worden nooit gelijktijdig omgezet — bij tientallen deployments is een big-bang-migratie geen realistische aanname.

  • Sinds 2.8 heeft de master de tenant-DSN’s en gebruikt die voor migraties en de schemastand (drizzle.__drizzle_migrations, site.tenant_id). Voor content-rapportages blijft de voorkeur de site-API, zodat het contentmodel geïsoleerd blijft.
  • Hoe worden per-site secrets geroteerd zonder downtime?
  • Hoe rapporteert een site zijn eigen template-versie, zodat het dashboard achterlopende deployments kan tonen?