Write up the design for a repo-wide visibility layer before building. Today the state is fragmented: 7 modules carry ad-hoc isPublic booleans (picture, cards, presi, memoro, times, broadcast.audience, uload.tags) with inconsistent semantics and mostly no UI; the majority of modules (library, calendar, todo, places, events, recipes, goals, habits, quiz, wardrobe, invoices-clients, …) have nothing. Spaces only carry member permissions, no public tier. The existing encryption layer (27 encrypted tables) is not a blocker — embeds.ts already demonstrates "decrypt client-side, inline plaintext into the publish snapshot". Design: - @mana/shared-privacy package with `VisibilityLevel = 'private' | 'space' | 'unlisted' | 'public'`, a `<VisibilityPicker>` svelte component, and predicate helpers (canEmbedOnWebsite, isVisibleToSpaceMember, …) - Per-record `visibility text not null default 'private'` on public-capable tables only; `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy` alongside. Field stays plaintext so RLS + publish resolvers can read it without the user's master key - Default-per-space-type: personal → private, team/club → space. Never public/unlisted by default - Embed resolvers gate hard on `canEmbedOnWebsite`; user filters (tags, status, date window) stack on top, never replace - RLS predicate extended: `space_member OR visibility='public' OR (visibility='unlisted' AND unlisted_token matches header)` Rollout (soft-first / hard-follow-up per existing migration convention): M1 shared package · M2 library (pilot) · M3 picture (replaces isPublic) · M4 calendar + todo + goals · M5 places/events/recipes/habits/quiz/wardrobe /invoices · M6 legacy-flag consolidation · M7 /settings/privacy overview + kill-switch · M8 (optional) unlisted share links. Out of scope: per-user sharing, field-level visibility, visibility cascading, time-boxed public, search-indexing by default. Documented explicitly so the first implementer doesn't reopen these. No code yet — waiting on user go-ahead before starting M1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 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: leitet sich vom Space-Typ ab.
- Personal-Space →
private - Team/Club-Space →
space(sonst müssten User bei jedem Task manuell "meine Space-Mitglieder dürfen das sehen" setzen — unrealistisch) - Nie
publicoderunlistedals Default.
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): VisibilityLevel {
return spaceType === 'personal' ? 'private' : '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
Offene Designfragen
- Subressourcen-Redaction. Calendar-Event mit Gästen: werden die Gästenamen beim Publish redacted? Vermutung: ja, vom Publish-Resolver. Pro Modul entscheiden.
- Public-Items + Owner-Identität. Ist der Name des Owners auf einem public Item sichtbar? Vermutung: ja für Events (Veranstalter), nein für Todos (Creator ist irrelevant). Pro Resolver.
- AI-Agent-Zugriff. Darf ein User-AI-Agent auf public Items anderer User zugreifen (Future-Feature)? Per
canEmbedOnWebsite-ähnliches Predicate, aber ein neues:canAiAccessCrossUser. Nicht Phase 1. - Encryption-Registry-Update. Für Module, die das Feld bekommen: Record-Body wie gehabt encrypted (weil auch bei public-Items will man die Dexie-Backup-Exports verschlüsselt haben).
visibilityist das einzige plaintext-Feld. Publish-Flow bleibt "clientseitig entschlüsseln → inline". - Mehrere Websites pro Space. Plan-Doc
website-builder.mdgeht von 1 Website pro Space aus. Bei 2+ Websites per Space müsste Visibility pro-Website differenziert werden (visibleOnSite: siteId[]statt bool). Nicht Phase 1. - Preview-Mode im Editor. Der Editor rendert selbst ohne Filter (Owner sieht alles). Der Publish-Preview muss die Filter anwenden, damit der User weiß, was wirklich public geht. Kleines Feature, auf M4 hängen.
- Default-Migration. Beim Erstmigration der 7 ad-hoc-Flags: alle
isPublic=falsewerdenvisibility='private'. Das ist strikt — ein existierendes Private-Item bleibt privat. Aber: ein User, der bisher alles implizit behandelt hat ohne den Flag anzufassen, hat nichts public. OK-Verhalten.
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.