managarten/docs/plans/website-builder.md
Till JS 4fc9d6c59c feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)
M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

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

794 lines
49 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.

# Website Builder — Block-Tree CMS für Privat + Firma
_Started 2026-04-23._
Ein Modul `website`, mit dem Nutzer (privat) und Firmen (Space mit mehreren Mitgliedern) mehrseitige Websites bauen, live bearbeiten und unter `mana.how`-Domains veröffentlichen. Kein Drag-Drop-Canvas wie Framer/Webflow, sondern ein **Block-Baum-Editor** mit Zod-validierten Block-Typen — dieselben Svelte-Komponenten rendern im Editor, in der Live-Preview und im öffentlichen Seitenaufruf. Content aus anderen Mana-Modulen (picture, library, news, …) wird per `moduleEmbed`-Block direkt eingebettet.
Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Zielzustand direkt, keine Legacy-Reste.
## Ziel in einem Satz
Jeder Mana-Nutzer (oder jede Firma via Space) kann eine vollständige Website mit beliebig vielen Seiten über einen Block-Baum-Editor bauen, Daten aus seinen Mana-Modulen einbetten, und unter `/s/{slug}`, später `{slug}.mana.how` oder einer Custom-Domain veröffentlichen — mit SSR-Rendering aus denselben Svelte-Komponenten, ohne separaten Astro-Build-Pfad.
## Nicht-Ziele
- **Kein Free-Form Canvas.** Keine absolute Positionierung, keine Pixel-CSS. Layout über Block-Typen, Theme-Variablen und wenige Container (`columns`, `rows`, `spacer`).
- **Kein dualer Renderer.** Keine Svelte-Komponenten **und** Astro-Komponenten für dieselben Blöcke. Der öffentliche Renderer ist SvelteKit-SSR, der Editor rendert dieselben Components.
- **Keine Admin-UI-Nutzung des bestehenden `mana-landing-builder` Services** für User-Sites. Der Service bleibt für Org-Landing-Pages (andere Code-Pfade, andere Zielgruppe). Wir beschreiben in M6 optional eine Konsolidierung.
- **Kein Plugin-System.** Block-Typen sind intern und in `packages/website-blocks` versioniert. Dritt-Blöcke erst wenn Bedarf real wird.
- **Kein Markdown-Editor-Ersatz.** RichText-Blöcke nutzen einen kuratierten Satz Tiptap-Extensions, nicht Markdown. Ein Export-zu-Markdown ist möglich, aber nicht Teil des Write-Pfades.
- **Keine E-Commerce-Primitive.** Shop, Warenkorb, Checkout: nicht Scope. Pricing-Blöcke sind Display-only.
- **Keine Versionierung auf Block-Ebene.** Sites haben `draft` und `published` als zwei konsistente Snapshots; kein per-Block-History-Browser.
## Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ Editor (auth-gated, local-first) │
│ apps/mana/apps/web/src/routes/(app)/website/… │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Seitenbaum + │ │ Block-Baum + │ │ Inspector │ │
│ │ Seite-Settings│ │ Live Preview │ │ (Zod → Form) │ │
│ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │
│ └──── Dexie (websites/pages/blocks) ───┘ │
│ │ │
│ ▼ │
│ encryptRecord(plaintext) → table.add() │
│ │ │
│ ▼ │
│ _pendingChanges (appId='website') │
└──────────────────────────┬───────────────────────────────────┘
│ (sync engine, same pipe as every module)
mana-sync → Postgres (website.* schema)
│ read path for public visitors
┌──────────────────────────────────────────────────────────────┐
│ Public renderer (no auth, SSR) │
│ apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/… │
│ │
│ +page.server.ts │
│ └─ resolveSite(siteSlug, path) │
│ └─ reads published snapshot from Postgres (no Dexie) │
│ │ │
│ ▼ │
│ +page.svelte renders <BlockRenderer mode="public" /> │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Form submissions (no auth) │
│ POST /api/v1/website/sites/:id/submit/:blockId │
│ → validate against stored block schema (Zod) │
│ → write to target module via mana-tool-registry handler │
│ → record in websiteSubmissions for audit │
└──────────────────────────────────────────────────────────────┘
```
## Entscheidungen
Explizit, begründet, und als Ankerpunkt für spätere Zweifel.
### D1 — Ein Block-Typ, eine Svelte-Komponente, drei Render-Modi
Jeder Block-Typ (`hero`, `richText`, `image`, `gallery`, `form`, …) lebt als **genau eine** Svelte-Komponente in `packages/website-blocks/src/{type}/{Type}.svelte`. Die Komponente bekommt `{ block, mode }`, wobei `mode ∈ 'edit' | 'preview' | 'public'`. Im `edit`-Mode werden Inline-Editing-Controls sichtbar; im `public`-Mode reine Anzeige.
**Warum:** Ein Codepfad bedeutet: wenn der Editor eine Change rendert, sieht der Besucher später exakt dasselbe. Dual-Rendering (Svelte-Editor + Astro-Public) wie in `shared-landing-ui` heute erzeugt garantiert Drift.
**Konsequenz:** Der SvelteKit-Public-Renderer ist SSR, nicht statisch. Für Performance siehe D9 (Caching).
### D2 — Block-Schema ist SSOT für Rendering, Validierung, UI, AI-Tools
Pro Block-Typ ein Zod-Schema in `packages/website-blocks/src/{type}/schema.ts`. Das Schema ist gleichzeitig:
1. **Datenbank-Validierung** (Store schreibt, Server validiert)
2. **Inspector-Formular** (Auto-Generierung via `zod-to-form`-Utility)
3. **AI-Tool-Input** (über `mana-tool-registry`, siehe D7)
4. **Persistenz-Schema-Migrationen** (jedes Block-Schema hat `version`, Upgrader)
**Warum:** Schema und Renderer werden immer zusammen geändert. Wenn sie getrennt leben, bekommen wir stille UI-Abweichungen vom Datenmodell. Zod als eine Quelle schließt das aus.
**Block-Paket-Skizze:**
```
packages/website-blocks/src/
├── hero/
│ ├── schema.ts # HeroBlockSchema (Zod, v1)
│ ├── Hero.svelte # Renderer (mode-aware)
│ ├── Hero.inspector.ts # optional: custom inspector (sonst auto)
│ └── index.ts
├── richText/…
├── image/…
├── gallery/…
├── form/…
├── moduleEmbed/…
├── columns/…
├── spacer/…
├── cta/…
├── faq/…
├── registry.ts # { type → { schema, Component, icon, category } }
└── index.ts
```
### D3 — Block-Baum über `parentBlockId`, Reihenfolge über `order`
Blöcke speichern `parentBlockId` (nullable — Top-Level auf einer Seite) und `order` (double-linked via fractional indexing, kein Reindex bei Insert). Container-Blöcke (`columns`, `rows`) haben mehrere Slots; `slotKey` ist optional auf dem Child.
**Warum:** Flache Tabelle mit `parentBlockId` ist die konventionelle, gut-getestete Repräsentation eines Baums in einem CRDT-fähigen System (wir haben field-level LWW via mana-sync). Alternativen:
- *JSON-Blob für den ganzen Baum*: einfach, aber jedes Move eines Blocks schreibt den gesamten Baum. Konfliktverlust garantiert bei Co-Editing.
- *Nested Set / Path-Enumeration*: schnell für Lese-Queries, aber Writes sind teuer und Konflikte weh tun.
Flach + `parentBlockId` + fractional index = feld-weise LWW ist pro Block funktionabel, Co-Editing zweier Member am selben Block ist sicher (Timestamp entscheidet pro Feld).
### D4 — Drei Tabellen plus optional Submissions, alle space-scoped, alle plaintext
```
websites { id, spaceId, slug, name, theme, navConfig, footerConfig,
publishedVersion, draftUpdatedAt, settings }
websitePages { id, siteId, path, title, seo, order }
websiteBlocks { id, pageId, parentBlockId, type, slotKey, props, order, schemaVersion }
websiteSubmissions { id, siteId, blockId, payload, targetModule, targetRecordId,
status, createdAt, ip, userAgent }
```
Alle Felder plaintext. Begründung: Site-Content ist öffentlich — es für den Autor zu verschlüsseln wäre sinnfrei, und macht SSR im Public-Path unmöglich (der Server hat keinen MK). Form-Submissions können sensible Daten enthalten; die landen nach Validierung in den Zielmodulen (z.B. `contacts`), dort sind die existenten Encryption-Regeln gültig. Der Submission-Audit-Row (`payload`) wird nach erfolgreicher Weitergabe geleert (siehe M2).
### D5 — Publish-Modell: `draft` + `published` als zwei separate Snapshots
Jedes `website` hat einen `publishedVersion` (UUID). Editor schreibt immer gegen den Draft (= die Live-Tabellenzeilen). Auf "Publish" wird ein Snapshot erzeugt: `websitePublishedSnapshots { siteId, version, blob }` — das `blob` ist ein vollständig aufgelöster, deterministisch serialisierter Baum (JSON). Der Public-Renderer liest nur dieses Blob.
**Warum:**
- Editor kann beliebig herumspielen, ohne dass Besucher halbfertige Seiten sehen.
- Rollback ist trivial: `publishedVersion` zeigt auf älteren Snapshot.
- Public-Read ist **ein** Query (`SELECT blob WHERE siteId AND version`), kein JOIN über drei Tabellen.
- Snapshots sind unveränderlich — gut cachebar (D9).
**Alternative verworfen:** "Live-Edit = sofort live". Katastrophal für Firmen-Nutzung, wo mehrere Member über Stunden editieren. Draft/Publish ist der nicht-verhandelbare Standard.
### D6 — Public-Serving über SvelteKit-Route, nicht via `mana-landing-builder`
`apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts` lädt Site + Page + BlockTree aus Postgres und rendert SSR. `mana-landing-builder` wird **nicht** erweitert — der Service bleibt für den separaten Org-Landing-Pages-Use-Case (admin-only), in M6 wird entschieden, ob er fusioniert oder abgelöst wird.
**Warum:**
- Astro-Static-Export in `mana-landing-builder` zwingt zu dualem Rendering (D1 verletzt).
- SvelteKit-SSR mit Caching (D9) ist für Hunderttausende User-Sites schnell genug.
- Statischer Export lohnt erst bei hohem Traffic pro Site — dann pro Site opt-in, nicht default.
**Subdomain-Handling (Phase 3):** SvelteKit-Host-Handler im Hook `hooks.server.ts` erkennt `{slug}.mana.how` und rewritet intern auf `/s/{slug}/…`. Wildcard-Cert existiert bereits.
### D7 — AI-Tools via `mana-tool-registry`, nicht separat
Sobald `mana-tool-registry` (siehe `docs/plans/mana-mcp-and-personas.md` M1) steht, registriert `website` seine Tools dort: `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`. Policy-Hint pro Tool: `write` für CRUD, `destructive` für `delete_page`/`delete_site` (nicht MCP-exponiert).
**Warum:** Alle AI-Writes laufen zwingend durch denselben Tool-Layer. Kein paralleles "AI-kann-Websites-bauen"-Subsystem.
**Reihenfolge:** `mana-tool-registry` M1 muss stehen, bevor Website-AI-Tools registriert werden. Bis dahin: Editor ohne AI. Website-AI-Tools landen als Teil von M5.
### D8 — Form-Submissions schreiben über Tool-Registry-Handler, nicht direkt in Ziel-Tabellen
Ein `form`-Block hat `targetModule` (z.B. `'contacts'`) und `targetAction` (z.B. `'create_contact'`). Der Submit-Endpoint:
1. Validiert Payload gegen das im Block gespeicherte Zod-Schema
2. Speichert Audit-Row in `websiteSubmissions` (Status: `'received'`)
3. Ruft den entsprechenden Tool-Handler aus `mana-tool-registry` auf (`ctx`: site-owner user/space)
4. Updated Audit-Row mit `targetRecordId` und `'delivered'`
**Warum:** Der Tool-Registry-Handler kennt bereits Encryption, RLS, Validation des Zielmoduls. Duplizieren wäre erzwungener Legacy-Einstiegspunkt.
**Abgrenzung:** Unauthentifizierter Submit-Endpoint → Rate-Limiting via Edge (Cloudflare) und Captcha-Block-Typ (in M6, nicht M1).
### D9 — Caching: Published-Snapshot mit Cache-Tag, Invalidation bei Publish
Published-Blob wird mit `Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400` geliefert, plus `Cache-Tag: site-{siteId}`. Bei `website.publish` → Cloudflare Purge der Tag-Gruppe.
**Warum:** Keine statische Build-Stufe nötig; Edge-Cache bei Cloudflare liefert millisekunden-Responses für populäre Sites. Bei Edits ist die neue Version nach Publish binnen weniger Sekunden live.
**Alternative verworfen:** Redis-Cache in der App. Doppelter Infrastruktur-Aufwand, CF macht es gratis.
### D10 — Multi-Tenant über Spaces, Editing-Permission über Membership
Ein `website` gehört zu einer `spaceId`. Jedes Mitglied des Spaces kann editieren + publishen. Rollen (editor-only, viewer-only) kommen später, wenn `space_members.role` nicht-trivial wird.
**Warum:** Wir verwenden, was es gibt. Spaces-RLS ist getestet. Ein eigenes `website_members` wäre parallele Permission-Ebene → Drift garantiert.
**Privat vs. Firma-Distinction:** Kein eigenes "ist eine Firma"-Flag. Ein Space mit einem Member = Privat, ein Space mit 2+ Membern = Firma. Die UI kann in Phase 2 auf `spaceMemberCount > 1` reagieren, um Team-Workflows zu zeigen.
### D11 — Slugs: space-scoped unique, reserved-Liste hart
`websites.slug` ist unique pro `spaceId`. Öffentliche URL in Phase 1 ist `/s/{siteSlug}` global unique (nicht space-scoped) — und deswegen gibt es *auch* eine globale Unique-Constraint auf `slug` wenn `isPublished=true`.
**Reserved slugs:** `app`, `api`, `auth`, `admin`, `settings`, `docs`, `blog`, `www`, `mail`, `dashboard`, plus alle existierenden Modulnamen. Liste in `apps/api/src/modules/website/reserved-slugs.ts`, erzwungen bei Write und in Migration gecheckt.
**Warum:** Eine Site mit `slug=api` würde die API-Route verschatten. Lieber strikt + reserviert Namen.
### D12 — Media-Assets über bestehendes `shared-uload`, kein eigenes `websiteAssets`
Bilder, Dateien, Cover: Upload über `shared-uload` → MinIO, Rückgabe der URL. Der `image`-Block speichert `{ url, altText, focalPoint }`. Cleanup: wir führen keine Reference-Counting-Tabelle in Phase 1. Bei `delete site` bleiben Assets liegen (ok, sie sind im Space-Bucket, Storage ist billig). GC-Job in M7.
**Warum:** Ein eigenes `websiteAssets` mit Reference-Counting wäre sauberer, aber ein GC-Job reicht als Aufräumer und verzögert erst mal Komplexität.
### D13 — Kein Legacy-Fork der `shared-landing-ui` Astro-Sections
Die 13 existierenden Astro-Sections (`HeroSection`, `FeatureSection`, …) werden **nicht** nach Svelte portiert oder geteilt. Wir schreiben die Block-Renderer neu. Die visuellen Patterns darf man inspirieren, aber Code teilen = duales Rendering (D1 verletzt).
**Warum:** Die Astro-Sections haben andere Constraints (Astro-Islands, build-time-data). Teilen würde beide Seiten einschränken.
**Konsequenz:** `shared-landing-ui` bleibt für Org-Landing-Pages. In M6 diskutieren wir die Konsolidierung ehrlich.
## Komponenten
### Komponente 1 — `packages/website-blocks`
Neues Workspace-Paket. Reine Svelte-Components + Zod-Schemata, keine Dexie/Netzwerk-Abhängigkeiten. Nutzbar vom Editor (Dexie-Kontext) **und** vom Public-Renderer (Postgres-Snapshot-Kontext) — beide Seiten geben `{ block, mode, children }`, der Renderer kümmert sich nicht um Datenquelle.
**Public API (Skizze):**
```ts
// packages/website-blocks/src/registry.ts
export interface BlockSpec<Props = unknown> {
type: string; // 'hero', 'richText', …
schema: ZodSchema<Props>;
schemaVersion: number;
Component: SvelteComponent<{
block: Block<Props>;
mode: 'edit' | 'preview' | 'public';
children?: Block[]; // nur bei Containern
onEdit?: (patch: Partial<Props>) => void; // im edit-Mode
}>;
icon: string; // Lucide-Name
category: 'content' | 'media' | 'layout' | 'form' | 'embed';
defaults: Props; // Initialwerte beim Einfügen
upgraders?: Record<number, (old: unknown) => Props>; // v1→v2 migrations
}
export const blockRegistry: Record<string, BlockSpec>;
```
**Block-Coverage M1:** `hero`, `richText`, `image`, `spacer`, `cta`, `columns` (2/3-spalt), `gallery`. Sieben Typen reichen für brauchbare One-Pager.
**Block-Coverage M4 expand:** `form`, `moduleEmbed`, `pricing`, `faq`, `testimonials`, `team`, `contact`, `footer`. Fünfzehn Typen decken alle 13 `shared-landing-ui`-Sections plus neuen Bedarf.
### Komponente 2 — `apps/mana/apps/web/src/lib/modules/website`
Standard-Modul-Struktur, wie jedes andere Modul im Repo:
```
apps/mana/apps/web/src/lib/modules/website/
├── types.ts # LocalWebsite, LocalWebsitePage, LocalWebsiteBlock
├── collections.ts # websitesTable, websitePagesTable, websiteBlocksTable
├── queries.ts # useSite(id), usePage(id), useBlocks(pageId), useBlockTree(pageId)
├── stores/
│ ├── sites.svelte.ts # createSite, updateSite, deleteSite, publishSite
│ ├── pages.svelte.ts # createPage, updatePage, deletePage, reorderPages
│ └── blocks.svelte.ts # addBlock, updateBlock, deleteBlock, moveBlock
├── components/
│ ├── BlockRenderer.svelte # rekursiv, nutzt blockRegistry
│ ├── BlockTreeEditor.svelte # Seitenleiste: Baum + Insert-Palette
│ ├── BlockInspector.svelte # rechts: Zod-schema → Formular
│ ├── InsertPalette.svelte # "+" zwischen Blöcken
│ ├── PagePicker.svelte
│ ├── SiteSettingsDialog.svelte # Theme, Nav, Footer, SEO-Defaults
│ ├── PublishBar.svelte # "Unveröffentlichte Änderungen" + Publish-Button
│ └── TemplatePicker.svelte # Starter-Templates
├── views/
│ ├── SitesListView.svelte # alle Sites des Spaces
│ ├── SiteEditorView.svelte # drei-Pane Editor
│ └── SiteSettingsView.svelte
├── tools.ts # AI-Tool-Registrierungen (aktiviert erst in M5)
├── constants.ts # THEME_PRESETS, RESERVED_SLUGS (client-copy)
├── module.config.ts # { appId: 'website', tables: [...] }
└── index.ts
```
**Routes:**
```
apps/mana/apps/web/src/routes/(app)/website/
├── +page.svelte # SitesListView
├── new/+page.svelte # Template-Picker oder Blank
└── [siteId]/
├── +layout.svelte # lädt site, stellt Context
├── +page.svelte # redirect auf /edit
├── edit/
│ └── [pageId]/+page.svelte # SiteEditorView
├── settings/+page.svelte # SiteSettingsView
└── submissions/+page.svelte # Eingegangene Form-Submissions
```
### Komponente 3 — Public-Renderer-Routes
```
apps/mana/apps/web/src/routes/s/
└── [siteSlug]/
├── +layout.server.ts # resolve site, throw 404 if unpublished
├── +layout.svelte # theme vars, nav, footer
└── [[...path]]/
├── +page.server.ts # resolve page by path, 404 if missing
└── +page.svelte # <BlockRenderer mode="public" />
```
**Resolver-Logik (+layout.server.ts):**
```ts
export const load = async ({ params, setHeaders }) => {
const snapshot = await db
.select()
.from(publishedSnapshotsTable)
.where(and(
eq(publishedSnapshotsTable.slug, params.siteSlug),
eq(publishedSnapshotsTable.isCurrent, true)
))
.limit(1);
if (!snapshot[0]) error(404);
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
'cache-tag': `site-${snapshot[0].siteId}`,
});
return { site: snapshot[0].blob };
};
```
Snapshot-Blob-Format:
```ts
interface PublishedSnapshot {
version: string;
site: { id, slug, name, theme, navConfig, footerConfig, settings };
pages: Array<{
id, path, title, seo,
blocks: BlockTreeNode[]; // rekursiver Baum, bereits auflösend
}>;
publishedAt: string;
publishedBy: string;
}
```
### Komponente 4 — `apps/api/src/modules/website`
Backend-Routes im unified `@mana/api`:
```
apps/api/src/modules/website/
├── routes.ts # Hono router
├── publish.ts # POST /sites/:id/publish
├── submit.ts # POST /sites/:id/submit/:blockId (unauth)
├── snapshots.ts # query helpers for published snapshots
├── reserved-slugs.ts # SSOT
└── tools.ts # Tool-Registry registrations (M5)
```
**Endpoints:**
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/v1/website/sites/:id/publish` | JWT (space-member) | Snapshot erzeugen, `publishedVersion` setzen, CF-Cache purgen |
| `POST` | `/api/v1/website/sites/:id/submit/:blockId` | None | Form-Submission annehmen, validieren, weitergeben |
| `GET` | `/api/v1/website/sites/:id/submissions` | JWT (space-member) | Submissions listen |
| `DELETE` | `/api/v1/website/sites/:id/submissions/:subId` | JWT (space-member) | Submission löschen |
Keine CRUD-Endpoints für Pages/Blocks — das läuft über den normalen Sync-Pfad (Dexie → mana-sync → Postgres) wie bei allen anderen Modulen.
### Komponente 5 — Starter-Templates
Sechs handkuratierte Templates in `apps/mana/apps/web/src/lib/modules/website/templates/`:
| Template | Zielgruppe | Seiten | Blöcke |
|----------|-----------|--------|--------|
| `portfolio` | Kreative, Freelancer | Start, Über mich, Arbeiten, Kontakt | hero + gallery + richText + form |
| `personal-linktree` | Privatnutzer, Creator | Start (Single-Page) | hero + 8× cta |
| `event` | Hochzeit, Geburtstag, Konferenz | Start, Programm, Anreise, RSVP | hero + richText + form |
| `smb-corporate` | Kleinbetrieb | Start, Leistungen, Team, Kontakt | hero + 3×columns + team + contact |
| `product-landing` | Firmen-Produktseite | Start (Single-Page, lang) | hero + features + testimonials + pricing + faq + cta |
| `blank` | Fortgeschritten | 1 leere Seite | — |
Templates sind JSON in `templates/{name}.json`: `{ site, pages[], blocks[] }`. Apply-Funktion klont mit neuen UUIDs in den Ziel-Space. Templates sind statisch im Build, nicht in DB — kein Admin-Flow zum Editieren im MVP (M6 evtl.).
### Komponente 6 — Inspector-Autoform
`components/BlockInspector.svelte` rendert Formulare aus Zod-Schemas via kleiner Utility `zodToForm(schema)` in `packages/website-blocks/src/inspector/`. Mapping:
| Zod | UI |
|-----|----|
| `z.string()` | `<input type="text">` |
| `z.string().long()` (custom brand) | `<textarea>` |
| `z.string().url()` | `<input type="url">` |
| `z.enum([...])` | `<select>` |
| `z.boolean()` | `<input type="checkbox">` |
| `z.number()` | `<input type="number">` |
| `z.object({ ... })` | Gruppen-Fieldset (rekursiv) |
| `z.array(z.object({...}))` | Liste mit Add/Remove + Drag-Reorder |
| Block-spezifisches Custom | Override via `Block.inspector.ts` |
**Warum Auto-Formular, nicht pro-Block-Handschrift:** 15 Blöcke × 8 Felder = 120 Formfelder handgeschrieben wäre Copy-Paste. Auto-Gen deckt 80%, Override-Mechanismus für Spezialfälle (Farbwähler, Icon-Picker, Module-Source-Picker).
### Komponente 7 — `moduleEmbed`-Block
Spezialblock, der Daten aus anderen Modulen zieht. Props:
```ts
{
source: 'picture.board' | 'library.entries' | 'news.feed' | 'cards.deck' | 'library.kind';
sourceId: string; // board-id, deck-id, feed-id
filter?: { kind?, tag?, limit?, order? };
layout: 'grid' | 'list' | 'carousel';
}
```
**Source-Provider-Pattern:** Jedes Modul, das einbettbare Daten liefert, registriert einen Provider:
```ts
// apps/api/src/modules/website/embed-providers.ts
export interface EmbedProvider {
source: string; // 'picture.board'
resolve: (sourceId: string, filter: unknown, ctx: EmbedContext)
=> Promise<EmbedResult>;
}
```
Public-Renderer ruft bei Publish die Provider und inlined das Ergebnis in den Snapshot (statischer Ansatz) **oder** ruft sie pro Request (dynamischer Ansatz). **Default: statisch beim Publish**, weil einfacher zu cachen. Opt-in dynamisch pro Block (Use-Case: "Letzte 5 Artikel", tagesaktuell). Dynamisch = Cache-Tag zusätzlich `embed-{source}-{sourceId}`, Purge bei Source-Änderung.
**Permission-Check im Provider:** Provider bekommt `EmbedContext { ownerUserId, siteId, isPublic: true }`. Source-Daten werden nur inlined, wenn das Original als "public" markiert ist (pro Modul unterschiedlich: picture `board.isPublic`, library `entry.visibility='public'`, …). Ohne Public-Flag → Provider returnt leer oder wirft "needs_public".
### Komponente 8 — Policies: Tier-Gating
`packages/shared-branding/src/mana-apps.ts` bekommt einen neuen Entry:
```ts
website: {
id: 'website',
name: 'Website',
description: 'Baukasten für deine Website',
requiredTier: 'public', // alle angemeldeten User
url: '/website',
// …
}
```
**Limits pro Tier (konfiguriert in `website.limits.ts`):**
| Tier | Max Sites | Max Pages/Site | Max Blocks/Page | Custom Domain |
|------|-----------|----------------|-----------------|---------------|
| `public` | 1 | 1 | 20 | ❌ |
| `beta` | 3 | 5 | 50 | ❌ |
| `alpha` | 10 | unlimited | unlimited | ❌ |
| `founder` | unlimited | unlimited | unlimited | ✅ |
Enforcement in den Stores (pre-create check via JWT-Claim). Feature-Flag-Banner im Editor, der höheres Tier bewirbt.
## Datenmodell
### Dexie-Tabellen (IndexedDB, client-side)
```ts
// database.ts — neue Version block
db.version(NEW).stores({
// …existing…
websites: 'id, spaceId, slug, publishedVersion, updatedAt, deletedAt',
websitePages: 'id, siteId, path, order, updatedAt, deletedAt',
websiteBlocks: 'id, pageId, parentBlockId, order, type, updatedAt, deletedAt',
websiteSubmissions: 'id, siteId, blockId, createdAt, status',
});
```
### Postgres-Schemas (mana_platform, neu)
```sql
-- Schema: website.* (isoliert via pgSchema)
CREATE TABLE website.sites (
id UUID PRIMARY KEY,
space_id UUID NOT NULL REFERENCES space.spaces(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
theme JSONB NOT NULL, -- { preset, overrides }
nav_config JSONB NOT NULL, -- { items: [{label,pagePath}] }
footer_config JSONB NOT NULL,
settings JSONB NOT NULL, -- { favicon, defaultSeo, analytics? }
published_version UUID, -- FK → website.published_snapshots(id)
draft_updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (space_id, slug)
);
CREATE UNIQUE INDEX sites_published_slug_idx
ON website.sites (slug)
WHERE published_version IS NOT NULL AND deleted_at IS NULL;
CREATE TABLE website.pages (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- '/' for home, '/about' for subpage
title TEXT NOT NULL,
seo JSONB NOT NULL, -- { title, description, ogImage, noindex }
"order" DOUBLE PRECISION NOT NULL, -- fractional index
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (site_id, path)
);
CREATE TABLE website.blocks (
id UUID PRIMARY KEY,
page_id UUID NOT NULL REFERENCES website.pages(id) ON DELETE CASCADE,
parent_block_id UUID REFERENCES website.blocks(id) ON DELETE CASCADE,
slot_key TEXT, -- for container-blocks
type TEXT NOT NULL, -- registry type id
props JSONB NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
"order" DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX blocks_page_parent_idx ON website.blocks (page_id, parent_block_id, "order");
CREATE TABLE website.published_snapshots (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
slug TEXT NOT NULL, -- duplicated for fast public lookup
blob JSONB NOT NULL, -- full snapshot
is_current BOOLEAN NOT NULL DEFAULT FALSE,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_by UUID NOT NULL REFERENCES platform.users(id)
);
CREATE UNIQUE INDEX snapshots_current_slug_idx
ON website.published_snapshots (slug)
WHERE is_current = TRUE;
CREATE TABLE website.submissions (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
block_id UUID NOT NULL, -- no FK: block could be deleted, submission stays for audit
payload JSONB NOT NULL, -- cleared after target-module write
target_module TEXT NOT NULL,
target_action TEXT NOT NULL,
target_record_id UUID,
status TEXT NOT NULL, -- 'received' | 'delivered' | 'failed'
error_message TEXT,
ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX submissions_site_created_idx ON website.submissions (site_id, created_at DESC);
```
### RLS
```sql
-- sites, pages, blocks: nur Space-Member lesen/schreiben
ALTER TABLE website.sites ENABLE ROW LEVEL SECURITY;
CREATE POLICY sites_space_member ON website.sites
USING (space_id IN (SELECT space_id FROM space.memberships WHERE user_id = current_user_id()));
-- Analog für pages/blocks (JOIN über site)
-- published_snapshots: öffentlicher Read (kein Auth), Write nur durch publish-Endpoint (service-role)
ALTER TABLE website.published_snapshots ENABLE ROW LEVEL SECURITY;
CREATE POLICY snapshots_public_read ON website.published_snapshots
FOR SELECT USING (is_current = TRUE);
CREATE POLICY snapshots_service_write ON website.published_snapshots
FOR ALL TO service_role USING (TRUE);
-- submissions: nur Space-Member lesen; Write via service-role aus Submit-Endpoint
ALTER TABLE website.submissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY submissions_space_member_read ON website.submissions
FOR SELECT USING (site_id IN (
SELECT id FROM website.sites WHERE space_id IN
(SELECT space_id FROM space.memberships WHERE user_id = current_user_id())
));
```
### Encryption-Registry
```ts
// apps/mana/apps/web/src/lib/data/crypto/registry.ts
websites: plaintext(['id', 'spaceId', 'slug', 'name', 'theme', 'navConfig',
'footerConfig', 'settings', 'publishedVersion',
'draftUpdatedAt', 'createdAt', 'updatedAt', 'deletedAt',
'userId']),
websitePages: plaintext(['id', 'siteId', 'path', 'title', 'seo', 'order',
'createdAt', 'updatedAt', 'deletedAt', 'userId']),
websiteBlocks: plaintext(['id', 'pageId', 'parentBlockId', 'slotKey', 'type',
'props', 'schemaVersion', 'order', 'createdAt',
'updatedAt', 'deletedAt', 'userId']),
websiteSubmissions: plaintext(['id', 'siteId', 'blockId', 'payload',
'targetModule', 'targetAction', 'targetRecordId',
'status', 'errorMessage', 'ip', 'userAgent',
'createdAt', 'userId']),
```
**Explizit plaintext** (nicht implizit leer), damit `pnpm run check:crypto` (die CI-Gate aus dem spaces-foundation-Sprint) den Ausschluss aktiv bestätigt.
## No-Legacy-Residues
Explizite Anti-Patterns, gegen die wir uns committen:
1. **Kein duales Rendering.** Es gibt **eine** Svelte-Komponente pro Block-Typ. Kein paralleles Astro oder React, keine "nur für Public"-Version. Wenn jemals statischer Build gewünscht ist, wird SvelteKit im prerender-Mode genutzt, nicht ein separates Build-Tool.
2. **Kein JSON-Blob-im-JSON-Blob.** Der Block-Baum ist als Zeilen in `website.blocks` gespeichert, nicht als `site.tree = {...massive nested JSON...}`. Das Published-Snapshot-Blob ist die einzige Denormalisierung und existiert nur lesbar.
3. **Keine eigene Tool-Registry für Website-AI.** Sobald `mana-tool-registry` steht, läuft alles darüber. Kein `packages/website-ai-tools` parallel zu `packages/mana-tool-registry`.
4. **Keine Direct-Writes in Zielmodule bei Form-Submissions.** Submit-Endpoint ruft Tool-Handler, niemals direkt `contactsTable.insert()`. Ein Code-Pfad.
5. **Kein shared-landing-ui-Fork.** Auch nicht "nur die Hero-Section kopieren, weil sie schon gut ist". Wir schreiben neu oder abstrahieren später bei M6-Konsolidierung.
6. **Keine Legacy-slug-Konflikte.** `reserved-slugs.ts` ist SSOT und wird in Migration-Script validiert: bei Migration werden alle existierenden `slug` gegen die Liste geprüft; fehlschlag → harter Exit. Wir haben noch nicht live, also null Konflikt erwartet.
7. **Kein "website_user_role" als Parallel-Permission-System.** Space-Membership ist die einzige Permission. Wenn feinere Rollen kommen, dann auf `space.memberships` — nicht als Duplikat in website.
8. **Kein impliziter "publish on save".** Draft und Published sind ausdrücklich getrennt. Publish ist ein expliziter User-Trigger. Kein Auto-Publish, keine "automatisch nach 5 Minuten publish"-Heuristik.
9. **Kein lokaler LiveQuery im Public-Renderer.** Der Public-Renderer nutzt ausschließlich Postgres. Dexie ist für den Editor, Ende.
10. **Keine `window.ANALYTICS.track(...)`-Einbau in Block-Renderer.** Analytics-Einbau (später M7) läuft über einen dedizierten `analytics`-Block oder via `settings.analytics``<script>`-Injection im Layout. Kein Pepper streuen.
11. **Keine Inline-CSS-Properties als Block-Props.** Kein `block.props.style = 'margin-top: 42px'`. Styling nur über Theme-Variables und ausgewählte Design-Tokens (`spacing: 'tight'|'normal'|'loose'`). Wenn Nutzer freies CSS braucht, ist das ein Pro-Feature in M7+.
12. **Keine Parallel-Slug-Resolution.** Eine einzige Funktion in `apps/api/src/modules/website/snapshots.ts` resolved `siteSlug → snapshot`. Nicht in SvelteKit-Hook plus Server-Route plus Cache-Layer. Ein Ort.
## Milestones
Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich, typechecked, `pnpm run validate:all` grün.
### M1 — Foundation (Schema + Editor-Skelett + 3 Blöcke)
- [ ] `packages/website-blocks/` scaffold: `registry.ts`, `BlockSpec`-Typ, Inspector-Autoform-Utility
- [ ] Drei Block-Typen: `hero`, `richText`, `spacer` — Schema + Component + Tests
- [ ] Dexie-Schema-Bump: `websites`, `websitePages`, `websiteBlocks`
- [ ] Drizzle-Schema: `website.sites`, `website.pages`, `website.blocks` (ohne snapshots, submissions)
- [ ] Encryption-Registry-Einträge (plaintext) + check:crypto grün
- [ ] `apps/mana/apps/web/src/lib/modules/website/` — module.config.ts, collections, queries, stores
- [ ] Route `/(app)/website`: Sites-Liste, Create-Site-Flow, Single-Page-Editor
- [ ] Editor-UI (drei-Pane): Seiten-Liste links, Preview Mitte, Inspector rechts
- [ ] Speichern zur Dexie → mana-sync → Postgres (kein Publish, kein Public-Route)
- [ ] `apps/api/src/modules/website/routes.ts` — Health-Endpoint + Validation-Stub
- [ ] App-Registry-Eintrag in `packages/shared-branding/src/mana-apps.ts`, Tier: `public`, Limits: 1 Site, 1 Page, 20 Blöcke
**Exit criteria:** Ein Nutzer legt eine Site an, fügt Hero + RichText + Spacer ein, Änderungen synchronisieren zu Postgres, beim Reload ist alles wieder da. Kein Public-Rendering.
### M2 — Publish + Public-Renderer + Mehrseitigkeit
- [ ] Drizzle-Schema: `website.published_snapshots`
- [ ] `apps/api/src/modules/website/publish.ts` — Publish-Endpoint:
- [ ] Baue Snapshot-Blob aus current draft
- [ ] Insert in `published_snapshots`, setze `is_current=TRUE` (old: FALSE), setze `sites.published_version`
- [ ] Cloudflare-Cache-Purge via API (Tag: `site-{id}`)
- [ ] `apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/` — Public-Render-Routen
- [ ] Cache-Header + Cache-Tag setzen
- [ ] Mehrseitigkeit: Page-Management im Editor (Add/Rename/Delete/Reorder), Nav-Config
- [ ] Limits-Enforcement in Stores (Pre-Check gegen Tier)
- [ ] `PublishBar.svelte` mit "Unveröffentlichte Änderungen"-Indikator
- [ ] Rollback-UI: letzte 10 Snapshots listen, ein Klick "auf diese Version zurück"
- [ ] Tests: Snapshot-Deterministik (zweimal publish → identisches Blob bei unverändertem Draft)
**Exit criteria:** Nutzer publisht. `https://<dev>/s/<slug>/` serviert die Site öffentlich, nicht authentifiziert. Rollback funktioniert.
### M3 — Block-Coverage-Expand (Layout + Media)
- [ ] Blöcke ergänzen: `image`, `gallery`, `cta`, `columns` (container), `faq`
- [ ] `columns`-Container: rendert Slots, Drop-Zone pro Slot im Edit-Mode
- [ ] Image-Upload via shared-uload-Integration im Inspector
- [ ] Gallery-Masonry mit Lightbox im Public-Mode
- [ ] Theme-System: `packages/website-blocks/src/themes/` — 3 Presets (classic, modern, warm)
- [ ] Theme-Preview + Farb-Customization im SiteSettingsDialog
- [ ] Tests pro Block: Schema-Validation, Component-Snapshot in allen drei Modes
**Exit criteria:** Ein Nutzer baut aus den jetzt 8 Block-Typen eine echte Landingpage, wählt ein Theme, publisht.
### M4 — Forms + moduleEmbed
- [ ] Drizzle-Schema: `website.submissions`
- [ ] `form`-Block: Field-Editor im Inspector (Name/Label/Type/Required), Target-Module-Picker
- [ ] Submit-Endpoint `POST /api/v1/website/sites/:id/submit/:blockId`:
- [ ] Rate-Limit via Hono-Middleware (10 req/min per IP)
- [ ] Schema-Validation gegen gespeicherten Block
- [ ] Call zu mana-tool-registry-Handler (Start-Implementation noch vor M5)
- [ ] Submission-Record + optionale E-Mail-Notification via mana-notify
- [ ] `moduleEmbed`-Block:
- [ ] Source-Provider-Interface
- [ ] Provider für `picture.board`, `library.entries`, `news.feed`
- [ ] Im Publish-Snapshot werden Provider aufgerufen und Ergebnis inlined (statischer Ansatz)
- [ ] Permission-Check: nur `isPublic` Content wird inlined
- [ ] Submissions-Liste im Editor (`/website/[id]/submissions`)
- [ ] Weitere Blöcke: `pricing`, `testimonials`, `team`, `contact` (konzeptuell: `form` + layout)
**Exit criteria:** Kontaktformular auf einer Site schreibt neuen `contacts`-Record beim Site-Owner. Picture-Board wird als Gallery auf der öffentlichen Seite angezeigt.
### M5 — AI-Tools + Templates
**Voraussetzung:** `mana-tool-registry` M1 aus mana-mcp-Plan hat gelandet.
- [ ] Website-Tools registrieren: `website.create_site`, `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`
- [ ] Policy-Hints: `create_site` propose, `add_block`/`update_block` propose, `publish` propose, `delete_*` destructive (nicht MCP-exponiert)
- [ ] Starter-Templates (6 Stück, siehe Komponente 5) als JSON in `templates/`
- [ ] TemplatePicker im `/website/new`-Flow
- [ ] AI-Proposal-Inbox für Website-Änderungen (`<AiProposalInbox module="website" />`)
- [ ] Integrations-Test: AI-Mission "Baue mir eine Portfolio-Seite aus meinen 3 besten Boards" produziert Proposals, User approves, Site landet in Editor
**Exit criteria:** Template-Flow + AI-Mission-Flow produziert brauchbare Sites.
### M6 — Subdomain-Publishing + Custom-Domain-Foundation
- [x] SvelteKit-Hook `hooks.server.ts`: Host-Header → rewrite `{slug}.mana.how``/s/{slug}/…`
- [ ] Wildcard-DNS + TLS-Check im Staging — ops-Aufgabe (Cloudflare-Config, kein Code)
- [x] Custom-Domain-Schema: `website.custom_domains { site_id, hostname, status, verification_token, verified_at }``tls_status` verzichten (kommt von Cloudflare-API in M6.x)
- [x] DNS-Verify-Flow: CNAME-Record auf `custom.mana.how`, TXT-Record mit Challenge (node:dns/promises)
- [ ] Cloudflare-SaaS-Hostname-Integration — API-Call ist **gestubbed**; produziert Log-Eintrag, kein realer Call. Siehe "Offene Enden unten".
- [x] Tier-Gate: Custom-Domain nur für `founder` (via `requireTier('founder')` in domains.ts)
- [x] `mana-landing-builder` Konsolidierungs-Entscheidung: **Parallel halten** (siehe unten)
**`mana-landing-builder` — Entscheidung: parallel halten**
Der Service bleibt vorerst nebenher. Gründe:
1. **Ziel-Unterschied.** `mana-landing-builder` ist ein Admin-only Tool für Org-Landing-Pages mit Cloudflare-Pages-Deploys. Der website-Builder ist ein End-User-Tool mit SSR-Rendering. Zwei klar unterschiedliche Surfaces.
2. **Rollen-Unterschied.** Org-Landings sind Admin-Branding. User-Sites sind private Portfolios, Events, Linktrees. Zwei unterschiedliche Daten-/Permission-Modelle.
3. **Lifecycle.** Der Builder ist jung + iteriert schnell; Org-Pages ändern sich selten. Sie zu fusionieren würde beide ausbremsen.
4. **Migration wäre teuer.** Org-Landings nutzen Astro-Sections mit Themes (`org-classic`, `org-warm`) die nicht 1:1 zu unseren Block-Typen passen. Migration = port jeder Section, migrate jede existierende Org.
**Revisit-Kriterium:** Wenn wir (a) Org-Landings selbst mit Block-Editor-Features wollen (Multi-Page, Forms) ODER (b) die Feature-Überlappung groß genug ist, dass doppelte Pflege schmerzt. Frühestens nach 6 Monaten Live-Daten.
**Exit criteria:** `{slug}.mana.how` funktioniert (serverseitiger Rewrite steht). Founder-User kann eigene Domain verbinden (Add + DNS-Check + Verify-Flow steht; TLS-Provisioning via CF SaaS ist die Ops-Lücke).
**Offene Enden in M6 (post-first-pass):**
- Live Cloudflare-SaaS-Hostname-API-Integration (`POST /zones/{zoneId}/custom_hostnames`) — bisher nur Log-Stub. Ops-Aufgabe + Code-Retrofit sobald `CF_API_TOKEN` + `CF_ZONE_ID` in prod-env liegen.
- DNS-Verify-Poller (Background-Check, repariert `failed``verified` ohne User-Klick). Dependency auf ein Job-Queue-Primitive oder Cron, landet zusammen mit M7 Observability.
- Apex-Domain-Handling: der Verify-Code akzeptiert A-Record-Fallback wenn CNAME-Lookup ENODATA liefert; bei komplexen Multi-IP-Setups kann das false-negative. Real life: für apex empfiehlt man ANAME/ALIAS, einige DNS-Provider unterstützen das nicht. Follow-up sobald der erste User daran hängt.
### M7 — Observability, GC, Analytics
- [x] Prometheus-Metrics: `mana_api_website_publish_total{result}`, `_publish_duration_seconds`, `_submissions_total{result}`, `_host_resolve_total{result}`, `_domain_verify_total{result}`, `_public_reads_total{result}`, `_public_read_age_seconds`. `/metrics`-Endpoint an apps/api root (unauth, verlässt sich auf Reverse-Proxy).
- [x] Orphan-Asset-GC: Script `apps/api/scripts/gc-website-assets.ts`**read-only** in M7 first-pass. Walks published_snapshots + submissions for mediaId refs, compares against mana-media's app=website listing, reports orphans older than 30d. Deletion-Toggle nach 2-3 Wochen stabiler Reports.
- [x] `analytics`-Block-Typ: Plausible + Umami mit self-hosted Script-URL-Override. Unsichtbar im Editor, emittiert genau einen `<script>` im public-Mode. Keine Cookies, keine PII.
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten) — verschoben auf M7.x. Der Analytics-Block deckt die Visitor-Seite; Editor-Inline-Dashboard ist Backlog bis jemand konkret danach fragt.
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen — M4.x Voraussetzung (Target-Delivery ist noch nicht gewired).
- [x] Dashboards in `docs/observability/website.md` — Metrics-Referenz, PromQL-Queries, Alert-Vorschläge, Grafana-Pfad.
- [x] (Bonus) Rollback-UI — Version-History-Dialog in PublishBar, M2-Exit-Kriterium „Rollback funktioniert" jetzt auch im Editor abgehakt.
- [x] (Bonus) Snapshot-Determinism-Test — vitest in `publish.test.ts` verifiziert byte-identische Outputs + Orphan-Drop.
**Exit criteria:** Betrieb ist beobachtbar (Metrics + PromQL), Storage-Wachstum ist überwacht (Scan-Script), Analytics-Opt-In steht.
## Risiken + Mitigation
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-----|--------|------------|
| Block-Schema-Migration bei Version-Bump ist schmerzhaft | Mittel | Mittel | `schemaVersion` + `upgraders`-Map pro Block, Migrations-Utility im Build zwingt forward-migration; Tests pro Upgrade |
| Published-Snapshot-Blob wird zu groß (Performance) | Mittel | Mittel | Hart-Limit auf Gesamtgröße (5MB), UI warnt bei 80%; `moduleEmbed` dynamisch als Fallback bei großen Galleries |
| Fractional Index läuft in Precision-Probleme bei häufigem Reorder | Niedrig | Mittel | Nach 1000 Reorders pro Page automatischer Rebalance-Job; Library wie `fractional-indexing` mit `jitter` |
| CF-Cache-Purge-API down → stale Inhalte | Niedrig | Niedrig | Bei Purge-Fehler: Fallback auf `max-age=60`, Nutzer sieht neue Version in ≤1min; Alert bei wiederholtem Fehler |
| Form-Spam auf Submit-Endpoint | Hoch | Mittel | Rate-Limit per IP, Honeypot-Feld pro Form, Captcha-Block-Typ in M7 |
| Reserved-Slug-Liste wird erweitert, alte Sites brechen | Niedrig (pre-launch) | Hoch (post-launch) | Pre-launch: wir haben null Konflikt. Post-launch: neue Reserved-Slugs nur mit Migration + Slug-Rewrite für Betroffene |
| moduleEmbed-Provider sharen versehentlich private Daten | Mittel | Hoch | Hart kodierter `isPublic`-Check pro Provider, Integrations-Test: Embed-Provider mit `isPublic=false` returnt null |
| Editor-Co-Editing zwei Member → Konflikte | Mittel | Niedrig | mana-sync field-level LWW löst es pro Feld; bei Baum-Konflikten (beide adden Block an gleicher Position) → fractional index bricht Tie |
| Svelte 5 Block-Components leaken Renderer-State zwischen `mode`-Wechsel | Niedrig | Niedrig | Unit-Tests pro Block mit allen drei Modes; `$effect` cleanup disziplinieren |
| AI generiert Block-Props, die Schema-Validation brechen | Mittel | Niedrig | Tool-Handler validiert vor Apply; fehlende Required-Fields → Proposal mit Warnung, User kann ergänzen |
## Offene Entscheidungen (später)
- **I18n pro Site:** Mehrsprachige Sites (DE/EN/IT)? Könnte über `pages.path` mit Locale-Prefix gemacht werden (`/de/about` vs. `/en/about`). Nicht in Scope M1M5; Entscheidung in M6-Zeitraum mit realer Nutzer-Nachfrage.
- **Custom-Code-Blöcke:** `<script>`-Injection oder `<iframe>`-Embed wäre mächtig, aber security-relevant (XSS). Wenn, dann nur für `founder` und sandboxed. Frühestens M7.
- **Versionierung über Publish hinaus:** Full Version-History (wie Git) pro Site? Derzeit nur "letzte 10 Snapshots". Wenn Kunden fragen, ausbauen.
- **A/B-Testing:** Zwei Varianten einer Page gegen ein Traffic-Split testen. Nice-to-have, aber erst wenn Analytics steht.
- **Staging-Deploy pro Site:** Eine Staging-URL (`/s/{slug}/preview?token=…`) zum Teilen vor Publish. Könnte Hand-in-Hand mit Collaboration (Member commentiert "das Hero sieht schief aus") kommen.
- **Export:** "Export als statische HTML/CSS/JS"-Download — sinnvoll als Lock-In-Gegenmittel, nicht trivial (alle Assets + Routen + Forms). Eher nein im MVP.
- **Konsolidierung mit `mana-landing-builder`:** Entscheidung in M6 wie oben. Solange parallel, beide als bewusst getrennte Systeme dokumentieren.
## Referenzen
- [`docs/plans/spaces-foundation.md`](spaces-foundation.md) — Space-Scoping, Membership-Model
- [`docs/plans/space-scoped-data-model.md`](space-scoped-data-model.md) — space-scoped RLS-Pattern
- [`docs/plans/mana-mcp-and-personas.md`](mana-mcp-and-personas.md) — `packages/mana-tool-registry` als SSOT für AI/MCP-Tools
- [`docs/plans/library-module.md`](library-module.md) — Module-Pattern-Beispiel für diskriminierte Typen
- [`apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`](../../apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md) — Sync-Engine-Deep-Dive
- [`.claude/guidelines/sveltekit-web.md`](../../.claude/guidelines/sveltekit-web.md) — Svelte 5 Runes Conventions
- [`.claude/guidelines/database.md`](../../.claude/guidelines/database.md) — pgSchema-Pattern, Drizzle
## Shipping Log
| Phase | Purpose | Commit |
| --- | --- | --- |
| M1 + M2 | Foundation (editor, 3 blocks) + publish + public renderer | folded into user's `54a12ffd5` + `89258eb45` |
| M3 | 5 more blocks, containers, upload, themes | `7a4f8894e` |
| M4 | Forms + moduleEmbed | `57be0f61b` |
| M5 | AI tools + starter templates | `13efae8cd` |
| M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | `3eca5ac20` |
| M7 | Observability (Prom metrics, /metrics endpoint) + analytics block + orphan-asset GC script + rollback UI + determinism test + docs | (pending commit at end of M7 session) |