feat(spaces): add space types + module allowlist as multi-tenancy foundation

Introduces SpaceType ('personal' | 'brand' | 'club' | 'family' | 'team' |
'practice') and SPACE_MODULE_ALLOWLIST as the shared-branding primitives
for the Spaces refactor that replaces the user-vs-org polymorphy with a
single tenancy primitive (Notion/Linear pattern).

Pure additive — no runtime behaviour change yet. Better Auth config,
Dexie migration, scope wrapper and rolling module migration follow in
separate commits.

Plan: docs/plans/spaces-foundation.md
Social-relay plan now defers brand storage to the Spaces primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 15:57:57 +02:00
parent 2dc298a796
commit b249345174
4 changed files with 852 additions and 0 deletions

View file

@ -0,0 +1,269 @@
# Social Relay — Module Plan
## Status (2026-04-20)
**Ideenphase, wartet auf Spaces-Foundation.** Noch kein Code. Nach Einführung der Spaces-Primitive (siehe [`spaces-foundation.md`](./spaces-foundation.md)) wird Edisconet ein Space vom Typ `brand` — der frühere `brands/`-Modul-Vorschlag entfällt. OAuth-Token, Voice-Doc und AI-Persona hängen am Space. Dieser Plan wird nach Phase-2 der Foundation überarbeitet und präzisiert; bis dahin nur konzeptuell gültig.
---
## Ziel
Ausgewählte LinkedIn-Posts von **Howspace** (Quelle) werden auf Deutsch übersetzt, für **Edisconet** (Ziel, Company Page ID 88888092) tonal angepasst und — **nach menschlichem Review** — auf der Edisconet-Company-Page veröffentlicht. Ursprung (Howspace) bleibt im Post sichtbar.
Kernfrage: *"Welcher HS-Post lohnt sich für Edisconet, wie klingt er auf Deutsch in unserem Ton, und wie kommt er mit korrekter Attribution raus — ohne dass ich jedes Mal 20 Minuten copy-paste-übersetze?"*
**Nicht im Scope (bewusst):**
- Automatisches Scraping von LinkedIn (ToS-Verstoß, Ban-Risiko für den Company-Account)
- Vollautomatisches Posten ohne Approval (Reputationsrisiko, KI-Halluzination bei Fachbegriffen)
- Multi-Source / Multi-Target (erst HS→Edisconet, später generalisieren)
- Bild-/Video-Assets aus HS-Posts übernehmen (Copyright) — wir erzeugen eigene Visuals oder postieren Text-only
## Abgrenzung zu bestehenden Modulen
| Modul | Unterschied |
|-------|-------------|
| `mana-research` | Research extrahiert Fakten aus vielen Quellen, Social Relay transformiert *einen* spezifischen Post für Republishing |
| `mana-ai` (Mission Runner) | Wird als Execution-Layer genutzt (Queue, Retry, Cron), nicht dupliziert |
| `mana-landing-builder` | Erzeugt Landing Pages, nicht Social-Posts |
| `news-research` | RSS-Feed-Discovery für Consumer, nicht B2B-Social-Republishing |
---
## Architektur-Entscheidung: Weg 3 (Eigenbau, manueller Intake)
Gegen Scraping, gegen Fire-and-forget. Begründung:
1. **ToS-konform**: Kein Crawling fremder LinkedIn-Seiten. User pastet Post-URL oder Text manuell.
2. **Review-Gate**: Draft landet in der Mana-UI, geht erst nach Approve live. Schützt vor KI-Fehlern und falscher Tonalität.
3. **Wiederverwendung**: `mana-llm` (Übersetzung+Adaption), `mana-ai` (Queue), `mana-auth` (OAuth-Token-Storage) existieren bereits.
4. **Erweiterbar**: Später zweite Quelle (X, Bluesky, Blog-RSS) oder zweite Ziel-Page ohne Umbau.
### Komponenten-Übersicht
```
┌─────────────────────┐ paste URL/text ┌──────────────────────┐
│ Mana Web Module │ ───────────────────▶ │ apps/api/social-relay│
│ (social-relay/) │ │ Hono routes │
└─────────────────────┘ └──────────┬────────────┘
▲ │
│ Draft-Review, Approve ▼
│ ┌──────────────────────┐
│ │ mana-llm │
│ │ (translate + adapt) │
│ └──────────┬────────────┘
│ │
│ ┌──────────▼────────────┐
│ │ services/ │
│ │ mana-social-relay │ ◀── cron: daily digest?
│ │ (publish queue) │
│ └──────────┬────────────┘
│ │
│ webhook: published ▼
│ ┌──────────────────────┐
└────────────────────────────────────│ LinkedIn UGC API │
│ (Edisconet page) │
└──────────────────────┘
```
---
## Intake: wie kommen HS-Posts rein?
Drei Wege, priorisiert:
1. **Post-URL paste** (primär): User kopiert `https://www.linkedin.com/posts/howspace_...` → Server holt OpenGraph-Meta (Titel/Beschreibung/Hero-Image URL, Post-Text oft im `og:description`) via `apps/api`-Fetch. Ausreichend für die meisten Text-Posts.
2. **Manueller Text-Paste** (Fallback): Wenn OG-Extraktion scheitert oder der Post Mehrwert im Carousel/Video hat, fügt der User den Text selbst ein. Screenshot optional als Referenz.
3. **Watchlist** (später, Phase 2): User trägt Howspace-URL ein, täglicher Cron fragt die OG-Daten der Company-Page an (oder einen HS-eigenen RSS-Feed/Newsletter, falls verfügbar) und zeigt neue Posts als **Vorschlagsliste** — kein Auto-Pull in die Queue.
Kein `puppeteer`/`playwright`-Scraping gegen linkedin.com in Phase 1.
---
## Processing: `mana-llm` Pipeline
Ein neuer Prompt-Template in `services/mana-llm/prompts/social-relay.ts`:
```ts
// Inputs: sourcePostText (EN), brandVoice ("edisconet"), sourceUrl
// Outputs: { germanPost, suggestedHashtags, attributionFooter, rationale }
```
Drei Teilschritte innerhalb *eines* LLM-Calls (günstiger als drei Calls):
1. **Übersetzung** EN → DE mit B2B-Ton
2. **Adaption**: Howspace-spezifische Referenzen (ihre Produktnamen, ihre Cases) werden generalisiert oder auf Edisconet-Kontext gemappt. Claim-Übernahmen (Statistiken, Kundennamen) werden als `[originalzitat]` markiert, nicht umformuliert.
3. **Attribution-Footer erzwingen**, Template:
> `Inspiriert von einem Post von @Howspace: [sourceUrl]`
Harter System-Prompt-Check + Post-Processing-Assertion: wenn der Footer fehlt, Fehler werfen, nicht stillschweigend posten.
Model: `claude-sonnet-4-6` (Qualität > Geschwindigkeit, 12 Posts/Tag). Temperatur niedrig (0.3) für Konsistenz. Prompt-Cache auf den Brand-Voice-Block, damit wiederholte Läufe günstig bleiben.
---
## Publishing: LinkedIn Community Management API
**Offizielle API, nicht inoffiziell.** Endpoints:
- `POST /rest/posts` — Text-Post als Organization
- OAuth 2.0, Scope `w_organization_social`
- Access-Token-Lifetime: 60 Tage, Refresh nötig
**Onboarding (einmalig):**
1. LinkedIn Developer App erstellen, Edisconet-Company-Page als Organization verifizieren
2. App-Review durchlaufen für `w_organization_social` (24 Wochen Bearbeitungszeit — **blockt Go-Live, früh starten**)
3. OAuth-Flow in `services/mana-auth` ergänzen: `auth/linkedin-org/callback` → speichert Token verschlüsselt in `mana_platform.linkedin_tokens` (neue pgSchema)
4. Refresh-Cron in `mana-social-relay` (alle 50 Tage)
**Kein Draft-Endpoint in der API** — LinkedIn bietet keine server-seitigen Drafts für Organizations. Unser Review-Gate läuft deshalb komplett in Mana: `status='approved'` → erst dann schlägt `publish` zu.
---
## Modul-Struktur (Frontend)
```
apps/mana/apps/web/src/lib/modules/social-relay/
├── types.ts # SourcePost, Draft, PublishedPost, Status
├── collections.ts # Dexie: socialRelayDrafts
├── queries.ts # useDrafts, useDraft(id), usePublished
├── stores/
│ └── drafts.svelte.ts # ingestUrl, regenerate, approve, reject, publish
├── api.ts # Client: /api/v1/social-relay/*
├── components/
│ ├── IngestForm.svelte # URL-Paste + Manual-Text-Fallback
│ ├── DraftCard.svelte # Listeneintrag (Quelle + DE-Preview + Status)
│ ├── DraftDetail.svelte # Seitenweise Review, Edit-in-place, Regenerate-Button
│ ├── PublishButton.svelte # approve + publish (mit Bestätigung)
│ └── HistoryView.svelte # veröffentlichte Posts + LinkedIn-Permalinks
└── routes/
├── +page.svelte # Liste aller Drafts
└── draft/[id]/+page.svelte # Detail + Review
```
## Backend-Routes (`apps/api`)
```
apps/api/src/modules/social-relay/routes.ts
├── POST /ingest # { sourceUrl | sourceText } → Draft (kein LLM-Call yet)
├── POST /drafts/:id/generate # triggert mana-llm → schreibt germanPost in Draft
├── PATCH /drafts/:id # User-Edits am germanPost
├── POST /drafts/:id/approve # status='approved'
├── POST /drafts/:id/publish # queue in mana-social-relay → LinkedIn
├── GET /drafts # Liste (paginated)
└── GET /published # Liste veröffentlichter Posts + Permalinks
```
Auth: nur `role='founder'` (Admin). Kein Public-Tier.
## Neuer Service `services/mana-social-relay`
Analog zu `mana-ai` Mission-Runner, aber klein:
- Port (neu, in `docs/PORT_SCHEMA.md` eintragen)
- Queue (Redis) für `publish`-Jobs
- LinkedIn-API-Client + Token-Refresh-Cron
- Webhook-Dispatcher zurück an `apps/api` bei Erfolg/Fehler
- Retry mit exponential backoff (LinkedIn API kann 429en)
Alternative: **kein eigener Service**, Publishing direkt in `apps/api` mit `mana-ai`-Mission als Queue. Erspart Container + Port. Entscheidung aufschieben bis M2 — wenn die Publish-Logik unter ~100 Zeilen bleibt, gewinnt Alternative.
---
## Data Model
**Dexie (`apps/mana/apps/web/src/lib/data/database.ts`) — v27:**
```ts
socialRelayDrafts: Dexie.Table<LocalSocialRelayDraft, string>
type LocalSocialRelayDraft = {
id: string
sourceUrl: string // encrypted
sourceText: string // encrypted (OG-extracted or manual)
sourceAuthor: string // "Howspace" — plaintext (brand name)
germanPost: string // encrypted (LLM output, user-editable)
suggestedHashtags: string[] // plaintext
attributionFooter: string // encrypted
status: 'draft' | 'generating' | 'ready' | 'approved' | 'publishing' | 'published' | 'failed' | 'rejected'
linkedInPostId: string | null // plaintext, nach publish
linkedInPermalink: string | null
failureReason: string | null // encrypted
createdAt: number
updatedAt: number
publishedAt: number | null
}
```
Encryption-Registry-Entry in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`: Felder mit `encrypted` oben.
**Postgres (`mana_platform.social_relay`):**
```sql
CREATE SCHEMA social_relay;
CREATE TABLE social_relay.drafts ( ... ); -- sync target
CREATE TABLE social_relay.linkedin_tokens ( -- OAuth-Tokens
organization_id TEXT PRIMARY KEY,
access_token_encrypted BYTEA NOT NULL,
refresh_token_encrypted BYTEA NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
```
Sync via `mana-sync` wie alle anderen Module; `appId='social-relay'`.
---
## Milestones
### M1 — Skelett + Intake (12 Tage)
- Modul registriert in `mana-apps.ts` (`tier: 'founder'`)
- Route `/social-relay`, leere Liste
- Dexie v27 + Encryption-Registry
- `POST /ingest` mit OG-Extraction (ohne LLM)
- Manuelle Text-Paste als Fallback
- Draft-Liste zeigt Quelle, noch kein generiertes Deutsch
### M2 — LLM-Pipeline (23 Tage)
- `services/mana-llm`: `social-relay.ts` Prompt-Template
- `POST /drafts/:id/generate`
- DraftDetail-View mit Side-by-Side EN/DE, Regenerate-Button
- Attribution-Footer-Check (Assertion im Backend)
- Edit-in-place
### M3 — LinkedIn OAuth + Publish (Dauer hängt von App-Review ab)
- LinkedIn Developer App einrichten
- **App-Review-Antrag stellen → läuft 24 Wochen, blockt M3** (parallel zu M1/M2 starten)
- OAuth-Flow in `mana-auth`
- `POST /drafts/:id/publish` → direkter API-Call (noch ohne Queue)
- Erfolg → Permalink in Draft, Status='published'
### M4 — Queue + Retry (1 Tag)
- Entscheidung: eigener Service vs. `mana-ai`-Mission
- Retry mit backoff
- History-View mit allen Posts + LinkedIn-Permalinks
### M5 — Watchlist / Vorschläge (optional, Phase 2)
- Howspace-Watchlist-Eintrag (URL der Company-Page)
- Täglicher Cron → OG-Scrape der öffentlichen Übersicht, Diff gegen bekannte Post-URLs
- Neue Posts als **Vorschläge** in der UI (1-Tap Ingest), kein Auto-Pull
---
## Offene Fragen
- **LinkedIn App-Review**: Wie streng ist `w_organization_social`-Approval für kleine Firmen? Erfordert u.U. eine Produkt-Demo. → Früh klären, ggf. parallel Weg-1-Workaround (Make.com + LinkedIn-Integration von Make, die eigenen OAuth-Scope mitbringt) als Übergangslösung für M3.
- **Bilder**: HS-Posts haben oft ein Hero-Image. Copyright → wir nutzen sie *nicht*. Eigenes Visual? Dann braucht M2 einen `mana-image-gen`-Hook oder manuellen Upload via `uload`. Phase-1-Entscheidung: **Text-only**, Bildoption in M5.
- **Brand Voice**: Braucht ein Edisconet-Voice-Dokument (Tonalität, Claims, verbotene Wörter) als System-Prompt-Kontext. Vor M2 zu liefern. Ablage: `services/mana-llm/prompts/voices/edisconet.md`.
- **Language-Fallback**: Was wenn HS-Post bereits Deutsch ist? → Erkennen (franc oder LLM selbst), dann nur Adaption, keine Übersetzung.
- **Eigener Service vs. inline in `apps/api`**: In M4 entscheiden.
- **Rate-Limiting**: LinkedIn erlaubt ~25 Posts/Tag/Page. Irrelevant für unseren Use-Case (15/Woche), aber in Queue einbauen.
- **Compliance-Footer**: Reicht "Inspiriert von" oder brauchen wir expliziteres "Basierend auf [URL]"? Rechtsmeinung bei Content-Ownership einholen, sobald M2 läuft.
---
## Zusammenfassung für "Was zuerst?"
Zwei Dinge **parallel** starten, weil LinkedIn App-Review lange dauert:
1. **Tag 1**: LinkedIn Developer App anlegen, Edisconet-Page verifizieren, App-Review beantragen.
2. **Tag 14**: M1 + M2 bauen (Intake + LLM-Pipeline). Ergebnis ist bereits nutzbar: Draft → Copy-to-Clipboard → manuell auf LinkedIn posten. Spart schon ~80% der Zeit gegenüber Vollmanuell.
3. **Sobald Review durch**: M3 (Auto-Publish) aktivieren.

View file

@ -0,0 +1,340 @@
# Spaces — Foundation Plan
## Status (2026-04-20)
**RFC / Architektur-Entscheidung, Code noch nicht begonnen.** Dieses Dokument fixiert den Umbau des Scope-Modells *bevor* Prod-Daten existieren. Voraussetzung für: Social Relay, `clubs/`-Paket der ClubDesk-Roadmap, Team-/Brand-/Familien-Features generell.
**Begriff gewählt:** `Space`. Nicht `Workspace` (Slack/Notion-Assoziation zu stark), nicht `Context` (kollidiert mit `kontext/`-Modul), nicht `Org` (impliziert juristische Entität, passt nicht für Familie/Personal).
---
## Ziel
Den User-zentrierten Daten-Scope ("jeder Record gehört implizit dem eingeloggten User") durch einen **Space-zentrierten** Scope ersetzen: jeder Record gehört einem Space, der User ist Mitglied eines oder mehrerer Spaces.
**Kernfrage:** *"In welchem Kontext arbeite ich gerade, und wer sieht/darf was?"* — einheitlich beantwortet für Solo-User, Familie, Verein, Brand, Team.
Damit fällt die Polymorphie `User vs. Org` weg, die wir in der Scope-Debatte als Hauptkomplexitätstreiber identifiziert haben. Industriestandard (Notion, Linear, Slack, Figma) aus gutem Grund.
---
## Leitprinzipien
1. **Eine Primitive: `Space`.** Nie User vs. Org. User hat von Anfang an mindestens einen Space (`personal`), kann weitere haben.
2. **Jeder Record hat `spaceId`. Keine Ausnahmen.** Auch Mood-Einträge, Schlafdaten, Träume leben im Personal-Space.
3. **Ohne aktiven Space keine Query.** Der Scope-Wrapper erzwingt das strukturell.
4. **Better Auth Organizations ist die Identity-Layer.** Wir bauen auf dem Plugin, nicht daneben.
5. **Privat-innerhalb-Space via `visibility`-Flag**, nicht über Scope-Polymorphie.
6. **Space-Typ bestimmt Modul-Sichtbarkeit.** Ein Brand-Space hat kein `meditate`-Modul, ein Personal-Space kein `club-finance`.
---
## Die Primitive: Space
Ein `Space` ist eine Better-Auth-Organization mit `metadata.type`.
```ts
type SpaceType =
| 'personal' // single-member, vom User selbst, existiert automatisch
| 'brand' // externe Kommunikations-Identität (Edisconet, Howspace)
| 'club' // Verein (ClubDesk-Ziel)
| 'family' // WG / Familie
| 'team' // Arbeitsteam / Projekt
| 'practice' // Freelancer-Praxis / Selbstständigkeit
type Space = {
id: string
slug: string // @edisconet, @mein-verein, @me
name: string
logo: string | null
type: SpaceType
createdAt: number
metadata: {
// Typ-spezifische Zusatzfelder — schwach typisiert, modul-spezifisch
voiceDoc?: string // brand/club
legalEntity?: string // brand/club/practice
uid?: string // brand/club/practice: MwSt / UID
aiPersonaId?: string // optional: 1:1 zu ai-agents-Eintrag
// ...
}
}
```
### Space-Typ → Modul-Allowlist
Jeder Space-Typ schaltet einen Subset der 120+ Module frei. Nicht konfigurierbar pro Space in Phase 1 — erst wenn Bedarf auftaucht.
```ts
// packages/shared-branding/src/space-modules.ts
const SPACE_MODULES: Record<SpaceType, readonly ModuleId[]> = {
personal: [ /* alle 120+ */ ],
brand: ['social-relay', 'mail', 'landing', 'contacts', 'calendar', 'files', 'tasks', 'ai-agents', 'profile'],
club: ['club-members', 'club-finance', 'calendar', 'events', 'mail', 'landing', 'files', 'tasks', 'profile'],
family: ['calendar', 'events', 'shopping', 'recipes', 'files', 'tasks', 'finance-shared', 'profile'],
team: ['tasks', 'calendar', 'files', 'mail', 'chat', 'ai-agents', 'profile'],
practice: ['invoicing', 'contacts', 'calendar', 'mail', 'finance', 'files', 'tasks', 'profile'],
}
```
---
## Data Model
### Jede Tabelle, jeder Record
```ts
// alle Dexie-Tabellen + alle Postgres-Tabellen
spaceId: string // → organizations.id, NOT NULL, indexed
visibility: 'space' | 'private' // default: 'space'
authorId: string // NOT NULL, wer den Record erstellt hat
```
- **`spaceId`**: Scope-Partition. Query startet hier.
- **`visibility: 'private'`**: Record sichtbar nur für `authorId`, auch in Multi-Member-Spaces. Ersetzt den User-vs-Org-Gedanken für den Edge-Fall "persönlicher Draft in geteiltem Space".
- **`authorId`**: unabhängig vom Space-Owner — wichtig für Multi-Member-Audit ("wer hat den Eintrag angelegt").
### Better-Auth-Erweiterung
Better-Auth-Plugin-Hooks:
- **Signup-Hook**: legt `personal`-Space mit Slug `@{user.username || user.id}` an, setzt als `activeOrganizationId`.
- **Organization.create-Hook**: verlangt `type` in `metadata`, validiert gegen enum.
- **Organization.delete-Hook**: verweigert Löschung, wenn `type='personal'`.
**JWT bleibt minimal.** Laut Architektur-Entscheid 2024-12 (siehe `services/mana-auth/src/auth/better-auth.config.ts:59-74`) stehen Org-Claims bewusst NICHT im JWT-Payload. Der aktive Space wird über die Session (Cookie) + `GET /organization/get-active-member` aufgelöst. Der Scope-Wrapper (siehe unten) liest daher aus einem **client-seitigen `$lib/stores/active-space.svelte.ts`**, der beim Boot einmal den Endpoint anfragt und bei jedem `set-active`-Call refresht. Keine JWT-Erweiterung nötig.
### Postgres-Layout
Kein eigenes `spaces`-Schema — wir nutzen Better-Auth's `organization`-Tabelle direkt. Für unsere Ergänzungen:
```sql
-- im mana_platform-DB, eigenes Schema
CREATE SCHEMA spaces;
-- Space-Credentials (OAuth-Tokens pro Space, z.B. LinkedIn für Edisconet)
CREATE TABLE spaces.credentials (
space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'linkedin', 'stripe', 'twilio', ...
access_token_encrypted BYTEA NOT NULL,
refresh_token_encrypted BYTEA,
expires_at TIMESTAMPTZ,
scopes TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (space_id, provider)
);
-- Per-Modul-Permissions im Space (feingranular über Better-Auth-Rollen hinaus)
CREATE TABLE spaces.module_permissions (
space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'owner' | 'admin' | 'member' | custom
module_id TEXT NOT NULL, -- 'club-finance', 'social-relay', ...
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_write BOOLEAN NOT NULL DEFAULT FALSE,
can_admin BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (space_id, role, module_id)
);
```
### mana_sync-DB
Wird um `space_id`-Spalte erweitert. RLS-Policy:
```sql
USING (
space_id IN (
SELECT organization_id FROM member WHERE user_id = current_user_id()
)
)
```
Der bestehende `appId`-Mechanismus bleibt — die Partition wird von `(user, appId)` zu `(space, appId)`.
---
## Scope-Wrapper (Frontend)
```
apps/mana/apps/web/src/lib/data/scope/
├── active-space.svelte.ts # reactive: { id, slug, type, role, permissions }
├── scoped-db.ts # Proxy um db, alle Queries bekommen spaceId-Filter
├── visibility.ts # applyVisibility(records, currentUserId)
└── migrations/
└── v27-add-space-scope.ts # Backfill alle existierenden Records → personal-Space
```
```ts
// Verwendung in Modulen
import { scoped } from '$lib/data/scope/scoped-db'
// statt: db.contacts.where({ ... }).toArray()
await scoped.contacts.where({ ... }).toArray()
// Escape-Hatch für Cross-Space (Home-Dashboard, globale Suche)
await scoped.contacts.acrossSpaces().where({ ... }).toArray()
```
Der Proxy wendet auto `where({ spaceId: activeSpace.id })` und filtert `visibility='private' && authorId !== me` heraus.
### Modul-Zugriff vor dem Wrapper
Zusätzlich zur `spaceId`-Filterung prüft der Wrapper gegen die `SPACE_MODULES`-Allowlist:
```ts
// scoped.club-finance in einem Personal-Space → wirft ModuleNotInSpaceError
// scoped.meditate in einem Brand-Space → dito
```
Das verhindert, dass ein Modul, das UI-seitig eigentlich ausgeblendet ist, über direkte Store-Calls trotzdem Daten schreibt.
---
## URL-Topologie
Slack/Linear/Notion-Muster:
```
mana.how/@{slug}/{module}/...
mana.how/@me/mood/today ← Personal-Space
mana.how/@edisconet/social-relay ← Brand-Space
mana.how/@turnverein/club-finance ← Club-Space
```
- SvelteKit: `+layout.ts` auf `/(app)/@[slug]/+layout.ts` resolved Space über Better-Auth-Slug-API
- Middleware: Non-Member → 404 (nicht 403, um Space-Existenz nicht zu leaken)
- Altes `activeOrganizationId` in Session wird gesetzt, damit Requests gegen `apps/api` den Scope kennen
- Space-Switcher-UI: klickt auf Space → `goto('/@${newSlug}/home')` statt Session-Manipulation
**Migration bestehender Routes**: alle Routes von `/(app)/{module}` auf `/(app)/@[slug]/{module}` verschieben. `@me` als Auto-Redirect für die alten URLs.
---
## Encryption
Drei-Stufen-Modell ersetzt die heutige User-Key-Logik:
1. **Personal-Space**: User-Key wie heute (master-key aus `mana-auth`, `MANA_AUTH_KEK`-gewrappt). Zero-Knowledge bleibt möglich.
2. **Shared-Space** (>1 Member): Space-Key, beim Space-Create erzeugt, KEK-gewrappt an jedes Mitglied über dessen Pubkey. Re-Wrap bei Member-Add. Member-Remove = Key-Rotation-Event (neue Keys für neue Records; alte bleiben lesbar — "eventual re-encryption" als Background-Job).
3. **Private Records innerhalb eines Shared-Space** (`visibility='private'`): zusätzlich mit User-Key re-encrypted, nur Autor kann lesen.
Die Encryption-Registry (`apps/mana/apps/web/src/lib/data/crypto/registry.ts`) muss für jede Tabelle wissen, welche Felder verschlüsselt werden — das bleibt wie heute. Was sich ändert: `encryptRecord()` wählt den Key aufgrund von `(spaceId, visibility)` statt konstant den User-Key.
**Phase-1-Kompromiss**: Shared-Space-Daten initial **unverschlüsselt** lassen (nur im Server-RLS-gesichert). Zero-Knowledge für Shared-Spaces kommt als eigenes RFC (`docs/plans/shared-space-encryption.md`), weil Key-Rotation und Pubkey-Infrastruktur für User nicht-trivial sind. Personal-Space behält E2E-Encryption wie heute.
---
## Tier-Logik verschieben
Heute: `tier` hängt am User (JWT-Claim). Das führt zum aktuellen `MANA_APPS tier patch`-Workaround.
Neu: **Tier hängt am Space.**
```ts
// space.metadata.tier: 'guest' | 'public' | 'beta' | 'alpha' | 'founder'
// Modul-Gate: requiredTier wird gegen activeSpace.tier geprüft
```
- Personal-Space eines Founder-Users → `tier='founder'`, alle Module
- Ein Brand-Space, den derselbe User anlegt → default `tier='beta'`, je nach Plan
- Admin-API `PUT /api/v1/admin/spaces/:id/tier` ersetzt das alte User-Tier-API
Das löst den bestehenden Tier-Patch-Workaround gleich mit auf und passt zum Billing-Modell der ClubDesk-Roadmap (Vereine zahlen pro Space, nicht User).
---
## Sync-Engine-Änderung
`mana-sync` (Go, 3050) bekommt:
1. **`space_id`-Column** in allen sync-relevanten Tabellen — NOT NULL, indexed.
2. **Partition-Key** wird `(space_id, app_id)` statt `(user_id, app_id)`.
3. **Subscription-Fan-Out**: bei Space-Member-Count > 1 muss ein Change an alle Member gepusht werden (WS/SSE). Heute ist das 1:1 (User pushed to self).
4. **Membership-Resolver**: der Server muss bei jeder Client-Connection wissen, welche Space-IDs der User sehen darf — aus Better-Auth's `member`-Tabelle.
Keine großen Algorithmus-Änderungen am Field-Level-LWW — der Scope partitioniert nur den Keyspace, die Konflikt-Resolution bleibt identisch.
---
## Migration (alles vor dem ersten Prod-Launch)
### Phase 1 — Schema + Better Auth (34 Tage)
1. `metadata.type` zu Better-Auth-Organization hinzufügen, Enum-Validierung im Signup-Hook
2. `spaces.credentials` + `spaces.module_permissions` Tabellen anlegen
3. Signup-Hook schreibt Personal-Space mit Slug `@{uniqueUsername}`
4. Dexie v28 (v27 ist vergeben): `spaceId`, `visibility`, `authorId` auf alle Tabellen
5. Migration-Script: alle bestehenden Records bekommen `spaceId=personalSpaceId(authorId)`, `visibility='space'`, `authorId=recordUserId`
6. `SPACE_MODULES` Allowlist in `packages/shared-branding`
### Phase 2 — Scope-Wrapper + eines Pilot-Modul (34 Tage)
1. `active-space.svelte.ts`, `scoped-db.ts`, `visibility.ts`
2. `calendar/` als Pilot umstellen: alle Queries auf `scoped.events...`
3. Space-Switcher-Komponente in der Shell
4. `/@[slug]/` Routing-Shell
### Phase 3 — URL-Refactor + Module-Rolling-Migration (12 Wochen)
1. Alle `(app)/*`-Routen nach `(app)/@[slug]/*` verschieben
2. Redirects `/(old-route)``/@me/(old-route)` als Übergang (1 Release, dann entfernen)
3. Modul-für-Modul auf `scoped`-Wrapper umstellen — kann parallelisiert werden
4. Tier-Logik von User auf Space verschieben; `MANA_APPS tier patch` auflösen
### Phase 4 — mana-sync (23 Tage)
1. `space_id`-Column + RLS-Policy
2. Partition-Key auf `(space_id, app_id)`
3. Subscription-Fan-Out testen mit 2-Member-Spaces
4. camt-/TWINT-Integration des ClubDesk-Pakets B baut dann direkt auf Space-Scope auf
### Phase 5 — Shared-Space-Encryption (eigenes RFC, späterer Zeitpunkt)
Siehe Encryption-Abschnitt. Nicht blockierend für Launch, kommt bei Bedarf.
---
## Konsequenzen / neue Anforderungen
### Space-Create-Flow
UI-Flow "Neuen Space anlegen" muss Typ-Auswahl anbieten mit Kurzbeschreibung. Templates (z.B. "Verein" → lädt `club`-Grundstruktur) wären wünschenswert, aber Phase 2.
### Cross-Space-Dashboards
- **Home** (`/@me/home`): aggregiert "heute anstehend" aus allen Spaces, die der User sieht. Nutzt `scoped.X.acrossSpaces()`.
- **Globale Suche**: Default = aktiver Space. Toggle "alle Spaces" sichtbar.
- **Notification-Feed**: immer Space-tagged (`[Edisconet] ...`, `[Turnverein] ...`).
### Space-Discovery / Invite-Flow
Better Auth liefert den Invite-Token-Flow. UI: Share-Link mit Rolle, E-Mail-Invite mit Rolle, Join-Page mit Space-Preview vor Accept.
### Profile-Modul wird bi-skopig
Das bestehende `profile/`-Modul bekommt zwei Modi: "User-Profil" (persönliche Daten, Timezone, Sprache) und "Space-Profil" (Logo, Voice-Doc, legal entity, Tools). Beide leben im aktiven Space — Space vom Typ `personal` zeigt User-Profil-Variante, andere Typen zeigen Space-Profil-Variante.
### Module, die fachlich nicht teilbar sind
Via `SPACE_MODULES` in Nicht-Personal-Spaces gar nicht erst erreichbar. Kein Code im Modul selber nötig.
---
## Was NICHT in diesem RFC entschieden wird
- **Konkrete ClubDesk-Module** (`clubs/`, `club-finance`): folgen einem eigenen Plan, bauen aber auf Spaces auf
- **Social-Relay-Implementierung**: der bestehende `social-relay-module.md` wird nach diesem Foundation-Plan überarbeitet (Brand wird zum Space, nicht eigenes `brands/`-Modul)
- **Shared-Space-Encryption**: eigenes RFC (Phase 5)
- **Billing-Integration** (Stripe pro Space): späterer Plan, braucht Spaces als Voraussetzung
- **Multi-Space-Parallel-Views** (Slack-Stil mit mehreren Spaces gleichzeitig sichtbar): Phase-2-Idee, nicht Launch-kritisch
---
## Offene Fragen
- **Slug-Uniqueness-Kollision**: User Till mit `@till` kollidiert mit potentiellem Brand `@till`. Lösungsraum: Slugs global unique (einfach, aber Race um beliebte Namen) vs. Slug-Präfixe (`@user/till` vs. `@org/till` — hässlich). Vorschlag: global unique, First-Come-First-Served, User-Slug bei Signup aus E-Mail-Local-Part + Suffix bei Kollision.
- **Default-Visibility neuer Felder**: `visibility='space'` standard. Aber z.B. Mood-Einträge in einem Shared-Family-Space wären `visibility='private'` per Modul-Config sinnvoll. Brauchen wir ein `defaultVisibility` pro Modul-Tabelle?
- **Space-Löschung**: was passiert mit veröffentlichten Inhalten (z.B. LinkedIn-Posts via Social Relay)? Soft-Delete mit Re-Activation-Period? Hard-Delete mit Warnungen? Orga-Entscheidung, nicht Architektur.
- **Anonymes Space-Sharing** (z.B. Event-Anmelde-Link ohne Account — existiert bereits in `mana-events`): bleibt wie heute, ist public-by-token und orthogonal zum Space-Scope.
- **Werden User-Tables in Better-Auth selbst ge-space't?** Nein: User, Session, Account sind identity-global. Nur unsere Domain-Daten werden ge-space't.
- **Slug-Änderungen**: darf ein Space seinen Slug ändern? Ja, mit 301-Redirect über alte Slugs (braucht `space_slugs_history`-Tabelle). Phase 2.
- **Welches Modul bekommt `social-relay` zum ersten Einsatz?** Logisch: nachdem Spaces stehen, Social Relay als *erster* neuer Modul-Code nach der Foundation — das stress-testet das ganze Modell mit einer echten Brand-Space-Anwendung.
---
## Zusammenfassung
Space-first macht 120+ Module strukturell gleich, eliminiert die User-vs-Org-Polymorphie, nutzt Better Auth maximal, und bildet die Grundlage für alles, was mit Team-/Brand-/Verein-Features kommt. Der Aufwand ist ein einmaliger Schema-Umbau jetzt (geschätzt 34 Wochen bei konzentrierter Arbeit), bevor Prod-Daten existieren. Das Alternative (später nachrüsten) wäre ein mehrmonatiges Migrationsprojekt mit Downtime.
**Reihenfolge kritischer Abhängigkeiten:**
1. Phase 1 (Schema) — blockiert alles weitere
2. Phase 2 (Wrapper + Pilot) — validiert das Modell an einem echten Modul
3. Phase 3 + 4 (Rolling-Migration) — parallelisierbar
4. Social Relay, ClubDesk-Pakete, Billing, Team-Features — alle danach

View file

@ -69,3 +69,14 @@ export {
// Types
export type { AppId, AppBranding, LogoProps, AppLogoWithNameProps } from './types';
// Spaces (multi-tenancy primitive — see docs/plans/spaces-foundation.md)
export {
SPACE_TYPES,
SPACE_TYPE_LABELS,
SPACE_TYPE_DESCRIPTIONS,
SPACE_MODULE_ALLOWLIST,
isModuleAllowedInSpace,
type SpaceType,
type SpaceModuleId,
} from './spaces';

View file

@ -0,0 +1,232 @@
/**
* Space Types & Module Allowlist
*
* A "Space" is the unit of data ownership in Mana. Every record belongs to
* exactly one Space. Users join Spaces via Better Auth's `member` relation.
*
* Space = Better Auth Organization with a typed `metadata.type` field. The
* type drives which modules are available inside the space (see
* `SPACE_MODULE_ALLOWLIST` below).
*
* See docs/plans/spaces-foundation.md for the full RFC.
*/
/**
* The six canonical Space types. Every Better Auth organization must have
* exactly one of these as `metadata.type`.
*
* - `personal` single-member, auto-created on signup. Holds private data
* like mood, sleep, dreams that don't belong in a shared context.
* - `brand` external communication identity (e.g. Edisconet, a creator
* persona). Hosts social-relay, mail, landing, public content.
* - `club` association/Verein. Member management, dues, events,
* governance. Target for the ClubDesk-replacement roadmap.
* - `family` household/family/WG. Shared calendar, shopping, recipes.
* - `team` work team / project. Tasks, chat, docs.
* - `practice` freelancer/solo-business. Invoicing, clients, time tracking.
*/
export type SpaceType = 'personal' | 'brand' | 'club' | 'family' | 'team' | 'practice';
export const SPACE_TYPES: readonly SpaceType[] = [
'personal',
'brand',
'club',
'family',
'team',
'practice',
] as const;
export const SPACE_TYPE_LABELS = {
de: {
personal: 'Persönlich',
brand: 'Marke',
club: 'Verein',
family: 'Familie',
team: 'Team',
practice: 'Praxis',
},
en: {
personal: 'Personal',
brand: 'Brand',
club: 'Club',
family: 'Family',
team: 'Team',
practice: 'Practice',
},
} as const;
export const SPACE_TYPE_DESCRIPTIONS = {
de: {
personal: 'Dein eigener Bereich — wird beim Signup automatisch angelegt.',
brand: 'Externe Kommunikations-Identität (z.B. eine Marke, ein öffentlicher Account).',
club: 'Vereinsverwaltung mit Mitgliedern, Beiträgen und Events.',
family: 'Geteilter Bereich für Haushalt, Familie oder WG.',
team: 'Arbeitsteam oder Projekt mit mehreren Mitwirkenden.',
practice: 'Freelancer- oder Solo-Business mit Kunden und Rechnungen.',
},
en: {
personal: 'Your own space — created automatically at signup.',
brand: 'External communication identity (e.g. a brand, a public account).',
club: 'Club management with members, dues, and events.',
family: 'Shared space for household, family, or flatshare.',
team: 'Work team or project with multiple collaborators.',
practice: 'Freelancer or solo business with clients and invoices.',
},
} as const;
/**
* Module IDs referenced by the allowlist. Strings (not a strict enum) because
* the allowlist intentionally includes modules that don't exist yet e.g.
* `club-finance`, `social-relay` so features can be gated before the code
* lands.
*/
export type SpaceModuleId = string;
/**
* Which modules are available inside each Space type.
*
* The personal space gets everything (sentinel `'*'`). Other types get a
* curated subset modules dealing with intimate personal data (mood,
* dreams, period, body measurements, ) are intentionally excluded from
* shared spaces.
*
* Rule of thumb: if a module's data would feel wrong shared with co-workers
* or club members, keep it out.
*/
export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[] | '*'> = {
personal: '*',
brand: [
'mana',
'social-relay', // future — not yet built
'mail',
'contacts',
'calendar',
'storage',
'uload',
'landing', // future
'presi',
'cards',
'picture',
'quotes',
'news',
'news-research',
'research-lab',
'ai-agents',
'companion',
'times',
'notes',
'photos',
'invoices',
'activity',
'goals',
],
club: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'news',
'research-lab',
'club-members', // future — ClubDesk Paket A
'club-finance', // future — ClubDesk Paket B
'invoices',
'finance',
'landing', // future — Paket C (Vereinswebsite)
'presi',
'cards',
'quotes',
'companion',
'times',
'notes',
'photos',
'activity',
'goals',
],
family: [
'mana',
'contacts',
'calendar',
'events',
'mail',
'storage',
'uload',
'recipes',
'food',
'places',
'presi',
'cards',
'photos',
'notes',
'companion',
'goals',
'activity',
'wetter',
'wisekeep',
'firsts',
],
team: [
'mana',
'contacts',
'calendar',
'events',
'storage',
'mail',
'uload',
'news',
'news-research',
'research-lab',
'presi',
'cards',
'picture',
'notes',
'quotes',
'invoices',
'companion',
'ai-agents',
'times',
'activity',
'goals',
],
practice: [
'mana',
'contacts',
'calendar',
'storage',
'mail',
'uload',
'invoices',
'finance',
'times',
'notes',
'presi',
'cards',
'quotes',
'companion',
'research-lab',
'activity',
'goals',
],
} as const;
/**
* Check whether a module is available inside a given Space type.
*
* Used by:
* - Scope wrapper (apps/mana/.../data/scope/scoped-db.ts) to block queries
* against disallowed modules structural guard against UI bypass.
* - UI module launcher to hide disabled modules in the active space.
* - Route guards that check before mounting a module page.
*/
export function isModuleAllowedInSpace(moduleId: SpaceModuleId, spaceType: SpaceType): boolean {
const allow = SPACE_MODULE_ALLOWLIST[spaceType];
if (allow === '*') return true;
return allow.includes(moduleId);
}