Eerste commit voor Helvoirt Gevelreiniging

This commit is contained in:
Joery Disselhof
2026-08-26 13:57:08 +02:00
commit 276dba07a2
225 changed files with 23121 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
# 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_<slug>`
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/<tool>/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 <pkg>`.
- **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 -> <project>, 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/<font>.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 <project>-db # regio WEUR
node node_modules/wrangler/bin/wrangler.js r2 bucket create <project>-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://<host>/_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_<collectie>`:
```powershell
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
--command "SELECT id,slug,title FROM ec_projecten ORDER BY created_at;" --json
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
--command "UPDATE ec_projecten SET title='...' WHERE id='<ulid>';" --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_<slug> 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/<storageKey>` (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.<veld>`, 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_<collectie>` uit `sqlite_master` en de kolommen van
`_emdash_collections`/`_emdash_fields`. Genereer dan: `INSERT _emdash_collections` +
`CREATE TABLE ec_<slug>` (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 <model> <out.sql>` + `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 <bucket>/<storageKey> --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@<domein>` 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@<domein>` 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@<domein>`, `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 <website@<domein>>` (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' '<mailbox-wachtwoord>' | 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.<domein>` (geleerd bij Helvoirt)
Na de NS-verhuizing naar Cloudflare **resolvet `mail.<domein>` 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@<domein>` (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.<domein>` staan, dat kost een debug-ronde.
**Serverhostname onbekend?** Vis 'm uit de `/webmail`-redirect van het klantdomein:
`curl.exe -sk -D - https://<domein>/webmail` → `Location: https://srv8.dewebsmid.nl/roundcube`.
> **Outbound werkt vóór de mail-DNS:** de melding naar `info@<domein>` 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 <domein>:443:<cf-ip> -H "Origin: https://<domein>" `
-H "Content-Type: application/x-www-form-urlencoded" `
-d "name=Test&email=jij%40mail.nl&message=test&website_url=&ts=$ts" https://<domein>/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/<p>/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: "<project>-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 <project>-db --remote --command `
"INSERT INTO auth_tokens (hash,user_id,email,type,expires_at,created_at) VALUES `
('<HASH>','<user-id>','<email>','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='<user-id>';"
```
Geef de gebruiker dan: `https://<domein>/_emdash/api/auth/magic-link/verify?token=<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 <project>-db --remote \
--command "UPDATE options SET value = '\"https://<domein>\"' WHERE name = 'emdash:site_url';"
# verifieer:
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-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 <project>-db --remote --command `
"INSERT INTO users (id, email, role, email_verified, disabled, created_at, updated_at) VALUES `
('<ULID>','<email-lowercase>',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=<domein>" -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@<domein>` | — |
| 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=<domein>&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.<domein>`-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://<domein>/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 `<img>` 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. `<link rel="preload" as="font" ... crossorigin>`.
⚠️ **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=<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=<site_settings-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 `<head>`), alleen als `gtmId` gezet is: het officiële
GTM-`<script>` via `is:inline define:vars={{ gtmId }}` (de IIFE eindigend op
`'dataLayer', gtmId`).
- **Body** (direct na `<body>`): het `<noscript>`-`<iframe src=...ns.html?id=${gtmId}>`.
- Leeg veld → **niets** geïnjecteerd (`{gtmId && (...)}`-guard).
3. **Verifiëren** (na deploy): zet tijdelijk een nep-ID (`GTM-TEST000` — niet-bestaande
container = laadt niets) op de remote D1, fetch de live HTML (`gtm.js?id=` in de head +
`ns.html?id=` na de body), en zet 'm daarna **terug op leeg**.
> ⚠️ **GTM laadt zelf geen cookies** tot er in de container een tag vuurt. Komt er tracking
> met cookies, dan is er apart een **cookie-consent** nodig (staat niet standaard op de site).
Concreet voorbeeld in dit project: `scripts/migrate-gtm-field.sql` + het GTM-blok in
`src/layouts/Base.astro` (frontmatter `gtmId` + de twee `{gtmId && ...}`-blokken).
---
## 9. Bouwen & deployen (Windows/corporate)
```powershell
$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
$env:NODE_OPTIONS="--use-system-ca" # corporate-cert workaround (anders hangt het)
node node_modules/astro/bin/astro.mjs check # typecheck: 0/0/0
node node_modules/astro/bin/astro.mjs build
node node_modules/wrangler/bin/wrangler.js deploy
```
- Roep tools aan via **`node node_modules/<tool>/bin/...`** (de `.bin`-shims ontbreken door
`--ignore-scripts`; `npx` wil dan een fresh install starten).
- **Secrets blijven** bestaan over deploys heen (los van de gegenereerde config).
- **Verifiëren vanaf deze machine:** corporate-DNS kan het domein niet resolven → forceer
de Cloudflare-IP: `curl -sk --resolve <domein>:443:<cf-ip> https://<domein>/`. IP ophalen
via DoH: `curl -sk "https://dns.google/resolve?name=<domein>&type=A"`.
- **Edge-cache na deploy:** de HTML kan een paar seconden een stale versie geven; poll
opnieuw na ~5s vóór je concludeert dat een wijziging niet live is.
- Live-logs: `node node_modules/wrangler/bin/wrangler.js tail <project> --format json`
(vang via een PowerShell `Start-Job` + `Receive-Job` als je niet interactief bent).
---
## 10. Checklist invullen
De oude standaard-checklist is **WordPress-gericht** en is vervangen door een schone,
stack-eigen versie. **Begin vanaf het lege template**
`Checklist - Websites (template, leeg).xlsx` (72 punten, WP-vrij, dropdown op
"Uitgevoerd" = Ja/Nee/N.v.t./Deels/Actie) en lever per project een ingevulde `.xlsx` op.
Vul de kolommen **Uitgevoerd / Wie / Wanneer / Opmerking**. Voorbeeld van een ingevulde
versie: `Checklist - Websites - behangerbrabant (ingevuld).xlsx`.
> De checklist heeft 9 secties: 1) Voorbereiding 2) Algemeen/inhoud 3) Techniek &
> beveiliging 4) Contactformulier & mail 5) DNS/hosting/mail-infra 6) Performance &
> toegankelijkheid 7) Browsers & responsive 8) SEO & vindbaarheid 9) Na livegang.
> Genereren/lezen van `.xlsx` met `openpyxl`; het oude WP-bron-`.xls` is OLE2 (lezen met `xlrd`).
Kern van de WP→stack-vertaling: plugins/iThemes/wp-login/xmlrpc/Duplicator/themes-FTP/
PHP-versie/cache-plugin/reCAPTCHA = **N.v.t.** (equivalent: Cloudflare + Astro: checkOrigin,
honeypot+time-trap, edge-caching, Worker `compatibility_date`); formulieren "opgeslagen in
beheer" = **D1 `contact_submissions`**; redirect via DirectAdmin = **middleware + Always Use
HTTPS**.
---
## 11. Per-klant te verzamelen (vóór livegang)
- Telefoon, e-mail, NAW, KvK/BTW, openingstijden → in `site_settings` (admin).
- **Mailbox**: adres + wachtwoord + in/uit-server + IP (van de collega). Contactpersoon
voor de autoreply-handtekening.
- **DKIM-record** uit DirectAdmin (collega).
- **CMS-login**: e-mailadres(sen) van de klant + gewenste rol (ADMIN/EDITOR) → gebruiker(s)
aanmaken (§6b).
- Echte foto's (hero/projecten/Arte), logo (SVG), favicon/og-image, echte reviews.
- **GTM-container-ID** (`GTM-XXXXXX`) van de klant, indien ze Tag Manager gebruiken → in
`site_settings` (§8a). Evt. ook Search Console-/Bing-verificatie (ingebouwde Settings → SEO).
- Akkoord op definitieve teksten + moment van livegang.
---
## 12. Go-live volgorde (samengevat)
1. Scaffold + `npm install` (corporate env).
2. Cloudflare D1 + R2 aanmaken, IDs in `wrangler.jsonc`.
3. Componenten + content-model bouwen; `seed/seed.json`. **Lees image-velden meteen uit**
(helper + statische fallback), galerij = **losse `image`-velden** (§5a).
4. `npm run build` + `deploy` → eerste Worker op `*.workers.dev`.
5. Setup-wizard draaien → content seeden; demo-tekst evt. via SQL bijwerken; **placeholders
in de media-library zetten + beeldvelden koppelen** (§5a).
6. **Mail**: `src/lib/smtp.ts` + `contact.ts` koppelen; `CONTACT_SMTP_HOST` op de
**serverhostname** (srv8), NIET `mail.<domein>` (§6); secret `CONTACT_SMTP_PASS` zetten
(bash!); testen via `wrangler tail` → 2× `mail-sent` + alle paden (422/honeypot/time-trap).
7. **CMS-login** (§6b): e-mail-provider-plugin registreren (hergebruikt `smtp.ts`) +
klant-gebruiker(s) in `users` aanmaken; magic-link testen (send → token → tail). Vermijd een
passkey op de preview-URL (breekt na de domeinkoppeling, §6b).
8. Zone op Cloudflare + nameservers om → **verifieer dat de zone `active` is** (niet `pending`;
NS naar de CF-NS, niet de hoster — §7); custom domain via `routes` (**rebuild!** → workers.dev
gaat uit). **Zet meteen `emdash:site_url` op het echte domein** (anders zijn de inlogmails dood — §6b).
9. **Mail-DNS** (MX/SPF/DKIM/DMARC) zetten (dashboard of DNS-token) + via DoH verifiëren.
10. HTTPS: middleware + "Always Use HTTPS" + SSL Full(strict) + HSTS.
11. SEO + perf-basics: favicon, robots, sitemap, canonical, meta, JSON-LD, self-hosted font,
`_headers`-caching.
12. **Checklist invullen** (§10) en met de klant aflopen.
13. **Klant-handover**: inlog-uitleg mailen (§6b) → klant-akkoord → live; daarna Search
Console + Analytics + PageSpeed-meting.
---
## 13. Bekende valkuilen (Windows/corporate) — niet opnieuw intrappen
- **`NODE_OPTIONS=--use-system-ca`** overal nodig (npm, astro, wrangler), anders hangt het
stil op het corporate-MITM-cert.
- **Tools via `node node_modules/.../bin`** (geen `.bin`-shims door `--ignore-scripts`).
- **Corporate-DNS resolvet je nieuwe domein niet** → forceer CF-IP met `curl --resolve`,
en check publieke DNS via **DoH** (`dns.google/resolve`), niet `nslookup`/poort 53.
- **Secrets via bash `printf`**, nooit via PowerShell-pipe (BOM → 535).
- **Rebuild ná elke `wrangler.jsonc`-wijziging** (adapter genereert eigen
`dist/server/wrangler.json`).
- **`emdash seed`-CLI werkt niet** (better-sqlite3) → seed via wizard.
- **Dev-server (Vite) is instabiel** (dep-optimizer 500's): vers herstarten + evt.
`node_modules/.vite` wissen; poll op *content*, niet alleen HTTP 200. Productie-build
heeft dit niet — testen kan het betrouwbaarst gewoon op een deploy.
- **Bestanden kunnen op deze machine verdwijnen** (antivirus/sync wist o.a. `node_modules`-
`.js` en losse bestanden). Herstel `node_modules` met `npm ci --ignore-scripts` (met env);
controleer na het aanmaken van losse bestanden of ze nog bestaan.
- **Emdash-login werkt niet out-of-the-box**: géén e-mail+wachtwoord, "Invite User" is een
dood JSON-endpoint, en de magic-link-knop geeft 503 zonder e-mail-provider-plugin. Los op
met de plugin + gebruikers via SQL (§6b). Reken er niet op dat de admin-UI dit regelt.
- **Een leeg image-veld doet niks** tenzij de component het uitleest (helper + fallback,
§5a). Upload-in-admin ≠ zichtbaar-op-site zolang de koppeling ontbreekt.
- **Twee "settings" in emdash:** de ingebouwde **Settings → SEO**-pagina is hardcoded en
niet uitbreidbaar; site-brede klant-velden (SEO-titel/description, **GTM**, NAW) horen in
de **`site_settings`-collectie** ("Instellingen"), waar `Base.astro` uit leest. De klant
zoekt GTM vaak op de verkeerde pagina. Zie §8a.
- **Passkey is origin-gebonden:** een passkey op `*.workers.dev` werkt NIET op het echte domein
→ login breekt na de domeinkoppeling. Nieuwe passkey op het echte domein, of mint handmatig een
magic-link-token (§6b). Vermijd passkeys op de preview.
- **`emdash:site_url` blijft op de preview staan:** de inlogmail-link wijst dan naar de uitgezette
`*.workers.dev` → de klant krijgt Cloudflares **"There is nothing here yet"**-placeholder. Update
de optie (`UPDATE options SET value='"https://<domein>"' ...`) bij de domeinkoppeling. Zie §6b.
- **`mail.<domein>` resolvet vaak niet** na de NS-verhuizing → `CONTACT_SMTP_HOST` = de
serverhostname (srv8), te vinden via de `/webmail`-redirect. `Error: Stream was cancelled` =
Worker-SMTP-verbindingsfout (host onvindbaar / cert-mismatch bij kaal IP). Zie §6.
- **"DNS is omgezet" ≠ klaar:** check dat de Cloudflare-zone `active` is (niet `pending`); NS
moeten naar de CF-NS, niet de hoster (anders SERVFAIL / lame delegation). Zie §7.
- **wrangler-OAuth-token:** **403** op DNS-records én zone-instellingen (SSL/HSTS); custom-domain-
creatie werkt wél. Mail-DNS/HTTPS via dashboard of `Zone.DNS:Edit`-token. Zie §7.
- **Mail-DNS van een zuster-zone kopiëren?** Pas de DMARC **`rua`** (en DKIM) aan naar het nieuwe
domein — copy-paste-valkuil (Helvoirt stond op behangerbrabant).
- **PowerShell-valkuilen:** `$HOME` is **read-only** (gebruik een andere variabelenaam); native-exe-
stderr laat het tool vaak "exit 255/1" melden terwijl de actie **slaagde** (check de echte
output op `success`); `curl.exe` met een **JSON-body** breekt op de quoting → gebruik
`Invoke-WebRequest -Body '{"...":"..."}'` voor JSON-POSTs.
---
## 14. Verwijzingen
- `architecture.md` — datamodel, sectie-volgorde, repo-layout (deels achterhaald op detail).
- `todo.md` — actuele projectstatus + alle besluiten/gotchas per sessie.
- `Checklist - Websites (template, leeg).xlsx` — **leeg herbruikbaar template** (start hier).
- `Checklist - Websites - behangerbrabant (ingevuld).xlsx` — voorbeeld van een ingevulde
checklist.
- Memory `wrangler-secret-bom-footgun` — de BOM-valkuil (geldt voor alle zustersites).
- Memory `emdash-schema-lives-in-d1` — schema in D1; veld toevoegen / collectie verwijderen op een live site.
- Memory `emdash-media-population` — image-veld opslag/serveer-model + zelf media in de library zetten.
- Memory `emdash-passwordless-magic-link` — passwordless auth, invite-bug, en de e-mail-provider-plugin voor magic-link.