# Nieuwe website bouwen & live zetten — runbook (De Websmid) > **Hoe je dit gebruikt:** geef dit bestand aan Claude aan het begin van een nieuw > website-project ("bouw een nieuwe site zoals Behangerbrabant, volg `newsite.md`"). > Het beschrijft de **architectuur, tools, proces, mail, DNS en livegang** — niet de > lay-out/het design (dat doe je per klant met `/impeccable` + de klant-content). > > **Referentie-template = dit project (`behangerbrabant`)** of de zustersite > `../exterios`. Kopieer daar de patronen en de concrete bestanden uit (zie hieronder > welke). Dit is een onepage-marketingsite-recept; voor een webshop/blog wijkt het af. --- ## 0. TL;DR — de stack in één zin Server-rendered **Astro 6** onepager + **emdash** headless CMS, gehost op **Cloudflare Workers** met **D1** (content/leads), **R2** (media) en **KV** (sessies). Mail loopt via de **eigen DirectAdmin-mailbox** op de hostingserver (authenticated SMTP vanuit de Worker), **niet** via Cloudflare Email Routing. Domein-DNS staat op Cloudflare. --- ## 1. Software-architectuur ``` Browser ──► Cloudflare Worker (Astro SSR) ├─► / ─► pages/index.astro ─► getEmDashCollection() ─► D1 ├─► /api/contact ─► honeypot + time-trap + validatie │ ├─► opslaan in D1 (vangnet, contact_submissions) │ └─► mail via authenticated SMTP ─► srv8 (DirectAdmin) ├─► /_emdash/admin ─► emdash admin (Portable Text editor, media library) │ └─► writes ─► D1 + R2 └─► src/middleware.ts ─► forceert http ─► https (301) ``` - **Output:** `output: "server"` (SSR per request). Geen statische pre-render van de homepage (content komt live uit D1, zodat de klant alles zelf kan beheren). - **Content:** elke sectie leest uit een emdash-collectie via `getEmDashCollection(...)` met **inline fallback** in de component (lage blast-radius; site blijft staan als D1 even faalt of nog niet geseed is). - **Per-collectie tabellen in D1:** emdash slaat elke collectie op als tabel `ec_` waarin **elk veld een echte kolom** is (titel, locatie, prijs, ...). Plus systeem- kolommen (id, slug, status, created_at, ...). Belangrijk voor directe SQL-edits. - **CMS-login = passwordless** (magic-link via een eigen e-mail-provider-plugin op dezelfde srv8-mailbox; geen wachtwoorden). Gebruikers worden als rij in `users` aangemaakt. Zie §6b. ### Stack & versies (uit `package.json`) | Laag | Pakket | Reden | |---|---|---| | Framework | `astro` ^6 | SSR onepager, snelle TTFB | | Adapter | `@astrojs/cloudflare` ^13 | Workers SSR | | CMS | `emdash` + `@emdash-cms/cloudflare` ^0.1 | klant beheert teksten/foto's/prijzen zelf | | React | `react`/`react-dom` ^19 | emdash admin-UI dependency | | DB | Cloudflare **D1** | content + leads (`contact_submissions`) | | Storage | Cloudflare **R2** | media uploads | | Sessies | Cloudflare **KV** | auto-provisioned (`SESSION`) | | Images | Cloudflare **Images** | auto-provisioned (`IMAGES`) | | Styling | scoped CSS + CSS custom properties | geen Tailwind, lichte payload | --- ## 2. Tools & accounts (eenmalig per machine / per project) - **Cloudflare-account** De Websmid: `CLOUDFLARE_ACCOUNT_ID=41972f4905f2c83f51111b80283e030b` (Mike@dewebsmid.nl). `wrangler whoami` om in te loggen/checken. - **Node + wrangler** (in repo). Op deze Windows/corporate-machine roep je tools aan via `node node_modules//bin/...` (zie §9, de `.bin`-shims ontbreken door `--ignore-scripts`). - **Python + Pillow** (systeem-python) voor beeld-conversie naar WebP (`sharp` is kapot door `--ignore-scripts`). Installeer libs met `pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org `. - **Hosting (DirectAdmin) bij de collega** voor de mailbox + DKIM. Server bv. `srv8.dewebsmid.nl`. - **Domeinregistrar** — nameservers mogen naar Cloudflare. --- ## 3. Repo scaffolden Kopieer de structuur van dit project / `../exterios`. Kernbestanden: ``` package.json name -> , scripts (dev/build/deploy/typecheck) tsconfig.json extends astro/tsconfigs/strict astro.config.mjs output:server, adapter cloudflare(), emdash({database:d1, storage:r2}), react() wrangler.jsonc name, main ./src/worker.ts, compat nodejs_compat, D1+R2 bindings, routes src/worker.ts re-export @astrojs/cloudflare server handler + emdash PluginBridge src/live.config.ts emdash live collections src/cloudflare-env.d.ts minimale types voor `cloudflare:workers` (env) + `cloudflare:sockets` src/middleware.ts http->https redirect (kopieer 1-op-1, zie §7) src/lib/smtp.ts SMTP-client (kopieer 1-op-1, zie §6) src/pages/api/contact.ts contactformulier-handler (kopieer + pas adressen aan, zie §6) seed/seed.json emdash schema + demo content public/robots.txt, public/favicon.svg, public/_headers, public/fonts/.woff2 ``` Installeren (Windows/corporate): `NODE_OPTIONS=--use-system-ca npm install --ignore-scripts` (zonder die env hangt npm op het corporate-cert; `--ignore-scripts` want `better-sqlite3` compileert hier niet). --- ## 4. Cloudflare resources aanmaken ```powershell $env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"; $env:NODE_OPTIONS="--use-system-ca" node node_modules/wrangler/bin/wrangler.js d1 create -db # regio WEUR node node_modules/wrangler/bin/wrangler.js r2 bucket create -media ``` - Zet de **D1 `database_id`** + R2-bucketnaam in `wrangler.jsonc` (binding D1 = `DB`, R2 = `MEDIA`). KV (`SESSION`) en Images (`IMAGES`) worden auto-geprovisioned bij deploy. --- ## 5. Content-model + seed (emdash) - Modelleer 1 singleton per onepage-blok + flat collections waar herhaling logisch is (zie `architecture.md` voor het volledige datamodel: `site_settings`, `hero`, `usps`, `pakketten`, `arte_showcase`, `segmenten`, `projecten`, `werkwijze`, `reviews`, `faq`). - **Beeldvelden**: zie **§5a (Beeld & media koppelen)** hieronder — leeg laten werkt alléén als de component het veld óók uitleest (anders verandert een upload niks). - **Seeden gaat via de setup-wizard, niet via CLI.** `emdash seed` (CLI) werkt hier NIET (better-sqlite3 niet gecompileerd). Draai de wizard op `https:///_emdash/admin/setup` → "Include sample content". - **Content achteraf direct aanpassen** (bv. demo-tekst overschrijven) kan met SQL op de remote D1 — elk veld is een kolom in `ec_`: ```powershell node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote ` --command "SELECT id,slug,title FROM ec_projecten ORDER BY created_at;" --json node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote ` --command "UPDATE ec_projecten SET title='...' WHERE id='';" --json ``` De publieke site leest deze kolommen direct (geen rebuild nodig). Let op revisie- kolommen `live_revision_id`/`draft_revision_id`: bij geseede content zijn die `null`, dan is een directe kolom-update schoon (admin-editor leest dezelfde kolom). > ⚠️ **Schema leeft in D1, niet in seed.json (op runtime).** Collecties/velden staan in > `_emdash_collections` + `_emdash_fields`; `seed.json` is alleen de **éénmalige** seed. > Een veld toevoegen aan een **live** site = D1-migratie (`ALTER TABLE ec_ ADD > COLUMN ...` + `INSERT INTO _emdash_fields (id=ULID, collection_id, slug, label, type, > column_type, sort_order)`), én `seed.json` bijwerken voor verse installs. Alléén > seed.json aanpassen laat het veld NIET in de live-admin verschijnen. Zie memory > `emdash-schema-lives-in-d1`. ### 5a. Beeld & media koppelen Emdash provisioned image-**velden**, maar de **componenten moeten ze ook uitlezen** — een leeg/ongekoppeld veld betekent dat een admin-upload niets verandert op de site. - **Opslagvorm (local provider op R2):** een image-veld is JSON `{ id, provider:"local", filename, mimeType, width, height, alt, meta:{ storageKey } }`. `src` wordt op displaytijd afgeleid uit `storageKey`; geserveerd via `/_emdash/api/media/file/` (R2, immutable cached). `astro.config` gebruikt `storage: r2({ binding:"MEDIA" })` → local provider (géén Cloudflare-Images-resizing). - **Bouw het uitlezen vanaf het begin in.** Helper `src/lib/images.ts#resolveCmsImage(value)` → `{ src, width?, height?, alt? }` (handelt local/extern/JSON-string af). Component: lees `entry.data.`, gebruik `.src`, en **val terug op de statische placeholder-webp** (mét responsive srcset) als het veld leeg is — nul risico, nul zichtbare verandering tot iemand upload. - **Galerij met vast aantal beelden** (bv. 4 stalen) = **losse `image`-velden** (`image_1..image_4`, nette picker per slot), **niet** een ruw `json`-veld (slechte UX). - **Placeholders alvast in de media-library zetten** (anders ziet de klant lege beeldvelden): R2-`object put` + rij in `media` + veld-JSON. Zie memory `emdash-media-population` (+ `scripts/migrate-images.sql` in dit project). - **Decoratieve velden** (`logo`, `favicon`) worden vaak níét gerenderd (bespoke SVG-mark in de header); laat die met rust of verwijder het veld om de admin niet te misleiden. - **Responsive:** local provider = één `src` per beeld. Voor automatische maatvarianten (ook voor klantuploads) zou je de **Cloudflare-Images-provider** moeten aanzetten — niet standaard nodig, wel een nette upgrade. ⚠️ **Upload de juiste grootte** (CMS = één formaat): een `-1280`-variant voor hero/cards, kleiner voor beelden boven de vouw — niet het 2752px-origineel. Een `srcsetFor()`-helper hoort `undefined` te geven voor CMS-paden (`/_emdash/api/media/file/...`), zodat een leeg srcset geen kapotte `-768`-URL's bouwt; de code-fallback (statische webp) houdt z'n srcset. - **Media op schaal koppelen** (veel beeldvelden, bv. 25 dienst-foto's): genereer R2-uploads + `media`-rijen + veld-JSON met een script (zoals `scripts/gen-cms-sql.mjs`, die je **importeerbaar** maakt door de CLI achter een `import.meta.url===…`-guard te zetten). **Dedupliceer**: één `media`-rij per unieke foto, meerdere velden mogen dezelfde `storageKey` delen → schone mediabibliotheek. --- ### 5b. Collecties toevoegen aan een AL-geseede site + admin-UX (geleerd bij Helvoirt) Na de eerste seed leeft het schema in D1. Een collectie/veld toevoegen kan zónder de wizard opnieuw te draaien (die reset auth + herintroduceert defaults), via **SQL die emdash's eigen tabelstructuur spiegelt**: - Lees de echte DDL van een bestaande `ec_` uit `sqlite_master` en de kolommen van `_emdash_collections`/`_emdash_fields`. Genereer dan: `INSERT _emdash_collections` + `CREATE TABLE ec_` (zelfde systeemkolommen) + per veld `INSERT _emdash_fields` + content-`INSERT`s. **Laat search/FTS weg** (supports zonder `search`, geen `searchable`-velden) → geen FTS-tabellen/triggers nodig, dus de SQL blijft compleet en veilig. Veld→`column_type`: string/text/select/datetime/slug/image/file/reference→TEXT, integer/boolean→INTEGER, number→REAL, portableText/json/multiSelect/repeater→JSON. ids = ULID (`ulidx`). Patroon in dit project: `scripts/gen-cms-sql.mjs ` + `scripts/cms-models/*`. - **`emdash`-defaults `pages`/`posts`** worden **alléén bij setup** aangemaakt (niet per boot) → veilig te verwijderen (`DROP TABLE ec_*` + `_emdash_fts_*` + rijen uit `_emdash_collections`/ `_emdash_fields`); ze blijven weg zolang de wizard niet opnieuw draait. - **"Bestaande hardcoded pagina's → CMS" migreren:** lees elke pagina uit het CMS **met de huidige tekst als fallback** (`getEmDashEntry`/`getEmDashCollection` in try/catch). Zo breekt de site nooit, ook tijdens de migratie. **Verifieer per stap met een sentinel:** zet een herkenbare waarde via `d1 execute --remote`, fetch de live pagina, zet terug. **Emdash admin-UX (v0.1.x) — wat wel/niet native is:** - **Dupliceren** van een entry = **native** (kopieer-icoon in de lijst → conceptkopie van alle velden + SEO). Geen eigen knop nodig. - **Repeater-velden** = inline rijen **toevoegen/verwijderen/verslepen** (WP-Bakery-gevoel). Definitie via `_emdash_fields.validation` = `{subFields:[{slug,type,label,required?}], minItems, maxItems}`. ⚠️ **Sub-velden kunnen GEEN `image` zijn** → beeld-rijen modelleer je als een **aparte collectie** met een image-veld (collectie heeft Add New + delete per rij). - **"(optional)"** bij niet-verplichte velden is **niet globaal uit te zetten** (geen branding/ custom-CSS-hook in deze beta). Enige nette weg: het veld **verplicht** maken (`UPDATE _emdash_fields SET required=1 ...`). Velden hebben **geen** `description`/help-tekst — zet uitleg in het **label** (bv. "SEO · paginatitel"). - **Media handmatig in de library**: `wrangler r2 object put / --file ... --remote` (storage_key == exacte R2-key), + rij in `media` (id=ULID, filename, mime_type, width, height, alt, storage_key, status='ready'), + image-veld-JSON `{id, provider:"local", filename, mimeType, width, height, alt, meta:{storageKey}}` (geen `src`; wordt op displaytijd afgeleid). NB: CMS- beeld = één formaat (geen auto-srcset); kies een passende variant of de Cloudflare-Images-provider. ## 6. Contactformulier + MAIL (belangrijkste sectie) ### Architectuurbesluit (geleerd bij Behangerbrabant) De klant krijgt een **echte mailbox op de hostingserver** (DirectAdmin, bv. `info@` op `srv8.dewebsmid.nl`). Daardoor moet **MX naar srv8** wijzen, en dat sluit **Cloudflare Email Routing uit** (die eist MX naar Cloudflare — wederzijds uitsluitend). Dus: - **De Worker stuurt de formuliermelding via authenticated SMTP** rechtstreeks naar de hosting-SMTP (poort 465, implicit TLS). Workers kunnen geen poort 25 en geen nodemailer; daarom een **hand-geschreven SMTP-client** op `cloudflare:sockets` → **`src/lib/smtp.ts`** (kopieer 1-op-1). - Aflevering aan `info@` is **lokaal op srv8**, dus de melding werkt **al vóór de DNS rond is** (auth-SMTP, niet afhankelijk van MX/SPF/DKIM). ### Wat `src/pages/api/contact.ts` doet 1. **Honeypot** (`website_url`) + **time-trap** (`ts` hidden field; <2s of >6h = stille 200). 2. **Validatie** (naam/e-mail/bericht), 422 met veld-errors. 3. **Opslaan in D1** (`contact_submissions`, migratie `migrations/0001_*.sql`) = vangnet. 4. **Twee mails** via `Promise.allSettled` (één fout blokkeert de ander niet): - **Melding naar de klant**: `To = info@`, `Reply-To = bezoeker`, house-style tekst ("Beste, ... Naam/Telefoonnummer/E-mailadres/Bericht"). - **Bevestiging (autoreply) naar de aanvrager**: `To = bezoeker`, `Reply-To = info@`, ondertekend met de **contactpersoon** van de klant (bv. "Joey") + T/E. - **`From`-header** = `Bedrijfsnaam >` (house-style); **envelope (`MAIL FROM`)** blijft de geauthenticeerde mailbox (`info@`) zodat srv8 niet weigert. `smtp.ts` ondersteunt hiervoor een aparte `envelopeFrom`. ### Config & secret Niet-geheime config zit als constant/env met fallback in `contact.ts` (`CONTACT_SMTP_HOST` srv8, `CONTACT_SMTP_PORT` 465, `CONTACT_SMTP_USER` info@, `CONTACT_EMAIL_TO` info@, `CONTACT_EMAIL_FROM` website@). **Alleen het wachtwoord is een secret**, nooit in de repo: ```bash # ZET SECRETS VIA BASH, NIET VIA POWERSHELL-PIPE. printf '%s' '' | NODE_OPTIONS=--use-system-ca \ node node_modules/wrangler/bin/wrangler.js secret put CONTACT_SMTP_PASS ``` > ⚠️ **BOM-valkuil:** `"x" | wrangler secret put` vanuit **Windows PowerShell** plakt een > UTF-8 BOM vóór de waarde → SMTP-auth faalt stil met `535 Incorrect authentication data`. > Gebruik **bash `printf '%s'`** of het Cloudflare-dashboard. `contact.ts` stript > defensief een leidende BOM (`charCodeAt(0)===0xFEFF`), maar zet hem gewoon goed. ### ⚠️ SMTP-host: gebruik de échte serverhostname, niet `mail.` (geleerd bij Helvoirt) Na de NS-verhuizing naar Cloudflare **resolvet `mail.` vaak niet** (de mail-records gingen niet mee bij de overstap → NXDOMAIN). De Worker geeft dan `Error: Stream was cancelled` (een `cloudflare:sockets`-verbindingsfout: host onvindbaar). Verbinden via een **kaal IP** lukt óók niet (het TLS-cert matcht de hostname, niet het IP). **Oplossing:** zet `CONTACT_SMTP_HOST` op de **echte hostname van de DirectAdmin-server** (bv. `srv8.dewebsmid.nl`) — die resolvet wél los van het klantdomein én heeft een geldig cert. `CONTACT_SMTP_USER`/`EMAIL_TO`/`EMAIL_FROM` blijven `info@` (alleen de TCP-verbinding gaat naar srv8). Zet 'm in `wrangler.jsonc` `vars` (niet-geheim, dus geen secret): ```jsonc "vars": { "CONTACT_SMTP_HOST": "srv8.dewebsmid.nl" } ``` > Doe dit meteen — laat de code-default niet op `mail.` staan, dat kost een debug-ronde. **Serverhostname onbekend?** Vis 'm uit de `/webmail`-redirect van het klantdomein: `curl.exe -sk -D - https:///webmail` → `Location: https://srv8.dewebsmid.nl/roundcube`. > **Outbound werkt vóór de mail-DNS:** de melding naar `info@` wordt **lokaal op srv8** > afgeleverd (de mailbox staat daar), dus die komt aan zonder MX. De **autoreply** naar de > bezoeker heeft SPF/DKIM/DMARC wél nodig voor de afleverbaarheid (anders spam) — zie §7. ### Testen (na deploy) Start `wrangler tail` in de achtergrond, doe een test-POST, en kijk naar de logs: ``` [contact] new-submission [contact] mail-sent melding-klant [contact] mail-sent bevestiging-aanvrager ``` POST-voorbeeld (Origin-header is nodig wegens Astro `checkOrigin`, `ts` tussen 2s en 6h): ```powershell $ts=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()-5000 curl.exe -sk --resolve :443: -H "Origin: https://" ` -H "Content-Type: application/x-www-form-urlencoded" ` -d "name=Test&email=jij%40mail.nl&message=test&website_url=&ts=$ts" https:///api/contact ``` **Ruim testrijen op** uit `contact_submissions` na het testen. Bevestig bij de collega dat de mail echt in de inbox landt (250 = aangenomen door srv8, niet per se afgeleverd). --- ## 6b. CMS-login & gebruikers (auth) Hoe logt de **klant** in op `/_emdash/admin`? Dit is niet triviaal in deze emdash-beta: - **Emdash is passwordless** — er is **geen e-mail+wachtwoord** (de `users`-tabel heeft geen `password_hash`). Methodes: passkey (WebAuthn), magic-link (e-mail) en OAuth (Google/GitHub via secrets `EMDASH_OAUTH_GOOGLE_CLIENT_ID`/`_SECRET`; redirect-URI `/_emdash/api/auth/oauth/

