managarten/docs/plans/website-builder-smoketest.md
Till JS 441f95697b docs(website): smoketest walkthrough + manual-test-backlog entry
The code is shipped (M1–M7) but nothing has run against real
Postgres + mana-sync + mana-media + a browser. This smoke-test doc is
the click-through a human needs to do before we trust the feature in
production.

- docs/plans/website-builder-smoketest.md — 10 scenarios end-to-end
  from migrations + dev-stack through create/publish, block coverage
  (image upload, gallery lightbox, columns container), forms with
  honeypot + rate-limit, moduleEmbed with public-flag enforcement,
  templates + AI tools, subdomain rewrite, custom-domain DNS verify,
  rollback + analytics, metrics + GC script, edge-cases + security.
  Lists bekannte Limits (CF SaaS gap, target-delivery, AiProposalInbox)
  explicitly so the tester knows what NOT to expect.

- docs/optimizable/manual-test-backlog.md — new release-blocker entry
  pointing at the walkthrough. Follows the same format as the Shared
  Space + Data Export entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:42:42 +02:00

21 KiB
Raw Permalink Blame History

Website Builder — Smoketest

Schritt-für-Schritt-Anleitung, um den Website-Builder End-to-End zu validieren — vom Site-Anlegen über Publish bis hin zu Custom-Domains, Forms und Rollback.

Warum nötig: 7 Milestones (M1M7) mit ca. 7000 Zeilen Code, alle Unit-/Type-Checks grün, aber nichts davon ist bisher durch einen echten Browser mit echtem Postgres gelaufen. Unit-Tests decken nicht ab: Dexie-v37-Upgrade aus existierender DB, Encryption-Registry-Integration, mana-sync Wire-Format, SSR-Public-Render-Pfad, Cloudflare-Cache-Header-Propagation, DNS-Resolver mit echten Records, Form-Submit-Flow über same-origin Proxy, AI-Proposal-Staging.

Scope: Das, was Code tun soll. Außerhalb: Cloudflare-SaaS-Hostname-Provisioning für Custom-Domain-TLS (ops-Lücke, dokumentiert in §M6 des Plans) und Target-Delivery für Form-Submissions zu contacts/notify (M4.x).

Siehe docs/plans/website-builder.md für das Design, docs/observability/website.md für Metrics.


Vorbereitung

1. Schema-Migrationen anwenden

Der Website-Builder hat drei SQL-Migrationen im website.* pg-Schema. Vor dem ersten Test anwenden:

export DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform
psql "$DATABASE_URL" -f apps/api/drizzle/website/0000_init.sql
psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql
psql "$DATABASE_URL" -f apps/api/drizzle/website/0002_custom_domains.sql

Verify:

psql "$DATABASE_URL" -c "\dt website.*"

Erwartet: published_snapshots, submissions, custom_domains.

2. Dev-Stack starten

pnpm docker:up           # postgres + redis + minio
pnpm run mana:dev        # mana-auth (3001) + mana-sync (3050) + web (5173) + api (3060)

Wenn irgendeiner der Services nicht hochkommt:

  • pnpm docker:logs mana-postgres — Postgres-Probleme
  • services/mana-auth-Konsole — JWT/Org-Fehler

3. Dev-User mit Founder-Tier

Custom-Domains brauchen founder Tier. Nutze den existierenden Dev-User oder leg einen neuen an:

pnpm setup:dev-user

Legt tills95@gmail.com / tilljkb@gmail.com / rajiehq@gmail.com alle als founder an. Passwort jeweils Aa-123456789.

Der Signup-Hook vergibt jedem Account automatisch einen personal Space — den nutzen wir.

4. Env-Vars optional

Für Cloudflare-Purge (M6 ops-Stub) optional:

CF_API_TOKEN=…
CF_ZONE_ID=…

Ohne das loggt die API nur [website] CF onboard TODO — alle lokalen Tests funktionieren trotzdem.

Für den Orphan-Asset-GC-Script (M7):

MANA_SERVICE_KEY=dev-service-key   # für internal /media/list

Szenario 1 — Grundfluss (M1 + M2)

Schritt 1: Site anlegen

  1. In den Browser: http://localhost:5173/website
  2. Klick "+ Neue Website" → landet auf /website/new (TemplatePicker)
  3. Wähle Template "Leer" (oder "Portfolio" wenn du mehr Content willst)
  4. Name: Mein Test, Slug wird automatisch zu mein-test
  5. Klick "Mit Leer starten"

