Files
helvoirtgevelreiniging.nl/newsite.md
T

715 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.