/callback`; koppelt op geverifieerd e-mailadres). - ⚠️ **De admin-knop "Invite User" is stuk:** de invite-link wijst naar het API-endpoint `/_emdash/api/auth/invite/accept` (geeft JSON) i.p.v. een pagina die een passkey registreert. Een invite kan dus niet afgerond worden → **niet gebruiken.** - ⚠️ **De ingebouwde "Sign in with email link" (magic-link) geeft 503** zolang er geen **e-mail-provider-plugin** is (de `email:deliver`-hook). De GitHub/Google-knoppen werken niet zonder OAuth-secrets. ### Oplossing: e-mail-provider-plugin (hergebruikt de srv8-mailbox) Maakt de ingebouwde magic-link werkend, met dezelfde `src/lib/smtp.ts` als §6. 1. **`src/plugins/smtp-email.ts`** — standard-formaat plugin: `export default { hooks: { "email:deliver": { exclusive: true, handler } } }` (géén emdash-import nodig). De handler leest de secret uit `cloudflare:workers` env en levert af via `sendMail`. Gooi bij fout (zodat de pipeline het meldt, niet stil "verstuurd"). 2. **Registreren in `astro.config.mjs`:** ```js import { fileURLToPath } from "node:url"; const smtpEntry = fileURLToPath(new URL("./src/plugins/smtp-email.ts", import.meta.url)).replace(/\\/g, "/"); // ... emdash({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [{ id: "-smtp-email", version: "1.0.0", entrypoint: smtpEntry, format: "standard", capabilities: ["email:provide"] }], }) ``` ⚠️ **Entrypoint = absoluut pad met forward slashes** — emdash genereert er een `import` mee, dus een resolvebare specifier is nodig (backslashes breken het). 3. Config-plugins zijn standaard **enabled**; als **enige** `email:deliver`-provider wordt 'ie bij runtime-init **auto-geselecteerd + gepersisteerd** (optie `emdash:exclusive_hook:email:deliver`). ⚠️ In **dev** registreert emdash óók een console-provider → twee providers → geen auto-select; alleen lokaal relevant, productie heeft alleen de plugin. Daarna werkt `/_emdash/admin/login` → "Sign in with email link" → e-mail → klik → sessie (KV) → `/_emdash/admin`. **Test** (na deploy): `POST /_emdash/api/auth/magic-link/send` `{"email":"..."}` → 200 + een rij in `auth_tokens` (`type='magic_link'`) + `wrangler tail` zonder SMTP-fout. Zie memory `emdash-passwordless-magic-link`. ### ⚠️ Login kapot na domeinkoppeling — en handmatig inloggen (geleerd bij Helvoirt) **Passkeys zijn origin-gebonden.** Registreert iemand een passkey/Windows-Hello-pincode op de **preview** (`*.workers.dev`), dan werkt die **niet** meer op het echte domein na de koppeling. Windows toont dan alleen "beveiligingssleutel / telefoon", niet de eigen pincode → de klant kan niet meer in. Oplossing: een **nieuwe passkey op het echte domein** registreren (kan pas ná inloggen) — dus vermijd passkeys op de preview, of regel eerst een andere login. **Vastgelopen (passkey dood én mailbox nog niet klaar)? Mint zelf een magic-link-token** (werkt zonder mailbox). Token = `base64url(32 random bytes)`; in `auth_tokens` staat alleen de **SHA-256-hash** ervan: ```powershell node -e "const c=require('crypto');const r=c.randomBytes(32);console.log('TOKEN='+r.toString('base64url'));console.log('HASH='+c.createHash('sha256').update(r).digest('base64url'))" # zet de HASH in auth_tokens (hash = PK, type='magic_link', 48u geldig) + e-mail geverifieerd: node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote --command ` "INSERT INTO auth_tokens (hash,user_id,email,type,expires_at,created_at) VALUES ` ('','','','magic_link',strftime('%Y-%m-%dT%H:%M:%fZ','now','+2 days'),strftime('%Y-%m-%dT%H:%M:%fZ','now')); ` UPDATE users SET email_verified=1 WHERE id='';" ``` Geef de gebruiker dan: `https:///_emdash/api/auth/magic-link/verify?token=` (single-use, verloopt op `expires_at`; `verifyMagicLink` hasht de token en zoekt op de hash). ### ⚠️ Inlogmail-link geeft Cloudflare-placeholder "There is nothing here yet" (geleerd bij Helvoirt) **Symptoom:** de klant vraagt "Sign in with email link" aan, krijgt netjes een mail, maar de **link in die mail** leidt naar de Cloudflare-placeholderpagina *"There is nothing here yet — Powered by Cloudflare"* in plaats van de admin. Inloggen lukt nooit. **Oorzaak:** emdash bouwt elke uitgaande systeemlink (magic-link én e-mailverificatie) op uit de **opgeslagen** optie **`emdash:site_url`**, **niet** uit de request-Host (bewust — anti-spoofing, zie `node_modules/emdash/src/api/site-url.ts#getSiteBaseUrl`). Die optie wordt **één keer gezet door de setup-wizard**, en dat gebeurt op de **preview-origin** (`*.workers.dev`). Na de domeinkoppeling schakelt de deploy `workers.dev` **uit** (§7) → de opgeslagen URL wijst naar een **dode subdomein** → Cloudflare serveert de placeholder. De rest van de site werkt prima (die draait op de request-host), alleen de inlogmail is stuk. **Fix** (data op de live D1, **geen redeploy** nodig). De waarde is een **JSON-string**, dus inclusief de dubbele quotes; tabel `options`, kolommen `name`/`value`: ```bash node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote \ --command "UPDATE options SET value = '\"https://\"' WHERE name = 'emdash:site_url';" # verifieer: node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote \ --command "SELECT name, value FROM options WHERE name = 'emdash:site_url';" --json ``` `getSiteBaseUrl` plakt er zelf `/_emdash` achter, dus zet **alleen het kale domein** (geen pad, geen trailing slash). Daarna een **nieuwe** inlog-link aanvragen — mails van vóór de fix bevatten nog de dode link. **Preventie:** zet `emdash:site_url` **meteen op het echte domein** zodra je de custom domain koppelt (§7 / go-live stap 8), nog vóór je de klant laat inloggen. Controleer 'm met de `SELECT` hierboven; staat er nog `*.workers.dev`, dan is élke inlogmail dood. ### Gebruikers aanmaken (provisioning) Geen werkende UI (invite stuk) → rij in `users` via SQL. Magic-link mailt **alleen naar bestaande** users (geen open signup). ```powershell node node_modules/wrangler/bin/wrangler.js d1 execute -db --remote --command ` "INSERT INTO users (id, email, role, email_verified, disabled, created_at, updated_at) VALUES ` ('','',50,1,0,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))" ``` - **Rollen:** 50=ADMIN, 40=EDITOR, 30=AUTHOR. Voor een klant is **EDITOR** vaak genoeg (alle content, geen gebruikers/instellingen). ULID via `node --input-type=module -e "import {ulid} from 'ulidx'; console.log(ulid())"`. - **Verwijderen:** `DELETE` uit `auth_tokens` / `oauth_accounts` / `credentials` (WHERE user_id …) + `users`. ### Klant-handover Stuur de klant een mail met inlog-uitleg (URL, "Sign in with email link", waar content + SEO-velden staan). ⚠️ De **inlogmail die de klant ontvangt is Engelstalig** ("Sign in to …") — emdash's standaardtemplate; benoem de knop in je handover-mail. Voorbeeld-handover: zie de mail die we voor Behangerbrabant schreven (in `todo.md`/sessielog). --- ## 7. DNS (op Cloudflare) + HTTPS ### Zone activeren + nameservers (geleerd bij Helvoirt) "De klant heeft de DNS omgezet" ≠ klaar. **Verifieer dat de zone op Cloudflare `active` is** (niet `pending`): de registrar moet de nameservers naar de **Cloudflare-toegewezen** NS wijzen (bv. `cesar`/`priscilla.ns.cloudflare.com`), niet naar de hoster (bv. `dns1.hosted.nl`). Wijzen ze nog naar de hoster, dan **SERVFAILt het domein overal** ("lame delegation" in de DoH-respons) en blijft de zone `pending`. Check de status + de toegewezen NS via de API (token uit de wrangler-config): ```powershell $cfg = "$env:APPDATA\xdg.config\.wrangler\config\default.toml" $tok = ((Get-Content $cfg | Where-Object {$_ -match 'oauth_token'}) -replace '.*=\s*"?([^"]+)"?.*','$1').Trim() (Invoke-RestMethod "https://api.cloudflare.com/client/v4/zones?name=" -Headers @{Authorization="Bearer $tok"}).result | Select-Object name,status,@{n='ns';e={$_.name_servers -join ', '}} ``` Pas custom-domain koppelen (routes + rebuild) als de zone **active** is. ### Mail-DNS (door de collega aan te leveren / via DNS-token toe te voegen) | Type | Naam | Waarde | Prio | |---|---|---|---| | MX | `@` | `srv8.dewebsmid.nl` | 10 | | TXT (SPF) | `@` | `v=spf1 mx include:_spf.dewebsmid.nl ~all` | — | | TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:info@` | — | | TXT (DKIM) | `x._domainkey` | public key uit DirectAdmin (selector `x` = default) | — | - MX = **DNS-only** (mail gaat nooit door de Cloudflare-proxy). De apex-A blijft proxied voor de site — MX staat daar los van. - **DKIM-key komt uit DirectAdmin** (E-mail → DKIM). Mail werkt zonder, maar belandt vaker in spam. - **Verifieer via DoH** (corporate-DNS blokkeert poort 53): `curl -sk "https://dns.google/resolve?name=&type=MX"` (en TXT, `_dmarc`, `x._domainkey`). > ⚠️ **Wie zet de DNS?** De `wrangler`-OAuth-login kan zones **lezen** (`/zones?name=` werkt), > maar geeft **403** op **DNS-records** (list/edit) én op **zone-instellingen** (SSL-modus, > Always Use HTTPS, HSTS). Dus die gaan via het **dashboard** of via een aparte **API-token met > Zone.DNS:Edit** (My Profile → API Tokens → "Edit zone DNS" → specifieke zone). **Wel** werkt > het **aanmaken van het custom domain** via `wrangler deploy` (Workers-scope). Vraag de > DNS-token aan de klant/Mike als je de mail-DNS/HTTPS zelf via de API wilt zetten. > ⚠️ **Mail-DNS van een zustersite kopiëren?** Pas álle domeinverwijzingen aan — vooral de > DMARC **`rua`** (en evt. de DKIM-key). Bij Helvoirt stond de `rua` per ongeluk nog op > `info@behangerbrabant.nl` (copy-paste). De MX mag direct naar `srv8.dewebsmid.nl` (een los > `mail.`-A-record is optioneel; alleen nodig als het mailprogramma die hostname gebruikt). ### HTTPS / redirect / security - **http→https**: `src/middleware.ts` doet een 301 op basis van de `cf-visitor`-header (kopieer 1-op-1; doet niets zonder die header, dus dev breekt niet). **Plus** optioneel in het dashboard: **SSL/TLS → Edge Certificates → "Always Use HTTPS"** aan (edge-niveau, maakt de middleware een no-op) en daarna **HSTS** aan. - **SSL-modus** op **Full** of **Full (strict)** (niet Flexible → anders redirect-loop). - **Spam-bescherming** = honeypot + time-trap (server-side), **geen reCAPTCHA**. - **Security headers** zijn al gezet (Permissions-Policy, X-Content-Type-Options nosniff, X-Frame-Options, Referrer-Policy). CSP optioneel. - Custom domain koppelen via `wrangler.jsonc` `routes` (apex + `www`, beide `custom_domain: true`). ⚠️ **Rebuild ná elke `wrangler.jsonc`-wijziging**, anders deployt wrangler een stale config (de adapter genereert `dist/server/wrangler.json`). ⚠️ Bij het toevoegen van `routes` **schakelt de deploy `workers.dev` uit** (custom-domain-only — goed tegen duplicate content, maar de **preview-URL stopt** en open passkeys op die URL breken, zie §6b). De middleware regelt `www→apex` + `http→https` al op worker-niveau, dus de redirects werken ook zonder de dashboard-instellingen. ⚠️ **Update meteen ook `emdash:site_url`** naar het echte domein (de setup-wizard zette 'm op de `*.workers.dev`-preview die nu uit gaat) — anders wijst élke inlogmail-link naar een dode subdomein en krijgt de klant de Cloudflare-placeholder. Zie §6b. --- ## 8. SEO / performance / afronding basics - **`public/favicon.svg` MOET bestaan** (Base.astro verwijst ernaar; ontbreekt = 404). Brand-mark als SVG met eigen donkere achtergrond. - **`public/robots.txt`**: `User-agent: *` / `Allow: /` / `Disallow: /_emdash/` + `Disallow: /api/` / `Sitemap: https:///sitemap.xml`. **Nooit** `Disallow: /`. - **`sitemap.xml`** endpoint + **canonical naar apex (non-www)** via `site:` in `astro.config.mjs`. - Meta `title` + `description` in `Base.astro`; JSON-LD `LocalBusiness` + `FAQPage`. - Beelden: WebP, responsive maten via Pillow. **Nieuwe bestandsnamen bij vervanging** (bv. `-v2-`) om cache-staleness te vermijden; `width`/`height` op `` matchen om CLS te voorkomen. - **Performance (mobiele Lighthouse-winst, geverifieerd bij Behangerbrabant):** - **Host fonts zelf** i.p.v. Google Fonts: één variabel woff2 (latin-subset) in `public/fonts/`, `@font-face` met `font-display: swap` in `tokens.css`, en de externe `fonts.googleapis.com`/`gstatic.com`-links eruit. Schrapt een render-blocking stylesheet + 2 externe origins. Download het woff2 met een Chrome-UA via de `css2`-URL (zelfde font = identiek uiterlijk). Evt. ``. ⚠️ **Niet elk font is self-te-hosten via de `css2`-API:** **width-varianten** (bv. *Archivo Expanded*) zijn door Google samengevoegd in de variabele basisfamilie → `family=Archivo+Expanded` levert **geen** `@font-face`. Dan: of de juiste axis/instance opvragen, of het font gewoon op Google Fonts laten (preconnect + `display=swap`) i.p.v. de display-typografie te riskeren. Geverifieerd bij Helvoirt. - **OG-image (1200×630) genereren** kan deterministisch met Pillow (`scripts/make-og.py`): echte foto cover-croppen + petrol-gradient + wordmark + 4,9-Google. ⚠️ Het **ster-glyph** (`★`, U+2605) zit niet in Arial → gebruik `seguisym.ttf` (Segoe UI Symbol) voor de sterren. - **`public/_headers`** voor far-future immutable caching op `/fonts/*` en `/images/*` (versie-vaste namen) — Workers Static Assets honoreert dit; lost de "efficient cache policy"-melding op. HTML niet cachen (SSR). - LCP-beeld preloaden (`fetchpriority="high"`), non-hero `loading="lazy"`. - **Lighthouse meten:** er is geen Lighthouse-MCP; de keyless PageSpeed-API is vaak `429` (dagquota). Meet met een **eigen Google PSI API-key** (`...runPagespeed?url=...&strategy=mobile&key=`) of via de PageSpeed-website. ### 8a. Analytics / Google Tag Manager (GTM) De klant moet z'n **eigen GTM-container** kunnen koppelen zonder code aan te raken. > ⚠️ **Twee "settings" in emdash — verwar ze niet.** De **ingebouwde** pagina > **Settings → SEO** heeft een **vaste, hardcoded** veldenlijst (`SiteSettings.seo` in > `node_modules/emdash/src/settings/types.ts`: title-separator, default OG-image, robots.txt, > Google Search Console-verificatie, Bing-verificatie). Daar kun je **geen** veld aan > toevoegen, en `Base.astro` leest die ook niet. Site-brede klant-instellingen (SEO-titel/ > description, GTM, NAW) horen in de **`site_settings`-collectie** (label "Instellingen") — > dáár leest `Base.astro` uit. De klant zoekt GTM vaak op de verkeerde (ingebouwde) pagina. 1. **Veld toevoegen** — live = D1-migratie (zie §5 ⚠️ + memory `emdash-schema-lives-in-d1`): - `ALTER TABLE ec_site_settings ADD COLUMN gtm TEXT;` - `INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, sort_order)` met `id=ULID`, `collection_id=`, `slug='gtm'`, `label='Google Tag Manager ID (bv. GTM-XXXXXX)'`, `type='string'`, `column_type='TEXT'`. - `seed.json` óók bijwerken (veld-def + lege startwaarde) voor verse installs. - De admin leest de veldenlijst **live uit D1** (`SchemaRegistry.listFields`, géén cache), dus het veld verschijnt direct in de Instellingen-editor — **geen rebuild nodig**. 2. **Renderen in `Base.astro`** — vraag alleen de **container-ID** (`GTM-XXXXXX`), niet het hele snippet, en bouw zelf de officiële code. Robuust uitlezen (vist de ID ook uit een per ongeluk geplakt volledig snippet; matcht bewust alleen `GTM-`, niet GA4's `G-XXXX`): ```ts const gtmId = (((s.gtm as string|undefined) ?? "").match(/GTM-[A-Z0-9]+/i)?.[0] ?? "").toUpperCase(); ``` - **Head** (zo hoog mogelijk in ``), alleen als `gtmId` gezet is: het officiële GTM-`