Erwartet:

  • Redirect zu /website/<siteId>/edit/<homePageId>
  • Drei-Pane-Editor: links Site-Meta + PageList + InsertPalette, Mitte leere Vorschau, rechts Inspector-Placeholder
  • Header zeigt "Mein Test" + ⚙-Button

Dev-Tools:

  • Application → IndexedDB → manawebsites → eine Row mit deinem Namen
  • Application → IndexedDB → manawebsitePages → eine Row mit path='/'

Schritt 2: Blöcke einfügen

  1. InsertPalette (unten links) → klick "Hero" → erscheint mittig, wird selektiert
  2. Inspector rechts: Titel ändern zu "Willkommen", Untertitel "Meine erste Seite"
  3. Palette → "Text" → klick in Vorschau um zu wählen → Inspector → Content tippen, zwei Absätze mit Leerzeile zwischen
  4. Palette → "Abstand" → Größe "Groß"

Erwartet:

  • Live-Updates in Vorschau beim Tippen (Reaktivität via Dexie)
  • Klick auf einen Block → outline indigo, Inspector zeigt die Felder dieses Blocks
  • Pfeil-Buttons (↑↓) im Inspector ändern Reihenfolge
  • × im Inspector-Header löscht

Schritt 3: Publish

  1. Oben PublishBar → "Veröffentlichen" klicken
  2. Pill wechselt von "Entwurf" → "Live"
  3. Link /s/mein-test wird geklickbar

Erwartet:

  • POST zu /api/v1/website/sites/<id>/publish (DevTools → Network → 201)
  • Response hat snapshotId, publishedAt, publicUrl: '/s/mein-test'

