feat(shared-privacy): M1 — visibility foundation package

Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).

What ships:
- @mana/shared-privacy package
  - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
  - VisibilityLevelSchema + UnlistedTokenSchema (zod)
  - defaultVisibilityFor(spaceType): personal → private, else → space
  - predicates: canEmbedOnWebsite, isReachableByLink,
    isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
  - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
  - VISIBILITY_METADATA: German labels + descriptions + phosphor icon
    names so non-UI surfaces (audit logs, CLI) label levels consistently
  - <VisibilityPicker> svelte component: compact lock/globe trigger with
    4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
  registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
  (schema migrations + store wiring + picker placement + embed resolver +
  legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
  with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
  listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
  embed; phase-1 AI always-deny), defaults (personal vs multi-member,
  null fallback), token uniqueness + schema round-trip

Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
  so RLS predicates and publish resolvers can read it without the user's
  master key
- Publish flow remains "decrypt client-side, inline plaintext into
  snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
  type defaults to private; cross-user AI always false)

Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 01:59:11 +02:00
parent 91fd88e77d
commit 49935c9628
16 changed files with 1100 additions and 148 deletions

View file

@ -0,0 +1,242 @@
# Visibility — Adding Visibility Control to a Module
How to adopt the unified visibility/privacy system for a module. Applies to every public-capable module (see `docs/plans/visibility-system.md` for the full list).
## TL;DR
Adding visibility control needs edits in **five places**:
1. **Dexie + mana-sync schema** — add `visibility`, `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy` columns
2. **`types.ts`** — add `visibility: VisibilityLevel` to the local record type + converter
3. **`stores/*.svelte.ts`** — add `setVisibility()` method + stamp default on create
4. **Detail-View `.svelte`** — drop `<VisibilityPicker>` into the header
5. **Embed resolver (if applicable)** — gate on `canEmbedOnWebsite`
Legacy `isPublic` flags are migrated in the same PR: `isPublic=true → visibility='public'`, else `'private'`.
## Architecture
```
┌──────────────────────────────────────────────────────────┐
@mana/shared-privacy │
│ VisibilityLevel = 'private'|'space'|'unlisted'|'public' │
│ VisibilityLevelSchema (zod) │
│ defaultVisibilityFor(spaceType) │
│ canEmbedOnWebsite / isReachableByLink / … (predicates) │
│ generateUnlistedToken() │
<VisibilityPicker>
└───────────┬─────────────────────────────────┬────────────┘
│ │
┌────────▼────────┐ ┌─────────▼──────────────┐
│ Module stores │ │ website/embeds.ts │
│ - setVisibility │ │ - filter by predicate │
│ - default on │ │ - decrypt + inline │
│ create │ │ matching records │
└──────────────────┘ └─────────────────────────┘
┌────────▼────────────┐
│ Detail-View │
<VisibilityPicker
│ level={...} │
│ onChange={...} /> │
└──────────────────────┘
```
`visibility` stays **plaintext** (not in the encryption registry) so RLS predicates and publish resolvers can read it without the user's master key.
## Step-by-step
### 1. Schema
**Dexie** — bump the module's table in `apps/mana/apps/web/src/lib/data/database.ts` (soft-migration):
```ts
// v{N+1}: add visibility
{
version: N + 1,
stores: {
myTable: 'id, spaceId, visibility, createdAt, …', // add visibility to indexes if you'll query on it
},
upgrade: async (tx) => {
await tx.table('myTable').toCollection().modify((r) => {
r.visibility = 'private';
});
},
}
```
**Postgres** — add columns to the mana-sync schema for this module (find the drizzle schema under `services/mana-sync/` or the per-module schema in `apps/api/`):
```sql
alter table <schema>.<table>
add column visibility text not null default 'private',
add column unlisted_token text,
add column visibility_changed_at timestamptz,
add column visibility_changed_by text;
```
Partial index for the common embed query:
```sql
create index <table>_public_idx on <schema>.<table> (space_id)
where visibility = 'public';
```
### 2. Local types
```ts
// types.ts
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface LocalMyRecord {
id: string;
// ... existing fields ...
visibility: VisibilityLevel;
unlistedToken?: string;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
}
```
Update `toMyRecord()` converter in `queries.ts` to forward the fields.
### 3. Store
```ts
// stores/my.svelte.ts
import {
VisibilityLevelSchema,
defaultVisibilityFor,
generateUnlistedToken,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { emitDomainEvent } from '$lib/data/events';
import { activeSpace } from '$lib/stores/space.svelte';
export const myStore = {
async createRecord(input: CreateInput) {
const now = new Date().toISOString();
const record: LocalMyRecord = {
id: crypto.randomUUID(),
// ...
visibility: defaultVisibilityFor(activeSpace.current?.type),
createdAt: now,
updatedAt: now,
};
await myTable.add(record);
return record;
},
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await myTable.get(id);
if (!existing) throw new Error(`Record ${id} not found`);
const before = existing.visibility;
if (before === next) return;
const now = new Date().toISOString();
const patch: Partial<LocalMyRecord> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: currentUserId(),
updatedAt: now,
};
// Mint a fresh token on first transition to unlisted; wipe on leaving.
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined;
}
await myTable.update(id, patch);
emitDomainEvent('VisibilityChanged', '<appId>', '<collection>', id, {
recordId: id,
collection: '<collection>',
before,
after: next,
});
},
};
```
Register the `VisibilityChanged` event type in `apps/mana/apps/web/src/lib/data/events/catalog.ts` once (not per-module); the payload comes from `@mana/shared-privacy`.
### 4. Detail-view UI
```svelte
<script lang="ts">
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { myStore } from '../stores/my.svelte';
import type { MyRecord } from '../types';
let { record }: { record: MyRecord } = $props();
async function onVisibilityChange(next: VisibilityLevel) {
await myStore.setVisibility(record.id, next);
}
</script>
<header>
<h1>{record.title}</h1>
<VisibilityPicker level={record.visibility} onChange={onVisibilityChange} />
</header>
```
For list views, show a small lock/globe icon next to items whose visibility differs from the space default. `VISIBILITY_METADATA[level].icon` gives the Phosphor icon name.
### 5. Embed resolver (only for public-embeddable modules)
```ts
// apps/mana/apps/web/src/lib/modules/website/embeds.ts
import { canEmbedOnWebsite } from '@mana/shared-privacy';
async function resolveMyModule(props): Promise<EmbedItem[]> {
let records = await db.table('myTable').toArray();
records = records.filter((r) => !r.deletedAt && canEmbedOnWebsite(r.visibility ?? 'private'));
// User filters (tags, status, date window) stack ON TOP — never replace.
// ...
const decrypted = await decryptRecords('myTable', records);
return decrypted.map(toEmbedItem);
}
```
Register the new source in `packages/website-blocks/src/moduleEmbed/schema.ts` under `EmbedSourceSchema`.
### 6. Legacy `isPublic` migration (if your module had one)
In the same PR that adds visibility:
```ts
// Dexie upgrade step
await tx
.table('myTable')
.toCollection()
.modify((r) => {
r.visibility = r.isPublic === true ? 'public' : 'private';
delete r.isPublic;
});
```
And the corresponding Postgres migration:
```sql
update <schema>.<table> set visibility = 'public' where is_public = true;
alter table <schema>.<table> drop column is_public;
```
## Checklist before PR
- [ ] Dexie schema bumped, upgrade step migrates existing rows to `'private'` (or maps `isPublic`)
- [ ] Postgres migration adds `visibility`, `unlisted_token`, `visibility_changed_at`, `visibility_changed_by`
- [ ] Partial index on `(space_id) where visibility = 'public'` (if module is embeddable)
- [ ] `visibility` **not** added to the encryption registry
- [ ] Default on create via `defaultVisibilityFor(space.type)`
- [ ] Store has `setVisibility()` + emits `VisibilityChanged`
- [ ] `<VisibilityPicker>` in the detail view
- [ ] Embed resolver (if applicable) gates on `canEmbedOnWebsite`
- [ ] Tests: store `setVisibility` flips correctly; resolver filters out non-public records
- [ ] `validate:all` passes
## Reference
- Plan + rationale: [`docs/plans/visibility-system.md`](../../docs/plans/visibility-system.md)
- Package: `packages/shared-privacy/`