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>
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.tsdemonstriert 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 diespaceModulePermissions-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 sindnoindexbis 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
publicoderunlistedals 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-Migrationpicture(boards, images) — ersetzt existierendesisPublicplaces(places)events(socialEvents) — Event-Kalender auf Band-Seitecalendar(events) — Tour-Plänetodo(tasks) — Public Roadmapgoals(goals) — Progress-Seitehabits(habits) — Build-in-Publicrecipes(recipes) — Rezept-Sammlungquiz(quizzes) — Public Quizzewardrobe(wardrobeGarments, wardrobeOutfits) — Style-Portfolioinvoices(invoiceClients) — Client-Portalpresi(decks) — ersetzt existierendesisPubliccards(decks) — ersetzt existierendesisPublicmemoro(memories) — ersetzt existierendesisPublic
Niemals-public (bleibt ohne Feld, Encryption bleibt wie ist):
notes,dreams,journalEntries,contacts,memos,messagessleep*,mood*,periods,finance(transactions)drink*,stretch*,meditate*mailDrafts,chat.conversationsbody*,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/privacymit:- 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:
- Schema-Add:
visibility text default 'private',unlistedToken text,visibilityChangedAt,visibilityChangedBy - Dexie v+1
<VisibilityPicker>ins Detail-View einbauen- Store-Methoden (
setVisibility(id, level)) + Domain-Event - Publish-Resolver (falls Modul embedbar) auf
canEmbedOnWebsiteumstellen - Bestehende
isPublic-Records:visibility = 'public'wennisPublic === 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-privacyPackage: 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
libraryEntriesbekommt das Feld- UI: Lock-Icon + Picker im Library-Detail-View
- Store:
setVisibility - Publish-Resolver:
resolveLibraryEntriesfiltertvisibility='public'(statt heute nurisFavorite) - 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.visibilityergänzen- Legacy
isPublic: synthetischer Default bei Migration (isPublic=true → visibility='public') - Publish-Resolver (
resolvePictureBoard) vonif (!rawBoard.isPublic)aufcanEmbedOnWebsite - 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.goalsinembeds.ts moduleEmbed-Schema erweitern: neuesource-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.isPublicauf das Unified-Feld umstellenisPublic-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/privacySeite- 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.
-
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 aufguestCount: numberdegradiert. Pro-Feld-Freigabe (publicFields: string[]am Record) ist Phase 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. -
AI-Agent-Zugriff cross-user. Entschieden: Phase 1 nein, Predicate vorbereitet.
canAiAccessCrossUser(level)im@mana/shared-privacy-Package returnt immerfalse. Wenn später ein Feature cross-user-Reads will, wird's pro Modul explizit freigeschaltet. Fließt nicht ins MVP. -
Encryption-Registry-Update. Entschieden: Record bleibt encrypted, nur
visibilityist 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. -
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 einesiteIds-Filter am Embed-Block, nicht am Record. Record-Modell bleibt simpel; Komplexität lebt im Publish-Layer. -
Preview-Mode im Editor. Entschieden: Zwei explizite Modi, Toggle im Editor. "Bearbeiten" (Owner sieht alles, default) + "Als Besucher ansehen" (Embed-Filter werden angewandt).
previewAsPublic: booleanim EditorView-State, den Embed-Renderer respektieren. Implementierung hängt an M4. -
Default-Migration der 7 ad-hoc
isPublic-Flags. Entschieden: Strict Mapping, kein User-Prompt.isPublic === true→visibility='public'. Alles andere →visibility='private'(nichtspace, 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 aufprivatezurückdrehen. - Domain-Event:
VisibilityChangedmit 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
- Kein globaler Privacy-Boolean im User-Profil. Sowas wie "mein ganzer Account ist privat" ist redundant, wenn der Default eh
privateist und User per Record explizit zustimmen müssen. - Kein dynamisches Visibility-Propagate. Wenn ein Goal public wird, werden zugehörige Todos nicht automatisch public. Explizit pro Item.
- Kein Silent-Downgrade. Wenn ein public Item gelöscht wird, wird es gelöscht — nicht stillschweigend auf private gesetzt. User-Intent klar widerspiegeln.
- 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. - Keine Side-Channel-Leaks durch Counts.
/settings/privacyzeigt Counts pro Modul — muss aber nur für eigene Daten, keine cross-user-Daten. - Kein Inline-CSS im Public-Renderer für Privacy-Indicators. Klar abgegrenzt im Design-System, nicht ad-hoc.