managarten/docs/plans/spaces-foundation.md
Till JS 80dbb3b3b6 feat(spaces): migrate calendar module to scoped-db wrapper (pilot)
First module to consume the scope layer — proves the model end-to-end
on a real query path.

Changes in calendar/queries.ts:
- db.table('calendars')   → scopedForModule<LocalCalendar>('calendar', 'calendars')
- db.table('timeBlocks')  → scopedForModule<LocalTimeBlock>('calendar', 'timeBlocks')
- db.table('events')      → scopedForModule<LocalEvent>('calendar', 'events')
- applyVisibility() wrapper runs on each read to drop private records
  authored by other members of a shared space.

Scope wrapper tweaks:
- getInScopeSpaceIds is now lenient during boot: if no active space has
  loaded yet, falls back to the user's personal sentinel so sentinel-
  stamped records from the v28 migration still render. Returns [] only
  when fully unauthenticated, which yields an empty-match filter.
- applyVisibility is no longer generic-constrained — T is inferred
  exactly as the input type; visibility/authorId are read via runtime
  duck-typing so arbitrary record shapes pass through cleanly.

Known follow-ups:
- Root-layout bootstrap (load active space + reconcile sentinels on
  login) is intentionally not wired up yet — needs a separate pass on
  the already-crowded (app) layout to avoid collateral damage.
- Four legacy tables (conversations, documents, spaceMembers,
  memoSpaces) carry a pre-existing `spaceId` field that points to the
  older context-space concept, not our multi-tenancy space. Renaming
  those to contextSpaceId is a tracked follow-up in the RFC — calendar
  is unaffected.

Plan: docs/plans/spaces-foundation.md (updated with the legacy-spaceId
note + lenient-scope rationale).

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

362 lines
18 KiB
Markdown
Raw Permalink 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.

# 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
---
## Bekannte Altlast: `spaceId`-Namenskollision
Vier bestehende Dexie-Tabellen nutzen das Feld `spaceId` bereits für das
**ältere** Context-Space-Konzept (chat-/memoro-interne Kontext-Ordner,
nicht das neue Multi-Tenancy-Space):
- `conversations` (chat) — `spaceId``contextSpaces.id`
- `documents` (context) — `spaceId``contextSpaces.id`
- `spaceMembers` (memoro) — `spaceId``contextSpaces.id`
- `memoSpaces` (memoro) — `spaceId``contextSpaces.id`
Die v28-Migration hat diese Tabellen **nicht korrumpiert**, weil der
Stamping-Code nur fehlende `spaceId`-Felder setzt (`if undefined/null`).
Bestehende Records mit Context-Space-Referenzen sind unverändert.
**Follow-up**: Rename `spaceId``contextSpaceId` auf diesen vier Tabellen
+ ihren Modulen + Dexie-v29-Migration, damit das Namensfeld eindeutig der
neuen Space-Primitive gehört. Bis dahin ist der Scope-Wrapper für diese
Tabellen nicht verwendbar — entweder Kollision erst fixen oder das
Wrapper-Filter per Modul-Ausnahme deaktivieren. Calendar, Todo, Notes etc.
sind nicht betroffen.
## 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