managarten/docs/plans/visibility-system.md
Till JS 259f6fb316 fix(shared-privacy): default all new records to 'space', not 'private'
Regression reported in testing: tasks and calendar events created via
the Workbench homepage widgets appeared there but vanished from their
respective module sub-routes (/todo, /calendar).

Root cause: my M4.b + M4.a shipped `defaultVisibilityFor('personal') →
'private'` based on the original plan ("personal space default is
private"). That collides with the pre-existing 2-tier visibility filter
in `apps/mana/apps/web/src/lib/data/scope/visibility.ts`, which treats
'private' records as "only the authorId sees them, even inside the
same space". Its applyVisibility() drops any 'private' record whose
authorId doesn't exactly match getCurrentUserId() — and the homepage-
widget cross-app queries in cross-app-queries.ts don't run that filter
while /todo/useAllTasks() does, creating the asymmetry the user saw.

Why the match can fail in practice: during auth bootstrap,
getEffectiveUserId() returns the 'guest' sentinel (which the Dexie
creating-hook stamps onto authorId), while getCurrentUserId() can
already resolve to the real user id by the time /todo's query runs.
authorId='guest' !== currentUserId=<real> → record filtered out.

Fix: defaultVisibilityFor() now returns 'space' regardless of space
type. Rationale:
- In a personal space there's exactly one member, so 'space' and
  'private' are effectively equivalent — both mean "only the owner
  sees it".
- In a multi-member space, 'space' is the desired default (otherwise
  every collaborative record would need a manual toggle).
- 'private' becomes an *active* user decision for drafts in shared
  spaces — click the VisibilityPicker to enable it.
- The parameter is retained (as `_spaceType`) for forward-compat so
  future space types can differentiate without touching call sites.

Impact on shipped modules: all 8 consumers (Library, Picture,
Calendar, Todo, Goals, Places, Recipes, Wardrobe) call
defaultVisibilityFor(activeSpace.type) at create time — they inherit
the fix automatically. No store edits required.

Existing records with visibility='private' from the testing window
stay as they are; user can flip them to 'Bereich' via the
VisibilityPicker, or reset the local Dexie to pick up the new default.

Plan doc updated with the full rationale (docs/plans/
visibility-system.md §Entscheidung).

Verified:
- pnpm test @mana/shared-privacy: 15/15 (defaults.test.ts updated)
- pnpm check (web): 7464 files, 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:46:48 +02:00

18 KiB

Visibility-System — Plan

Status (2026-04-23)

PLANUNG — noch kein Code. Dieses Dokument schreibt die Design-Entscheidungen fest, bevor Code entsteht. Ausgelöst durch die Frage "wie zeige ich Kalender-/Task-Daten auf einer publizierten Website?" — beim Audit wurde klar, dass das ein repo-weites Thema ist, nicht ein Website-Builder-Detail.

Bisheriger Befund:

  • 7 Module haben einen ad-hoc isPublic-Boolean (picture, cards, presi, memoro, times, broadcast.audience, uload.tags). Semantik inkonsistent, meistens ohne UI-Control.
  • Mehrheit der Module (library, calendar, todo, notes, contacts, places, events, goals, habits, recipes, quiz, wardrobe, invoices-clients, …) hat gar kein Visibility-Feld.
  • Spaces regeln nur Member-Permissions (can_read, can_write, can_admin) pro Rolle — kein "öffentlich"-Level auf Space-Ebene.
  • Tag-System existiert (user-global, N:N via TagLink), trägt aber keine Visibility-Ebene.
  • Kein globales Privacy-Setting im Settings-Bereich.
  • Keine Architektur-Dokumentation zu dem Thema — Greenfield.
  • 27 Tabellen sind AES-GCM-verschlüsselt. Kein Showstopper: embeds.ts demonstriert bereits das Muster "clientseitig entschlüsseln → plaintext in den Snapshot inlinen".

Ziel

Ein einheitliches, deny-by-default Visibility-System, das:

  • pro Record eine klare, auditable Sichtbarkeitsstufe setzt,
  • überall im UI identisch bedienbar ist (ein <VisibilityPicker>-Control, ein Lock-Icon),
  • mit dem Verschlüsselungslayer kompatibel ist (public Content wird beim Publish entschlüsselt und als plaintext inlined — nicht in Dexie umgeschrieben),
  • mit der Spaces-Foundation kompatibel ist (Space-Member-Sicht ist eine eigene Stufe),
  • die existierenden ad-hoc isPublic-Flags ablöst.

Abgrenzung

  • Kein Sharing-zu-spezifischen-Usern. Mana hat kein "mit user@x.de teilen"-Feature. Für Multi-User-Zugriff gibt's Spaces. Eine Stufe "link-basiert geteilt" (Unlisted) ist drin, "an 3 Personen geteilt" nicht.
  • Kein rollenbasiertes Sub-Gating innerhalb eines Space. visibility='space' heißt "alle Space-Member sehen es" — nicht "nur Admins". Für differenzierte Sichten bleibt die spaceModulePermissions-Matrix zuständig.
  • Kein Field-Level-Visibility in Phase 1. Wenn ein Calendar-Event public wird, werden Gästelisten vom Publish-Resolver redacted, das Feature ist auf Record-Ebene. Feld-Ebene ist zu komplex für zu wenig Nutzen.
  • Kein Cascading. Ein Todo unter einem public Goal wird nicht automatisch public. Jedes Item hat seinen eigenen Flag. Mikro-Toggling ist der Preis für klare Semantik.
  • Kein "temporär public" in Phase 1. publicUntil-Timestamp wird später diskutiert; nicht im ersten Wurf.
  • Keine öffentliche Discovery / Search-Indexierung in Phase 1. Public Items sind auf dem eigenen Website-Snapshot sichtbar, nicht in einem globalen Mana-Feed. <meta name="robots">-Defaults sind noindex bis der User explizit im Website-Settings-Dialog opt-in macht.

Entscheidung: vier Stufen

// packages/shared-privacy/src/types.ts
export type VisibilityLevel = 'private' | 'space' | 'unlisted' | 'public';
Stufe Wer sieht es Default für
private Nur Owner (Personal-Space) oder keine (error) in Multi-Member Personal-Space, bei sensiblen Modulen sowieso
space Alle Space-Member gemäß spaceModulePermissions Team/Club-Space-Defaults
unlisted Wer den Direct-Link + Token hat; nicht gelistet, nicht indexiert Event-Einladungen mit Nicht-Member-Gästen
public Beliebig — sichtbar auf Website-Embeds, für AI-Agent referenzierbar Ein aktiv als "für die Öffentlichkeit" markiertes Item

Default bei Record-Create: immer space, unabhängig vom Space-Typ.

Ursprünglich war Personal-Space → private geplant. Nach der ersten Integration (M4.b Todo) stellte sich heraus: das existierende 2-Stufen-System in scope/visibility.ts behandelt 'private' als "nur der Author sieht es, auch innerhalb des gleichen Space" — und filtert solche Records im applyVisibility()-Aufruf der Modul-Queries. Das fightet mit dem "Personal-Space default = private"-Plan: neu angelegte Tasks/Events erschienen im Homepage-Widget (das keinen Filter nutzt), verschwanden aber auf /todo (Filter schlägt zu). Der authorId-Check in applyVisibility versagt während des Auth-Bootstraps, wenn der Hook 'guest' als authorId stempelt aber getCurrentUserId() bereits den echten User zurückgibt.

Der robuste Fix: defaultVisibilityFor() returnt immer 'space'. Im Personal-Space (1 Member) ist das semantisch äquivalent zu 'private'. In Multi-Member-Spaces ist 'space' der erwünschte Default (sonst müssten User jeden Task manuell für ihre Mitglieder freischalten). 'private' wird zur aktiven User-Entscheidung für Drafts in geteilten Spaces — Klick im VisibilityPicker.

  • Nie public oder unlisted als Default (deny-by-default-Regel bleibt).

Begründung der Namen: visibility + 4-Stufen-Enum ist das vertraute Google-Drive/YouTube-Mental-Model. Alternativ diskutiert: sharingLevel, audience. Verworfen — visibility ist breiter bekannt und technischer neutral (keine "Zuhörerschaft"-Assoziation).

Architektur

1. Shared-Package — @mana/shared-privacy

Neue Workspace-Package mit:

// types.ts
export type VisibilityLevel = 'private' | 'space' | 'unlisted' | 'public';

// schema.ts
export const VisibilityLevelSchema = z.enum(['private', 'space', 'unlisted', 'public']);

// defaults.ts
export function defaultVisibilityFor(_spaceType: SpaceType | null | undefined): VisibilityLevel {
  // Always 'space' — compatible with the existing 2-tier applyVisibility
  // filter, and semantically equivalent to 'private' in personal spaces
  // (only one member). Users flip to 'private' explicitly for drafts.
  return 'space';
}

// predicates.ts
export function canPublish(level: VisibilityLevel): boolean {
  return level === 'public' || level === 'unlisted';
}
export function canEmbedOnWebsite(level: VisibilityLevel): boolean {
  return level === 'public';
}
export function isVisibleToSpaceMember(level: VisibilityLevel): boolean {
  return level !== 'private';
}

// UnlistedToken.ts — share-tokens für unlisted-Mode
export function generateUnlistedToken(): string;  // crypto.randomUUID-like
  • <VisibilityPicker level={...} onChange={...} spaceType={...} /> Svelte-Component — Lock-Icon-Dropdown mit den 4 Stufen, Beschreibungstext, Modul-agnostisch.

2. Per-Record-Feld

Jede Public-Fähige Tabelle bekommt:

// Dexie (client) + Postgres (mana-sync)
visibility: text not null default 'private'  // VisibilityLevel
unlistedToken: text  // NULL außer wenn visibility='unlisted'
visibilityChangedAt: timestamptz
visibilityChangedBy: text  // userId, für audit

visibility steht nicht in der Encryption-Registry → bleibt plaintext. Das ist notwendig, damit RLS-Predicates und Publish-Resolver es ohne MK lesen können.

3. Public-fähige vs. niemals-public Module

Phase 1 betrifft nur die "public-fähigen" Module. Die anderen bleiben implizit private (Feld nicht vorhanden, Encryption bleibt).

Public-fähig (Phase 1 Target — bekommt visibility-Feld):

  • library (libraryEntries) — erste Pilot-Migration
  • picture (boards, images) — ersetzt existierendes isPublic
  • places (places)
  • events (socialEvents) — Event-Kalender auf Band-Seite
  • calendar (events) — Tour-Pläne
  • todo (tasks) — Public Roadmap
  • goals (goals) — Progress-Seite
  • habits (habits) — Build-in-Public
  • recipes (recipes) — Rezept-Sammlung
  • quiz (quizzes) — Public Quizze
  • wardrobe (wardrobeGarments, wardrobeOutfits) — Style-Portfolio
  • invoices (invoiceClients) — Client-Portal
  • presi (decks) — ersetzt existierendes isPublic
  • cards (decks) — ersetzt existierendes isPublic
  • memoro (memories) — ersetzt existierendes isPublic

Niemals-public (bleibt ohne Feld, Encryption bleibt wie ist):

  • notes, dreams, journalEntries, contacts, memos, messages
  • sleep*, mood*, periods, finance (transactions)
  • drink*, stretch*, meditate*
  • mailDrafts, chat.conversations
  • body*, stretch* (Health-Daten)
  • kontextDoc, userContext (User-State für AI)
  • _aiDebugLog, _events (Meta-Infrastruktur)

Bei Grenzfällen (z. B. meImages — persönliche Avatar-Bilder, aber können öffentlich sichtbar sein als Profilbild) entscheidet Reihenfolge: zuerst „definitely-public" Module migrieren, dann Edge-Cases einzeln diskutieren.

4. RLS-Integration

Heute: select … where space_id = current_space() and user_has_read(module).

Neu für Public-fähige Tabellen:

select  where
  (space_id = current_space() and user_has_read(module))
  or visibility = 'public'
  or (visibility = 'unlisted' and unlisted_token = request.header('X-Unlisted-Token'))

Der öffentliche Website-Renderer liest ausschließlich aus published_snapshots — das sind bereits plaintext JSON-Blobs, keine Dexie-Tabellen-Reads. RLS-Änderung ist für zukünftige Server-Side-APIs relevant (z. B. eine API, die Android-Apps public Places ausliefert), nicht für den jetzigen Website-Flow.

5. Publish-Resolver (embeds.ts)

Statt ad-hoc-Checks (wie if (!rawBoard.isPublic) throw):

import { canEmbedOnWebsite } from '@mana/shared-privacy';

async function resolveCalendarEvents(props): Promise<EmbedItem[]> {
  let events = await db.table('events').toArray();
  events = events.filter(e =>
    !e.deletedAt
    && canEmbedOnWebsite(e.visibility ?? 'private')  // hard gate
  );
  // User-Filter darauf:
  if (props.filter?.tags?.length) {  }
  if (props.filter?.upcomingDays) {  }
  // Decrypt + map to EmbedItem
  return (await decryptRecords('events', events)).map(toEmbedItem);
}

Harte Regel: Jeder Embed-Resolver canEmbedOnWebsite-gated auf visibility === 'public'. User-Filter (Tags, Status) werden zusätzlich draufgelegt, nie ersetzend.

6. UI-Konvention

  • Detail-View jedes Moduls bekommt ein Lock-Icon oben rechts neben den anderen Actions. Click öffnet <VisibilityPicker>.
  • List-View zeigt ein kleines Lock-Icon pro Item, wenn visibility !== spaceDefault. Public/Unlisted-Items sind visuell kenntlich.
  • "Visibility änderen" in die Workbench-Event-Timeline — Domain-Event VisibilityChanged (alter Wert + neuer Wert + Actor).
  • Settings-Bereich bekommt eine Übersichtsseite /settings/privacy mit:
    • aktueller Count pro Stufe pro Modul ("Du hast 3 public Places, 12 public Library-Einträge, …")
    • Bulk-Aktion "alle public Items dieses Space auf private stellen" (Kill-Switch)

Migration-Strategie

Per existierendem soft-first/hard-follow-up-Pattern (siehe Memory-Regel):

Soft-Migration pro Modul:

  1. Schema-Add: visibility text default 'private', unlistedToken text, visibilityChangedAt, visibilityChangedBy
  2. Dexie v+1
  3. <VisibilityPicker> ins Detail-View einbauen
  4. Store-Methoden (setVisibility(id, level)) + Domain-Event
  5. Publish-Resolver (falls Modul embedbar) auf canEmbedOnWebsite umstellen
  6. Bestehende isPublic-Records: visibility = 'public' wenn isPublic === true, sonst 'private'

Hard-Migration (später): 7. isPublic-Feld aus Schema droppen 8. Code-Cleanup der ad-hoc isPublic-Checks

Jede Phase ist ein eigener PR; keine Big-Bang-Migration.

Rollout-Reihenfolge (Milestones)

M1 — Foundation

  • @mana/shared-privacy Package: Types, Zod, Defaults, Predicates, <VisibilityPicker>
  • Tests
  • Story im Storybook (falls Storybook existiert) für den Picker
  • Dokumentation in .claude/guidelines/ (neue Seite: "Adding visibility to a module")

Akzeptanzkriterium: Package published, Picker rendert, Tests grün. Noch kein Modul benutzt es.

M2 — Pilot: Library

  • libraryEntries bekommt das Feld
  • UI: Lock-Icon + Picker im Library-Detail-View
  • Store: setVisibility
  • Publish-Resolver: resolveLibraryEntries filtert visibility='public' (statt heute nur isFavorite)
  • Dexie-Migration v+1 (soft: defaultet alle existierenden auf 'private')
  • End-to-End-Test: User markiert Library-Entry public, published Website, Item erscheint im Embed

Akzeptanzkriterium: Picker funktioniert, Publish-Resolver respektiert ihn, soft-Migration löst alte Daten nicht.

M3 — Picture (ersetzt isPublic)

  • boards.visibility + images.visibility ergänzen
  • Legacy isPublic: synthetischer Default bei Migration (isPublic=true → visibility='public')
  • Publish-Resolver (resolvePictureBoard) von if (!rawBoard.isPublic) auf canEmbedOnWebsite
  • UI: Picker im Board-Detail + Image-Detail

Akzeptanzkriterium: Bestehende public Boards bleiben public nach Migration. Neue Boards kriegen default private.

M4 — Calendar + Todo + Goals

Parallel, weil ähnliche Pattern:

  • Feld-Add in jeweiligem Schema
  • Picker im Detail-View
  • Neuer Embed-Resolver calendar.events, todo.tasks, goals.goals in embeds.ts
  • moduleEmbed-Schema erweitern: neue source-Enum-Werte

Akzeptanzkriterium: Ein Test-Space kann eine Website mit Tour-Kalender + Public-Roadmap + Goal-Progress publishen.

M5 — Places + Events + Recipes + Habits + Quiz + Wardrobe + Invoices

Breite Welle — alle Module, die noch public-relevant sind. Jedes ist ein kleiner PR nach gleichem Muster.

M6 — Konsolidierung der Legacy-Flags

  • cards.isPublic, presi.isPublic, memoro.isPublic, uload.tags.isPublic auf das Unified-Feld umstellen
  • isPublic-Spalten droppen (Hard-Migration)
  • times.visibility-Enum ('private'|'guild') mappen auf das neue 4-Stufen-Enum ('private' bleibt, 'guild''space')

M7 — Settings-Übersicht + Kill-Switch

  • /settings/privacy Seite
  • Counts pro Modul + pro Stufe
  • "Alles auf privat stellen"-Button mit Bestätigungs-Dialog

M8 — Optional: Unlisted-Mode

  • /share/[token] Route, rendert einen Record per Token
  • Per Modul: Share-Dialog "Link erstellen"
  • Neu erzeugte Tokens rotieren nicht automatisch — wer den Link weitergibt, akzeptiert permanente Exposure bis Revoke

Designentscheidungen (2026-04-23 festgeschrieben)

Die folgenden Fragen waren offen beim ersten Entwurf und wurden vor der Umsetzung entschieden.

  1. Subressourcen-Redaction. Entschieden: Whitelist, nicht Blacklist. Publish-Resolver ziehen nur explizit freigegebene Felder in den Snapshot. Beim Calendar-Event: { title, startsAt, endsAt, location.publicAddress }; das Gäste-Array wird auf guestCount: number degradiert. Pro-Feld-Freigabe (publicFields: string[] am Record) ist Phase 2.

  2. Owner-Identität auf public Items. Entschieden: Space-Setting, nicht per-Record. Der Space bekommt ein Feld publicDisplayName (z. B. "Tills Gigs" oder "Anonym"). Publish-Snapshots zeigen nur diesen Namen — nie User-Real-Name, Avatar oder Email. Space ist ohnehin der Publish-Container; dort gehört die Identitätsentscheidung hin.

  3. AI-Agent-Zugriff cross-user. Entschieden: Phase 1 nein, Predicate vorbereitet. canAiAccessCrossUser(level) im @mana/shared-privacy-Package returnt immer false. Wenn später ein Feature cross-user-Reads will, wird's pro Modul explizit freigeschaltet. Fließt nicht ins MVP.

  4. Encryption-Registry-Update. Entschieden: Record bleibt encrypted, nur visibility ist plaintext. Publish-Flow entschlüsselt clientseitig und inlined plaintext in den Snapshot (heutiges Picture-Board-Muster). Vorteile: Dexie-Backups bleiben durchgehend encrypted, Zero-Knowledge-Mode funktioniert weiter, keine Re-Encryption-Migration beim Visibility-Toggle.

  5. Mehrere Websites pro Space. Entschieden: Phase 1 ignoriert das. visibility='public' heißt "für das aktive Space-Publish-Target". Bei späterer Multi-Site kommt eine siteIds-Filter am Embed-Block, nicht am Record. Record-Modell bleibt simpel; Komplexität lebt im Publish-Layer.

  6. Preview-Mode im Editor. Entschieden: Zwei explizite Modi, Toggle im Editor. "Bearbeiten" (Owner sieht alles, default) + "Als Besucher ansehen" (Embed-Filter werden angewandt). previewAsPublic: boolean im EditorView-State, den Embed-Renderer respektieren. Implementierung hängt an M4.

  7. Default-Migration der 7 ad-hoc isPublic-Flags. Entschieden: Strict Mapping, kein User-Prompt. isPublic === truevisibility='public'. Alles andere → visibility='private' (nicht space, weil die existierenden Flags keine Space-Dimension hatten). Hard-Drop des alten Booleans in M6.

Zusätzlich für M1 festgeschrieben:

  • Unlisted-Token-Format: 32-char base64url, generiert via crypto.randomUUID() + Base-Normalisierung. Rotiert NICHT automatisch. Revoke = Token auf NULL setzen und Visibility auf private zurückdrehen.
  • Domain-Event: VisibilityChanged mit Payload { recordId, collection, before, after, actor }. Landet im _events-Log → integriert sich in Workbench-Timeline und AI-Revert-System.
  • Rate-Limit-Warnung (optional, M7): "mehr als 100 Records public in einer Minute" zeigt einen Toast "X Items wurden public gemacht — rückgängig machen?". Kein Hard-Block, nur Fat-Finger-Schutz.

Anti-Patterns — was wir nicht bauen

  1. Kein globaler Privacy-Boolean im User-Profil. Sowas wie "mein ganzer Account ist privat" ist redundant, wenn der Default eh private ist und User per Record explizit zustimmen müssen.
  2. Kein dynamisches Visibility-Propagate. Wenn ein Goal public wird, werden zugehörige Todos nicht automatisch public. Explizit pro Item.
  3. Kein Silent-Downgrade. Wenn ein public Item gelöscht wird, wird es gelöscht — nicht stillschweigend auf private gesetzt. User-Intent klar widerspiegeln.
  4. Kein "teilen mit Mana-User X per Email". Für Multi-User gibt's Spaces. Für Ad-hoc-Sharing gibt's unlisted. Dritte Option = Komplexität ohne klaren Use-Case.
  5. Keine Side-Channel-Leaks durch Counts. /settings/privacy zeigt Counts pro Modul — muss aber nur für eigene Daten, keine cross-user-Daten.
  6. Kein Inline-CSS im Public-Renderer für Privacy-Indicators. Klar abgegrenzt im Design-System, nicht ad-hoc.