Schritt 4: Public-Render

  1. Link öffnen (oder http://localhost:5173/s/mein-test direkt)
  2. Erwartet:
    • SSR-Render: View-Source zeigt Hero-HTML ohne JavaScript-Rehydrate-Marker (pure SSR)
    • Keine Navigation-Leiste (kein navConfig.items), nur Brand-Link
    • Network: GET /api/v1/website/public/sites/mein-test mit Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
    • Response-Header Cache-Tag: site-<id>

Schritt 5: Editor + Draft-Ahead-Indicator

  1. Zurück in den Editor
  2. Hero-Titel ändern zu "Willkommen!"
  3. PublishBar zeigt Pill "Unveröffentlichte Änderungen"
  4. Öffne /s/mein-test in anderem Tab → immer noch der alte Titel (erwartet, Draft ≠ Live)
  5. PublishBar → "Änderungen veröffentlichen" → Reload des Public-Tabs → neuer Titel

Schritt 6: Rollback (M7-Polish)

  1. PublishBar → "Versionen" klicken → Dialog mit 2 Snapshots
  2. Obere Row = "Aktuell live" mit grünem Pill
  3. Untere Row (erste Publish) → "Wiederherstellen" klicken → bestätigen
  4. Dialog reload: neue aktuelle Row ist die alte
  5. /s/mein-test reloaden → Titel ohne !

Szenario 2 — Block-Coverage (M3)

Image-Upload

  1. Neuer Block "Bild" → Inspector rechts → Dropzone
  2. Klicke/ziehe ein JPG oder PNG rein (< 25 MB, irgendein Testbild)
  3. Erwartet:
    • POST an /api/v1/media/upload mit app=website
    • Response { id }, URL wird automatisch in block.props.url geschrieben
    • Vorschau zeigt das Bild

Fehlerfälle:

  • Nicht-Bild-Datei → "Bitte wähle ein Bild (PNG, JPG, WEBP, GIF)."
  • Datei > 25 MB → "Datei zu groß (max 25 MB)."
  1. Block "Galerie" → Inspector → "+ Bilder hinzufügen" → mehrere Dateien gleichzeitig
  2. Alt-Text pro Bild eintragen (Pflicht für A11y — sollte Screenreader-Crawl später nicht stören)
  3. Layout/Columns/Lightbox-Toggle testen
  4. Im Editor: Gallery zeigt alle Bilder im Grid, Klick auf ein Bild wählt nur den ganzen Block (Editor-Mode)
  5. Public (/s/mein-test): mit lightbox=true, Klick auf Bild → Fullscreen-Overlay mit Prev/Next ( ) + Escape
  6. Escape/Klick außerhalb schließt

Columns-Container

  1. Block "Spalten" → 2 oder 3 Columns
  2. Klick in eine Column-Drop-Zone ("Spalte 1")
  3. Insert-Palette nochmal öffnen, einen Text-Block einfügen — landet in der Column
  4. Erwartet: Text ist Child des Container-Blocks, parentBlockId in der Dexie-Row zeigt auf Columns-Block-ID, slotKey = 'col-0'
  5. Public-Mode: responsive, < 720px stapeln sich die Columns untereinander (stackOnMobile=true Default)

Manuelle Verify:

# In DevTools Console:
(await db.websiteBlocks.toArray()).map(b => ({ type: b.type, parentBlockId: b.parentBlockId, slotKey: b.slotKey }))

Sollte mindestens eine Row zeigen mit parentBlockId: <columns-id> und slotKey: 'col-0'.

Theme wechseln

  1. Header → ⚙-Button → SiteSettingsDialog
  2. Unter "Theme" auf eines der drei Presets klicken (Classic / Modern / Warm)
  3. Farb-Overrides testen: Color-Picker für Primary, Background, Foreground
  4. "Speichern" → Dialog schließt
  5. Public-Reload → neue Farben + Font angewandt (CSS-Variablen)

Szenario 3 — Forms (M4)

  1. Editor → Block "Formular" (kommt mit 3 Default-Fields: Name, E-Mail, Nachricht)
  2. Inspector: Feld hinzufügen — "Telefon" Typ tel, optional
  3. Titel und Description setzen, Submit-Label auf "Absenden" lassen
  4. Site neu publishen (wichtig: das Form-Schema wird im Snapshot eingefroren)
  5. /s/mein-test öffnen → Formular sichtbar

Submit-Flow testen

  1. Felder ausfüllen (required: Name + E-Mail + Nachricht)
  2. Absenden → "Danke! Wir melden uns bald."

Erwartet:

  • POST an /s/mein-test/__submit/<blockId> (same-origin SvelteKit proxy)
  • Von dort Forward an /api/v1/website/public/submit/mein-test/<blockId>
  • Response 201 mit submissionId

Honeypot-Check

  1. In der gleichen Form-Instanz: DevTools → Elements → finde <input name="honeypot"> in der verborgenen .wb-form__honeypot-Wrapper
  2. Temporär sichtbar machen (display: block), Wert "spam-bot" eintragen, normal absenden
  3. Erwartet: Success-Message erscheint (act-as-success-for-bots), aber nichts in der Submissions-Tabelle

Verify:

psql "$DATABASE_URL" -c "SELECT created_at, payload, status FROM website.submissions ORDER BY created_at DESC LIMIT 3"

Rate-Limit

  1. Submit-Endpoint 11× schnell in Folge (Script / Postman / Hand-Refresh)
  2. Ab der 11. Response: 429 "Rate limit überschritten"

Submissions-Inbox

  1. Im Editor zurück zu Site → Top-Level-Submenu hat (noch) keinen Link, also URL direkt: /website/<siteId>/submissions
  2. Erwartet: Deine Submissions (die echten, nicht die Bot-Spam-Try) als Liste
  3. Löschen-Button funktioniert

Szenario 4 — Module-Embed (M4)

Picture-Board einbetten

Vorbereitung: in /picture mindestens ein Board anlegen, ein paar Bilder hochladen + auf Board ziehen, Board auf "Öffentlich" flippen (Board-Detail → Einstellungen).

  1. Zurück im Website-Editor → Block "Modul einbetten"
  2. Inspector → Quelle: picture.board, Quellen-ID: die UUID deines Public-Boards (copy aus /picture/board/<id>-URL)
  3. Layout: "Grid", Max 12
  4. Im Editor: Platzhalter-Text "Nicht aufgelöst. Quelle: picture.board …"
  5. Publish! Während des Publishes wird der Embed aufgelöst:
    • DevTools → Network → POST /publish hat einen blob.pages[0].blocks[].props.resolved.items: [...]
  6. Public-Mode: Grid mit den Board-Images

Public-Gate verify

  1. Board auf "nicht öffentlich" flippen
  2. Site erneut publishen
  3. Public-Mode: Platz mit Error-Pill "Einbettung fehlgeschlagen: Board ist nicht öffentlich …"
  4. Board wieder öffentlich, erneut publishen → wieder ok

Library-Embed (optional)

  1. In /library 2-3 Bücher/Filme anlegen, eines favorisieren ()
  2. ModuleEmbed mit library.entries, Filter isFavorite=true
  3. Publish → Public zeigt nur Favoriten

Szenario 5 — Templates + AI (M5)

Template-Flow

Schon in Szenario 1 gemacht wenn du nicht "Leer" gewählt hast. Teste alle vier:

  • Portfolio — 4 Seiten, inkl. Gallery + Form
  • Link-Sammlung — 1 Seite mit 6 CTAs
  • Event — 3 Seiten inkl. RSVP-Formular
  • Leer — 1 leere Seite

Erwartet:

  • Jedes Template setzt navConfig.items passend zu den Seiten
  • Alle Blöcke haben frische UUIDs (nicht die Template-localIds)
  • parentBlockId-Chains bleiben korrekt nach dem Clone

Verify mit:

# Alle Site-UUIDs sollten eindeutig sein — keine Collisions mit früheren Template-Nutzungen

AI-Tool-Flow

Voraussetzung: Companion-Chat / Mission funktioniert lokal, mana-llm antwortet.

  1. /ai-missions → neue Mission: Objective "Erstelle mir eine Portfolio-Website für meinen Freund Alex"
  2. Run Mission → warte auf Planner-Response
  3. Erwartet: Proposals im Proposal-Inbox (falls AiProposalInbox gewired ist — siehe bekannte Limits) mit:
    • apply_website_template(templateId: 'portfolio', name: ..., slug: ...)
    • oder einzelne create_website / add_website_block Steps
  4. Approve → Website taucht in /website auf

Bekannt: Wenn AiProposalInbox UI noch nicht existiert (laut Plan), bleiben Proposals in pendingProposals Dexie-Table. Verify mit:

// DevTools Console
(await db.pendingProposals.toArray()).filter(p => p.toolName?.startsWith('website'))

Foregroung-Execution (ohne Approval-UI) von list_websites in einem Chat klappt — das ist defaultPolicy: 'auto'.


Szenario 6 — Subdomain-Publishing (M6)

Lokal: Subdomains auf localhost funktionieren nicht out-of-the-box — mein-test.localhost wird vom Browser nicht automatisch zu localhost aufgelöst. Drei Optionen:

A) /etc/hosts Eintrag (einfach):

