Ga naar inhoud

De klantsite van binnen

apps/site-template is één Astro 7-app in SSR-modus (Node-adapter, poort 4321) met twee gezichten: de publieke site op SITE_DOMAIN en het CMS op admin.<SITE_DOMAIN>. Per klant draait er één container van dezelfde image; alleen de omgevingsvariabelen verschillen. Deze pagina volgt een verzoek door de app en wijst aan waar de code staat.

Alle omgevingsvariabelen staan als schema in astro.config.mjs en worden geïmporteerd uit astro:env/server, nooit uit process.env. Twee soorten:

Soort Voorbeelden Wanneer gelezen
access: "secret" TENANT_ID, DATABASE_URL, SITE_DOMAIN, BETTER_AUTH_SECRET, PLATFORM_API_KEY, PLATFORM_WEBHOOK_SECRET, S3-sleutels, RESEND_API_KEY Runtime, bij de start van de container
access: "public" EMAIL_FROM, S3_BUCKET, S3_REGION, S3_PUBLIC_URL, S3_FORCE_PATH_STYLE Build, ingebakken in de image

Daarom kan één image alle klanten dienen, en daarom kost het wijzigen van een public variabele een rebuild van iedereen. Zie Deploy.

src/middleware.ts beslist per verzoek, in deze volgorde:

  1. Cross-origin form-POST? Weigeren met 403. Dit vervangt Astro’s eigen checkOrigin, die achter de proxy het protocol verkeerd ziet (src/lib/csrf.ts).
  2. Statisch bestand of /api/platform/*? Doorlaten zonder sessie-lookup; de platform-API controleert zelf de X-Platform-Key.
  3. Sessie ophalen via Better Auth en op context.locals zetten.
  4. Acties (/_actions) lopen op beide hosts en autoriseren zichzelf.
  5. Publieke host: /admin/... en de auth-pagina’s worden doorgestuurd naar de CMS-host; alle andere paden gaan door naar de publieke pagina’s.
  6. CMS-host: auth-pagina’s zijn zonder sessie bereikbaar; alles anders vereist een gebruiker met CMS-toegang, anders volgt een redirect naar /login. Schone URL’s zoals /diensten worden intern herschreven naar /admin/diensten; onbekende paden gaan naar de CMS-home.
  7. Security-headers op elke response (src/lib/security-headers.ts).

De hostnames komen uit src/lib/hosts.ts: publicHost() haalt protocol en www. weg, adminHost() zet er admin. voor. De regel daarvoor (adminHostnameFor) staat in het contract, zodat het dashboard dezelfde CMS-host afleidt. Lokaal wordt dat admin.localhost:4321.

Publiek (src/pages/): / (home), /[slug] (pagina’s zoals over-ons), /diensten en /diensten/[slug], /nieuws en /nieuws/[slug], /referenties, /contact, plus /robots.txt en /sitemap.xml. Elke publieke pagina roept na de 404-checks cachePublicRoute(Astro.cache, [tags]) aan; dat is de opt-in voor de route-cache.

CMS (src/pages/admin/), bereikbaar via de secties uit src/lib/cms-nav.ts: Dashboard, Pagina’s, Diensten, Nieuws, Referenties, Media, Navigatie en Instellingen (alleen beheerder). Elke sectie heeft een overzicht, een new-pagina, een detail [id] en voor content met blokken een blok-editor [id]/blocks/[blockId]. Een sectie toevoegen aan cmsNavItems maakt hem meteen routeerbaar; de middleware leidt zijn prefixes daaruit af.

Auth-pagina’s: /login, /logout, /forgot-password, /reset-password. API: /api/auth/[...all] (Better Auth) en /api/platform/health, /api/platform/stats en /api/platform/revalidate voor het dashboard.

  1. loadPublicSite() (src/lib/site.ts) haalt de site-singleton (naam, theme, SEO-defaults) en de navigatie op met getDb(DATABASE_URL).
  2. De pagina haalt zijn eigen content op (bijvoorbeeld de pages-rij op slug) en zet de media-id’s in de blokken om naar afbeeldingen (src/lib/media.ts).
  3. Layout.astro zet themeToCssVarsString(theme) als style op <html>. Vanaf dat moment bestaan --color-primary, --font-heading, --radius-md en de andere tokens; global.css koppelt ze via @theme inline aan Tailwind-utilities.
  4. BlockRenderer.astro uit @platform/blocks valideert de blokken één voor één (parseBlocksForRender) en rendert per blok de bijbehorende component. Ongeldige blokken worden overgeslagen en gelogd; in dev-modus tonen ze een UnknownBlock.
  5. SEO (src/lib/seo.ts) combineert seo_defaults van de site met seo van de pagina tot titel, beschrijving, canonieke URL en Open Graph.

Zie Blokken en huisstijl voor stap 3 en 4 in detail, en Architectuur, Renderstrategie voor de cache.

Elke mutatie in het CMS is een Astro Action in src/actions/cms-*.ts (pagina’s, diensten, nieuws, referenties, navigatie, media, instellingen, blokken). Een actie doet altijd hetzelfde rijtje:

  1. Autoriseren met requireCmsUser (of requireCmsAdmin voor instellingen) uit src/lib/cms/.
  2. Valideren met het write-schema uit @platform/contract (pageWriteSchema, blocksSchema, themeSchema en zo verder). Formulierdata wordt eerst omgezet in src/lib/cms/form.ts en block-form.ts.
  3. Schrijven met Drizzle; constraint-fouten worden nette CONFLICT-meldingen (src/lib/cms/errors.ts).
  4. Auditlog schrijven (src/lib/cms/audit.ts).
  5. Cache purgen voor precies wat er is veranderd (src/lib/cache.ts): per tag of pad met purgePublicCache; wijzigingen die op elke pagina zichtbaar zijn (navigatie, huisstijl, alt-teksten) purgen de tag public.
  6. Gebeurtenis melden aan het dashboard (content.published, content.deleted) via src/lib/platform/send-event.ts; bij een mislukking gaat de envelope in webhook_outbox. De contactactie meldt op dezelfde manier form.submitted.

Media verwijderen is geblokkeerd zolang een blok, logo of OG-afbeelding er nog naar verwijst (src/lib/cms/media-usage.ts).

Better Auth (src/lib/auth.ts) met de Drizzle-adapter op de tenant-database; tabellen users, sessions, accounts en verifications. Twee rollen uit cmsRoleSchema: admin (beheerder, ook instellingen) en editor (redacteur, alleen content). Publieke registratie staat uit; gebruikers komen uit de seed of uit create-user. baseURL en trustedOrigins zijn de admin-origin, dus sessiecookies gelden alleen op de CMS-host. Een geslaagde login stuurt user.login naar het dashboard via een auth-hook. Inlogpogingen zijn begrensd met src/lib/rate-limit.ts.

Uploads gaan naar een S3-compatible bucket (R2 in productie, MinIO lokaal) onder de prefix <TENANT_ID>/ (src/lib/storage.ts, src/lib/media.ts). De media-rij bewaart key, afmetingen, mime-type en de verplichte alt-tekst. De publieke URL is S3_PUBLIC_URL plus de key; Astro’s /_image-endpoint optimaliseert die afbeeldingen, en astro.config.mjs staat dat toe via remotePatterns afgeleid van S3_PUBLIC_URL.

Onder /api/platform/, alleen met header X-Platform-Key (src/lib/platform/require-platform-key.ts):

  • GET /health: draait de site en is de database bereikbaar. Gebruikt een aparte, snelle probe met een timeout van twee seconden en antwoordt altijd 200, met database: ok of unreachable.
  • GET /stats: aantallen pagina’s, diensten en nieuws, en de laatste mutatie.
  • POST /revalidate: hele route-cache leeg.

Uitgaand ondertekent sendPlatformEvent de envelope met HMAC-SHA256 over timestamp + "." + body en POST hem naar PLATFORM_WEBHOOK_URL. Mislukt dat, dan bewaart webhook_outbox de envelope en probeert drainWebhookOutbox het opnieuw met oplopende wachttijd, tot 24 uur. Het volledige protocol staat in Contract.

  • csrf.ts: eigen origin-check met x-forwarded-proto, met uitzondering voor /api/platform/*.
  • security-headers.ts: standaardheaders op elke response.
  • rate-limit.ts: begrenzing van inlogpogingen.
  • safe-redirect.ts: alleen relatieve paden als redirect na login.
  • href.ts in het contract: alleen interne paden en http(s)-, mailto- en tel-links in navigatie en blokken.
  • De cache-key bevat de origin, zodat CMS-HTML nooit op de publieke host kan verschijnen, en cachePublicRoute wordt nooit vanuit CMS-pagina’s aangeroepen.

Losse tests staan naast de code: *.test.ts voor pure logica (bijvoorbeeld csrf.test.ts, outbox.test.ts, send-event.test.ts) en *.integration.ts voor tests tegen een echte database. Er is bewust geen testrunner-configuratie toegevoegd; zie Werkwijze en regels.