managarten/.claude/guidelines/visibility.md
Till JS 49935c9628 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>
2026-04-24 01:59:11 +02:00

8.4 KiB

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):

// 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/):

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:

create index <table>_public_idx on <schema>.<table> (space_id)
  where visibility = 'public';

2. Local types

// 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

// 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

<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)

// 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:

// 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:

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