127.0.0.1 mein-test.localhost

Dann: http://mein-test.localhost:5173/ sollte deine publizierte Site laden.

B) In Production (mana.how): automatisch via Wildcard-DNS — Config ist Cloudflare-Sache.

C) Override via SvelteKit-Test-Param (nur Code-Check): öffne DevTools → Network → resend das Request mit custom Host: mein-test.mana.how Header. Response-Body sollte der HTML der Public-Seite sein.

Erwartet:

  • hooks.server.ts rewritet event.url.pathname intern zu /s/mein-test
  • URL im Browser bleibt auf mein-test.mana.how (kein 302-Redirect!)

App-Subdomain wins

  1. Versuche: Site mit Slug todo anzulegen → InvalidSlugError "reserved"
  2. Auch wenn du durch die Validierung kämst: todo.mana.how ist in APP_SUBDOMAINS, Hook leitet auf /todo (Todo-Modul) weiter, nicht auf die Website.

Szenario 7 — Custom-Domain (M6)

Voraussetzung: Account ist founder Tier. Nicht-Founder sieht "Eigene Domain" Section gar nicht oder bekommt 403 vom API.

Domain hinzufügen

  1. Editor → → SiteSettingsDialog → ganz unten "Eigene Domain"
  2. Input: portfolio.example.com → klick "+ Domain"
  3. Erwartet:
    • Row in Liste mit Pill pending
    • DNS-Konfigurations-Box zeigt:
      CNAME  portfolio.example.com                 → custom.mana.how
      TXT    _mana-challenge.portfolio.example.com → <32-char-hex>
      
    • Click-to-Copy funktioniert auf den Werten
  4. Verify in DB:
    psql "$DATABASE_URL" -c "SELECT hostname, status, verification_token FROM website.custom_domains"
    

DNS-Verify (mit echter Domain)

