From d5ae2f19b41b7ad235b76911ab3581ffa0bd486d Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 02:08:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(library):=20M2=20=E2=80=94=20adopt=20unifi?= =?UTF-8?q?ed=20visibility=20system=20as=20the=20pilot=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First consumer of @mana/shared-privacy. Library entries now carry an explicit VisibilityLevel the owner can flip from the detail view via ; embed resolver gates hard on canEmbedOnWebsite so only entries the user marked 'public' appear on published websites. Replaces the M1/old flow — the library embed used to pass-through `filter.isFavorite` as a weak proxy for "show on my site". That filter still works as an additional user-facing filter, but it can no longer override the visibility gate (fixes a real leak: a favourited private book would have ended up on the public snapshot). Changes: - @mana/shared-privacy added to the web-app's dependency list - LocalLibraryEntry + LibraryEntry gain visibility / unlistedToken / visibilityChangedAt / visibilityChangedBy fields. Legacy rows (pre-migration) fall back to 'space' via the toLibraryEntry converter — matches the Dexie hook's existing structural default and maps to the space-foundation semantics unchanged - libraryEntriesStore.createEntry stamps defaultVisibilityFor(active space.type) explicitly so personal-space entries default to 'private' instead of the generic 'space' fallback - libraryEntriesStore.setVisibility(id, level): flips the field, mints/clears the unlisted token on the transition boundary, emits the cross-module VisibilityChanged domain event - Event catalog registers VisibilityChanged with the payload type re-exported from @mana/shared-privacy (kept under a dedicated "Visibility (Cross-Module)" section — this is the first of many modules that will emit it) - Library DetailView header gains the next to the kind-pill, so "who sees this?" is visible at a glance - embeds.ts resolveLibraryEntries replaces its favourite-proxy gate with canEmbedOnWebsite. User filters (kind/status/favorite) still stack on top but cannot relax the visibility requirement - ListView's inline-create EntryForm seed ships with visibility: 'private' so the type asserts cleanly and the preview entry matches the safe default No schema migration needed — the visibility column already exists on every space-scoped Dexie record (Spaces-Foundation v28). The Dexie hook's 'space' default still fires for rows the library store doesn't pre-populate (e.g. legacy paths); setVisibility and createEntry now own the intent. What's verified: - pnpm check (web): 7450 files, 0 errors, 0 warnings - pnpm test library + website: 23/23 passing - @mana/shared-privacy: 15/15 passing (re-ran after the dep pull) - pnpm run validate:all: theme-tokens, theme-parity, crypto-registry, encrypted-tools all green Next in the rollout: M3 Picture (swap the picture.board isPublic flag for visibility and update the board embed to use canEmbedOnWebsite). See docs/plans/visibility-system.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/package.json | 1 + .../apps/web/src/lib/data/events/catalog.ts | 18 ++++++- .../src/lib/modules/library/ListView.svelte | 1 + .../web/src/lib/modules/library/queries.ts | 4 ++ .../modules/library/stores/entries.svelte.ts | 48 +++++++++++++++++++ .../apps/web/src/lib/modules/library/types.ts | 20 ++++++++ .../modules/library/views/DetailView.svelte | 20 ++++++-- .../web/src/lib/modules/website/embeds.ts | 16 +++++-- pnpm-lock.yaml | 3 ++ 9 files changed, 121 insertions(+), 10 deletions(-) diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index cc19c636c..9d1a08bad 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -66,6 +66,7 @@ "@mana/shared-icons": "workspace:*", "@mana/shared-links": "workspace:*", "@mana/shared-llm": "workspace:*", + "@mana/shared-privacy": "workspace:*", "@mana/shared-stores": "workspace:*", "@mana/shared-tags": "workspace:*", "@mana/shared-tailwind": "workspace:*", diff --git a/apps/mana/apps/web/src/lib/data/events/catalog.ts b/apps/mana/apps/web/src/lib/data/events/catalog.ts index 8d006dfe7..84cb7f5f9 100644 --- a/apps/mana/apps/web/src/lib/data/events/catalog.ts +++ b/apps/mana/apps/web/src/lib/data/events/catalog.ts @@ -613,6 +613,17 @@ export type BodyEventType = | 'MeasurementLogged' | 'EnergyCheckLogged'; +// ── Visibility (Cross-Module) ─────────────────────── +// Emitted by any module whose records carry a `visibility` field when +// the user flips it (typically via ). The payload type +// lives in @mana/shared-privacy so the event shape stays aligned with +// the primitives. See docs/plans/visibility-system.md. + +import type { VisibilityChangedPayload } from '@mana/shared-privacy'; +export type { VisibilityChangedPayload }; + +export type VisibilityEventType = 'VisibilityChanged'; + // ── System Events (Goals, Companion) ──────────────── export interface GoalReachedPayload { @@ -667,7 +678,8 @@ export type ManaEventType = | CompanionEventType | SocialEventsEventType | BodyEventType - | SystemEventType; + | SystemEventType + | VisibilityEventType; /** * Discriminated union of all domain events. @@ -785,4 +797,6 @@ export type ManaEvent = | DomainEvent<'EnergyCheckLogged', EnergyCheckLoggedPayload> // System | DomainEvent<'GoalReached', GoalReachedPayload> - | DomainEvent<'GoalProgress', GoalProgressPayload>; + | DomainEvent<'GoalProgress', GoalProgressPayload> + // Visibility (cross-module) + | DomainEvent<'VisibilityChanged', VisibilityChangedPayload>; diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte index 52d53dec2..f6eea123b 100644 --- a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte @@ -109,6 +109,7 @@ : presetKind() === 'series' ? { kind: 'series', watched: [] } : { kind: 'comic' }, + visibility: 'private', createdAt: '', updatedAt: '', id: '', diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts index 2180a0d15..1de25cd1c 100644 --- a/apps/mana/apps/web/src/lib/modules/library/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts @@ -32,6 +32,10 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry { times: local.times ?? 0, externalIds: local.externalIds ?? null, details: local.details, + // Legacy rows pre-dating the visibility pilot default to 'space' + // (the pre-pilot stamp that Dexie hooks wrote). New rows get the + // space-type-aware default at create time in entries.svelte.ts. + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? now, updatedAt: local.updatedAt ?? now, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts index d680458fb..a1f9b40b9 100644 --- a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts @@ -7,6 +7,13 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import { libraryEntryTable } from '../collections'; import { toLibraryEntry } from '../queries'; import type { @@ -78,6 +85,10 @@ export const libraryEntriesStore = { times: 0, externalIds: input.externalIds ?? null, details, + // Pre-populate the visibility field so the Dexie hook's generic + // 'space' fallback doesn't fire for personal-space entries (which + // should default to 'private' per the unified visibility system). + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const snapshot = toLibraryEntry({ ...newLocal }); await encryptRecord('libraryEntries', newLocal); @@ -188,4 +199,41 @@ export const libraryEntriesStore = { }); emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id }); }, + + /** + * Flip the visibility of an entry. Mints an unlisted token on first + * transition to 'unlisted' and wipes it when moving back to anything + * else, so a revoked link can't be silently re-activated. Emits a + * cross-module `VisibilityChanged` event so the Workbench timeline + + * audit surfaces pick it up. + * + * No-op if the level is already what the user selected. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await libraryEntryTable.get(id); + if (!existing) throw new Error(`Library entry ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await libraryEntryTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, { + recordId: id, + collection: 'libraryEntries', + before, + after: next, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts index 2d6466642..6518d94af 100644 --- a/apps/mana/apps/web/src/lib/modules/library/types.ts +++ b/apps/mana/apps/web/src/lib/modules/library/types.ts @@ -6,6 +6,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; // ─── Discriminators & Enums ────────────────────────────── @@ -83,6 +84,24 @@ export interface LocalLibraryEntry extends BaseRecord { times: number; externalIds?: LibraryExternalIds | null; details: LibraryDetails; + /** + * Visibility level — pilot of the unified privacy system (see + * docs/plans/visibility-system.md). Optional on the local record + * because existing rows pre-date the field; the Dexie hook stamps + * 'space' as the structural default. `toLibraryEntry` narrows to a + * non-optional VisibilityLevel for callers. + */ + visibility?: VisibilityLevel; + /** ISO timestamp of the last visibility flip — useful for audit. */ + visibilityChangedAt?: string; + /** userId who made the last flip. */ + visibilityChangedBy?: string; + /** + * 32-char base64url token for unlisted-mode. Set when visibility is + * flipped to 'unlisted' and the record doesn't yet have one; cleared + * when visibility moves back to anything else. + */ + unlistedToken?: string; } // ─── Domain Type (plaintext, for UI) ───────────────────── @@ -107,6 +126,7 @@ export interface LibraryEntry { times: number; externalIds: LibraryExternalIds | null; details: LibraryDetails; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte index dfa11ec0d..6f1a2774d 100644 --- a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte @@ -1,5 +1,6 @@