Architectuur en multi-tenancy
Overzicht
┌───────────────────────── Eén Next.js-applicatie (Vercel) ─────────────────────────┐
bezoeker ──► │ proxy.ts: domein → klant │
klantdomein │ noordlicht-installatie.nl ──► /sites/<klant>/… (publieke website, blokken) │
│ │
ondernemer ──► │ /dashboard (klantomgeving) /m (mobiele PWA) /editor-preview (bouwer) │
jij (eigenaar)► │ /admin (platformbeheer – aparte rol, aparte layout) │
│ │
│ Server actions ──► auth.ts (sessie, rol, pakket) ──► repo.ts scoped(tenantId) │
│ agent/ (beleid → runtime → Claude / demo) │
│ integrations/ (connector-contract, kluis, retries) │
└───────────────┬──────────────────────┬──────────────────────┬──────────────────────┘
│ │ │
Supabase Postgres + RLS Supabase Storage Externe diensten
(één database, tenant_id) (map per tenant) Claude, Resend, HubSpot, Exact, …
Multi-tenant: één database, gedeelde tabellen, tenant_id overal
Onderzochte opties:
| Model | Voordelen | Nadelen | Oordeel |
|---|---|---|---|
Eén database, gedeelde tabellen + tenant_id + RLS |
Goedkoop, één migratie voor iedereen, eenvoudig rapporteren | Scheiding moet streng afgedwongen worden | Gekozen voor alle standaardklanten |
| Schema per klant | Iets sterkere scheiding | Migraties × aantal klanten, lastig in Supabase | Niet nodig |
| Database/project per klant | Maximale scheiding, eigen regio/back-up | Duur (Supabase rekent compute per project), beheer × N | Alleen voor enterprise-klant die het contractueel eist |
Hybride route: standaard gedeeld; een grote klant kan later een eigen Supabase-project
krijgen. Omdat alle code via repo.ts loopt, verandert alleen de verbinding.
Hoe klantgegevens strikt gescheiden blijven
| Laag | Maatregel | In de demo |
|---|---|---|
| Database | Elke tabel heeft tenant_id; Row Level Security staat alleen rijen toe waar tenant_id = tenant van de ingelogde gebruiker (supabase/migrations/0001_init.sql). |
Nagebootst in scoped() |
| Queries | Eén toegangspunt: scoped(tenantId). Lezen filtert, wijzigen/verwijderen weigert records van anderen (NotFound), invoegen stempelt de tenant. |
✅ + tests |
| API / server actions | tenantId komt uit de server-side sessie, nooit uit formulier of URL. Elke actie controleert rol en pakket opnieuw. |
✅ |
| Bestanden | Upload-map per tenant; pad gevalideerd; productie: Storage-policy op tenant_id/…. |
✅ |
| Achtergrondtaken | Elke taak krijgt expliciet een tenantId mee en werkt via scoped(); nooit "alle klanten tegelijk" zonder platformrol. |
✅ (after() bij formulieren) |
| AI-context | Agent-tools zijn gesloten over de scope van de klant; het model kan geen tenantId kiezen. Alleen goedgekeurde kennisbronnen van díe klant gaan mee. Promptcache per klant. | ✅ |
| Integraties | Tokens versleuteld per koppeling (AES-256-GCM, vault.ts), nooit naar browser of AI. |
Code klaar, demo zonder echte tokens |
| Platformbeheer | Aparte rol platform_admin, aparte layout en aparte functies (platform.ts); kan geen tokens inzien. |
✅ |
| Logging | Audit-log met tenant_id; platformbeheer ziet alles, klant alleen eigen regels. | ✅ |
Websitebouwer
- Een pagina = een lijst blokken (
{ id, type, props }) als JSON;draftenpublishedgescheiden. - Publiceren kopieert
draft→publisheden bewaart een versie (max. 20); terugzetten zet een versie terug als concept. - Bloktypes staan in één schema (
lib/blocks.ts); de editor bouwt zijn formulieren daaruit op. Nieuw bloktype = schema + render-component. - De preview draait in een iframe met dezelfde componenten als de live site, zodat
responsive gedrag klopt; wijzigingen gaan live via
postMessage(alleen same-origin). - Server-side validatie: alleen bekende bloktypes, geen
javascript:-links, alleen geüploade of https-afbeeldingen.
Uitbreidingspad: kolommen/secties met kinderen (geneste blokken), globale blokken
(header/footer per klant), meertaligheid (props.nl, props.en), A/B-varianten,
gedeelde blokbibliotheek per branche, en eventueel een vrij canvas met
@dnd-kit voor drag-and-drop binnen kolommen.
Overstappen, exporteren, loskoppelen
- Exporteren:
/api/exportlevert alle gegevens van de klant als JSON (pagina's, diensten, aanvragen, offertes, klanten, berichten, AI-log, koppelingstatus) – zonder tokens. Uitbreiding: CSV per tabel en een statische HTML-export van de website. - Loskoppelen: koppeling verbreken verwijdert tokens; bij de leverancier trekt de klant de toestemming in (OAuth-app). Logregel wordt bewaard.
- Opzeggen: status
opgezegd→ site offline, data 30 dagen bewaard voor export, daarna definitief verwijderd (vastleggen in verwerkersovereenkomst). - Domein: blijft eigendom van de klant; alleen een CNAME-record wijst naar het platform.
Eigen domein koppelen (productie)
- Klant (of jij) voegt het domein toe aan het Vercel-project (later automatisch via de Vercel API).
- DNS:
www→ CNAMEcname.vercel-dns.com; apex-domein → A-record volgens Vercel. - Vercel regelt SSL automatisch.
tenants.domaininvullen;proxy.tsherkent het domein en toont de juiste site.- Voorbeeldlink vóór livegang:
<slug>.<NEXT_PUBLIC_ROOT_DOMAIN>(wildcard-domein op Vercel).