Wenn du eine echte Domain zur Hand hast:

  1. In deinem DNS-Provider:
    • CNAME portfolio.example.comcustom.mana.how
    • TXT _mana-challenge.portfolio.example.com<token>
  2. DNS-Propagation abwarten (dig @1.1.1.1 TXT _mana-challenge.portfolio.example.com sollte den Token zeigen)
  3. "Verify" klicken
  4. Erwartet: Pill wechselt auf verifying (kurz) → verified (grün). verified_at in DB gesetzt.

DNS-Verify (Simulation mit /etc/hosts — funktioniert NICHT)

Node's dns.resolveTxt nutzt System-DNS, nicht /etc/hosts. Der Verify-Call wird immer ENOTFOUND liefern für erfundene Hosts. Alternative: temporär status per SQL auf verified setzen:

psql "$DATABASE_URL" -c "UPDATE website.custom_domains SET status='verified', verified_at=now() WHERE hostname='mein-test.local'"

Dann: /etc/hosts-Eintrag, Resolver-Cache im Hook leeren (60s TTL, oder Server restart), und http://mein-test.local:5173/ → sollte deine Site laden.

Reserved-Hostname

  1. Versuche mana.how oder api.mana.how einzutragen → 400 "Ungültiger oder reservierter Hostname"
  2. Versuche *.mana.how (deine Subdomain-Root) → Reserved

Invalid Tier

  1. Log out, einloggen als ein Nicht-Founder-User (public Tier)
  2. Gehe zu gleicher Site (wenn in shared space) oder eigene → SettingsDialog → "Eigene Domain"-Section
  3. Add → Erwartet: 403 vom API, UI zeigt "Forbidden" oder ähnliche Fehlermeldung

