Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:
isParallelSafe: (name) =>
AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'
Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.
Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
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-builderServices 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-blocksversioniert. 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
draftundpublishedals 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:
- Datenbank-Validierung (Store schreibt, Server validiert)
- Inspector-Formular (Auto-Generierung via
zod-to-form-Utility) - AI-Tool-Input (über
mana-tool-registry, siehe D7) - 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:
publishedVersionzeigt 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-builderzwingt 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:
- Validiert Payload gegen das im Block gespeicherte Zod-Schema
- Speichert Audit-Row in
websiteSubmissions(Status:'received') - Ruft den entsprechenden Tool-Handler aus
mana-tool-registryauf (ctx: site-owner user/space) - Updated Audit-Row mit
targetRecordIdund'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):
// 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):
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:
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:
{
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:
// 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:
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)
// 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)
-- 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
-- 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
// 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:
-
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.
-
Kein JSON-Blob-im-JSON-Blob. Der Block-Baum ist als Zeilen in
website.blocksgespeichert, nicht alssite.tree = {...massive nested JSON...}. Das Published-Snapshot-Blob ist die einzige Denormalisierung und existiert nur lesbar. -
Keine eigene Tool-Registry für Website-AI. Sobald
mana-tool-registrysteht, läuft alles darüber. Keinpackages/website-ai-toolsparallel zupackages/mana-tool-registry. -
Keine Direct-Writes in Zielmodule bei Form-Submissions. Submit-Endpoint ruft Tool-Handler, niemals direkt
contactsTable.insert(). Ein Code-Pfad. -
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.
-
Keine Legacy-slug-Konflikte.
reserved-slugs.tsist SSOT und wird in Migration-Script validiert: bei Migration werden alle existierendensluggegen die Liste geprüft; fehlschlag → harter Exit. Wir haben noch nicht live, also null Konflikt erwartet. -
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. -
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.
-
Kein lokaler LiveQuery im Public-Renderer. Der Public-Renderer nutzt ausschließlich Postgres. Dexie ist für den Editor, Ende.
-
Keine
window.ANALYTICS.track(...)-Einbau in Block-Renderer. Analytics-Einbau (später M7) läuft über einen dediziertenanalytics-Block oder viasettings.analytics→<script>-Injection im Layout. Kein Pepper streuen. -
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+. -
Keine Parallel-Slug-Resolution. Eine einzige Funktion in
apps/api/src/modules/website/snapshots.tsresolvedsiteSlug → 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, setzeis_current=TRUE(old: FALSE), setzesites.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.sveltemit "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
isPublicContent 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_sitepropose,add_block/update_blockpropose,publishpropose,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
- SvelteKit-Hook
hooks.server.ts: Host-Header → rewrite{slug}.mana.how→/s/{slug}/… - Wildcard-DNS + TLS-Check im Staging
- Custom-Domain-Schema:
website.custom_domains { site_id, hostname, status, tls_status, verified_at } - DNS-Verify-Flow: CNAME-Record auf
custom.mana.how, TXT-Record mit Challenge - Cloudflare-SaaS-Hostname-Integration (API-Call bei Verify-Success)
- Tier-Gate: Custom-Domain nur für
founder mana-landing-builderKonsolidierungs-Entscheidung:- Untersuchen: kann Org-Landing-Page als spezial
spaceKind='organization'-Site im neuen System leben? - Wenn ja: Org-Landing-Pages migrieren,
mana-landing-builder→ deprecation note, löschen nach Datenmigration - Wenn nein: Gründe dokumentieren, beide Systeme parallel halten
- Untersuchen: kann Org-Landing-Page als spezial
Exit criteria: {slug}.mana.how funktioniert. Founder-User kann eigene Domain verbinden.
M7 — Observability, GC, Analytics
- Prometheus-Metrics:
website_publish_total,website_submissions_total,website_render_duration_seconds,website_cache_hit_ratio - Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period
analytics-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In- Per-Site-Stats im Editor (Views/Tag, Top-Seiten)
- Submission-Retention:
payloadnach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit) - Dashboards in
docs/observability/website.md
Exit criteria: Betrieb ist beobachtbar, Storage wächst nicht unbegrenzt.
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.pathmit Locale-Prefix gemacht werden (/de/aboutvs./en/about). Nicht in Scope M1–M5; 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ürfounderund 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— Space-Scoping, Membership-Modeldocs/plans/space-scoped-data-model.md— space-scoped RLS-Patterndocs/plans/mana-mcp-and-personas.md—packages/mana-tool-registryals SSOT für AI/MCP-Toolsdocs/plans/library-module.md— Module-Pattern-Beispiel für diskriminierte Typenapps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md— Sync-Engine-Deep-Dive.claude/guidelines/sveltekit-web.md— Svelte 5 Runes Conventions.claude/guidelines/database.md— pgSchema-Pattern, Drizzle
Shipping Log
(Leer — wird befüllt, während M1 → M7 gehen.)
| Phase | Purpose | Commit |
|---|---|---|
| — | — | — |