Szenario 8 — Analytics-Block (M7)

  1. Editor → Block "Analytics" → Inspector
  2. Provider "Plausible", Domain deineseite.com (beliebiger String, real muss nicht existieren)
  3. Script-URL leer lassen (nutzt https://plausible.io/js/script.js)
  4. Im Editor sichtbar als dezente Pill 📊 Analytics: plausible (deineseite.com)
  5. Publish!
  6. Public-Mode → View-Source → finde:
    <script defer data-domain="deineseite.com" src="https://plausible.io/js/script.js"></script>
    
  7. Wechsel zu "Umami", Site-Key abc12345-1234-…, self-hosted URL testen

A11y-Check: Analytics-Block hat aria-hidden="true" im Edit-Mode (Meta-Pill) und emittiert nur ein <script>-Tag im Public-Mode. Keine visuellen Artefakte für Screenreader.

Privacy-Check: Keine Cookies im document.cookie nach Page-Load. Plausible + Umami sind cookieless.


Szenario 9 — Metrics + GC (M7)

Metrics-Endpoint

curl -s http://localhost:3060/metrics | grep website

Erwartet: Zeilen wie:

mana_api_website_publish_total{service="mana-api",result="success"} 3
mana_api_website_publish_duration_seconds_bucket{...,le="0.1"} 2
mana_api_website_submissions_total{service="mana-api",result="received"} 1
mana_api_website_submissions_total{service="mana-api",result="spam"} 1
mana_api_website_host_resolve_total{service="mana-api",result="miss"} 42
mana_api_website_public_reads_total{service="mana-api",result="hit"} 17
mana_api_website_public_read_age_seconds_bucket{...,le="60"} 5

Je nachdem wie viel du in den Szenarien vorher gemacht hast. Counts nach jedem Publish/Submit neu checken — die Werte müssen steigen.

Orphan-Asset-GC

MANA_SERVICE_KEY=dev-service-key bun apps/api/scripts/gc-website-assets.ts

Erwartet:

  • Log: "scanning published_snapshots + submissions for media references"
  • Log: "referenced mediaIds: N"
  • Log: "media items in scope: M"
  • Report-Datei: /tmp/gc-website-assets-<ts>.json

Wenn MANA_SERVICE_KEY nicht gesetzt: Script warnt und zeigt 0 Media-Items. Erwartet-Verhalten, kein Bug.

Zum Testen eines Orphans: uploade ein Bild über mana-media (DevTools → uploadImage direkt), setze es NICHT in einen Block. Script sollte es nach 30d Grace als Orphan listen — für den Test kannst du den GRACE_MS-Konstant im Script temporär auf 0 setzen.


Szenario 10 — Edge-Cases + Sicherheit

Space-Scope-Check

  1. Zwei Personal-Spaces (User A + User B), beide haben eine Website mit Slug mein-test
  2. User A's Slug mein-test geht live
  3. User B versucht zu publishen mit gleichem Slug → 409 "Slug is already taken"
  4. User A unpublished → User B kann jetzt publishen

SSRF / Host-Header-Injection

  1. DevTools → resend request mit Host: internal-metrics.local → Hook ruft /resolve-host?host=internal-metrics.local → 404 → kein Rewrite, SvelteKit sieht das als normale Route, zeigt 404

Form-Validation an Server-Seite

  1. Manuell POST zum submit-Endpoint mit einem extra Feld das nicht im Block deklariert ist:

    curl -X POST http://localhost:5173/s/mein-test/__submit/<blockId> \
      -H 'content-type: application/json' \
      -d '{"name":"A","email":"a@b.c","message":"hi","admin":true}'
    
  2. Erwartet: 201, aber admin wird ignoriert (nur deklarierte Fields gehen in payload)

  3. Verify: SELECT payload FROM website.submissions ORDER BY created_at DESC LIMIT 1admin sollte nicht drin sein.

  4. Missing required field:

    curl -X POST … -d '{"name":"","email":"a@b.c","message":"hi"}'
    

    Erwartet: 400 "Pflichtfeld Name fehlt"

Dexie-v37-Upgrade aus existierender DB

  1. Browser-Profile mit bestehender Mana-DB (v36 oder früher)
  2. App-Reload → Dexie upgraded auto auf v37 → v41 (wenn wardrobe schon drin)
  3. DevTools → Application → IndexedDB → mana → Version zeigt 41 (oder höher)
  4. Öffne /website → funktioniert ohne Fehler

Bekannte Limits / was NICHT getestet wird

  1. Cloudflare-SaaS-Hostname-ProvisioningcloudflareOnboard() ist gestubbed. Live-TLS für Custom-Domains braucht CF-Credentials in prod-env. Details: docs/plans/website-builder.md §M6.
  2. Target-Delivery für Submissions — Formular-Submissions landen in website.submissions (Inbox). Forwarding zu contacts.create oder mana-notify ist M4.x, braucht Server-side Tool-Handler-Infrastructure.
  3. AiProposalInbox UI — Component existiert nicht im Repo; Proposal-Staging funktioniert, aber die Inbox-View für Website-Blocks muss jemand bauen.
  4. Multi-Member-Co-Editing — Shared-Spaces-RLS erlaubt mehreren Members, dieselbe Site zu editieren. mana-sync field-level LWW kümmert sich um Konflikte pro Feld. Nicht im Smoke-Scope, aber architektonisch vorgesehen.
  5. Drag-Drop-Block-Reorder — aktuell nur Pfeil-Buttons im Inspector. @dnd-kit-Integration ist Backlog.

Verify-Checkliste (Kurzform)

Nach allen Szenarien solltest du folgendes beobachtet haben:

  • Site erstellen + Blöcke einfügen + Live-Preview updated
  • Publish → /s/<slug> serviert SSR-HTML
  • Draft-ahead-Pill zeigt wenn Editor ≠ Live
  • Rollback-Dialog restored ältere Snapshot
  • Image-Upload via mana-media (25 MB Limit greift)
  • Gallery-Lightbox im Public-Mode mit Escape + Pfeiltasten
  • Columns-Container: Child-Blöcke haben korrekten parentBlockId + slotKey
  • Theme-Switch propagiert CSS-Variablen in Public-Mode
  • Form-Submit landet in Inbox + 429 nach 10 Submits
  • Honeypot blockt Bot-Submits silent
  • ModuleEmbed: Public-Flag wird enforced (private Board → Error-Pill)
  • Template-Clone hat frische UUIDs + intakte parentBlockId-Chain
  • AI-Tool list_websites im Chat liefert echte Daten
  • Subdomain-Rewrite funktioniert (via /etc/hosts oder Host-Header)
  • Custom-Domain Add + DNS-Verify-UI + 403 für Non-Founder
  • Reserved-Hostnames werden abgelehnt
  • Analytics-Block emittiert genau ein <script>-Tag im public-Mode
  • /metrics-Endpoint hat Counter-Values für alle getesteten Operations
  • GC-Script läuft und schreibt JSON-Report

Wenn alle Punkte grün sind, Eintrag aus docs/optimizable/manual-test-backlog.md löschen.