From be611cd1ee533a9dcc2f1316cfd77847863e0f1c Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 21:44:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20phase=208=20=E2=80=94=20encrypt?= =?UTF-8?q?=20remaining=20tables=20(storage,=20picture,=20music,=20events,?= =?UTF-8?q?=20guests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last sweep of registry entries that were stuck on enabled:false. Each table is corrected to match the actual schema fields, then flipped on with writers + readers wrapped. Registry corrections + flips ---------------------------- - files: was ['name','originalName','notes'] → ['name','originalName'] LocalFile has no `notes` column. `name` IS indexed but no .where('name') call site exists in the app, so encryption is safe — the index just becomes a no-op for content lookups. - images: was ['prompt','negativePrompt','revisedPrompt','notes'] → ['prompt','negativePrompt']. Neither revisedPrompt nor notes exists on LocalImage. `prompt` is indexed, same caveat as files.name. - songs: was ['title','artist','album','lyrics','notes'] → ['title']. lyrics + notes don't exist; artist / album / albumArtist / genre stay PLAINTEXT so the album / artist / genre browsing views (which aggregate by those fields) don't have to decrypt the entire library on every render. - mukkePlaylists: kept ['name','description'], now flipped on - socialEvents: was ['title','description','notes'] → ['title','description','location'] (no notes column; location is the actually sensitive third field) - eventGuests: was ['name','email','phone','notes'] → ['name','email','phone','note'] (singular `note`, matching the schema) - manaLinks: REMOVED from registry entirely. Despite the name it's the cross-app foreign-key table — sourceAppId / sourceRecordId / targetAppId / targetRecordId — with zero user-typed content. The Phase 1 placeholder listed label/url/notes which don't exist. Storage (files) --------------- - storage/stores/files.svelte.ts: renameFile encrypts diff before fileTable.update. Other store ops touch only metadata (favorite / isDeleted / parent) so they stay unwrapped. - storage/queries.ts: useAllFiles decrypts before sort - storage/ListView.svelte (Workbench): same decrypt-before-render - storage/views/DetailView.svelte (inline editor binds to plaintext) - cross-app-queries.useStorageStats: decrypts only the recent slice (totalSize stays cheap because it reads plaintext .size) - search/providers/storage: decrypts before substring scoring - storage/trash/+page.svelte: decrypts the visible deleted set Picture (images) ---------------- - No client-side .add for images — they arrive purely via sync, so no store-level encryption to add. Reads are wrapped: - picture/queries.ts: useAllImages, useArchivedImages, allImages\$ - picture/ListView.svelte (uses prompt as alt text) - cross-app-queries.useRecentImages (dashboard widget renders prompt) - search/providers/picture: decrypts before substring scoring Sync-applied plaintext rows coexist with locally-edited ciphertext rows without issue — decryptRecord is per-row idempotent on non-encrypted strings. Music (songs + playlists) ------------------------- - music/stores/library.svelte.ts: updateMetadata + insert encrypt diffs before write - music/stores/playlists.svelte.ts: create snapshots plaintext for the return value before encryptRecord mutates the row, update encrypts diff - music/queries.ts: useAllSongs decrypts before title sort, useAllPlaylists decrypts before name sort - music/ListView.svelte (Workbench) - music/views/DetailView.svelte (inline editor) - cross-app-queries.useMusicStats decrypts only the recent slice - search/providers/music decrypts songs + playlists before scoring Events (social gatherings + guests) ----------------------------------- This one needed careful handling because publishEvent is the exception to the local-only confidentiality model — it intentionally pushes the event content to a public RSVP page anyone with the link can read. - events/stores/events.svelte.ts: - createEvent encrypts before .add - updateEvent encrypts the diff before .update - publishEvent + syncSnapshotIfPublished now DECRYPT the local row before forwarding to eventsApi.publish / .updateSnapshot — the server-side public snapshot needs plaintext, by design. The privacy contract is: drafts and unpublished events are encrypted at rest; the moment you publish, you accept that the content becomes readable via the share link. - events/stores/guests.svelte.ts: addGuest + updateGuest encrypt diff before write. Guests are NEVER pushed to the public snapshot, so no decrypt-before-publish path. - events/queries.ts: useAllEvents, useUpcomingEvents, usePastEvents, useEvent all decrypt the visible socialEvents rows before joining with timeBlocks. useGuestsByEvent + useEventGuests decrypt the eventGuests rows. Phase 8 is the last big sweep. The registry is now ~25 tables on, ~3 left intentionally off (manaLinks because no user content; boards / boardItems / dreamSymbols partially handled in earlier phases). The "what's encrypted?" surface should look complete on the settings/security page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/cross-app-queries.ts | 17 +++++-- .../apps/web/src/lib/data/crypto/registry.ts | 47 ++++++++++++++++--- .../web/src/lib/modules/events/queries.ts | 24 ++++++---- .../modules/events/stores/events.svelte.ts | 26 +++++++--- .../modules/events/stores/guests.svelte.ts | 6 +++ .../web/src/lib/modules/music/ListView.svelte | 8 ++-- .../apps/web/src/lib/modules/music/queries.ts | 16 +++---- .../modules/music/stores/library.svelte.ts | 8 +++- .../modules/music/stores/playlists.svelte.ts | 13 +++-- .../lib/modules/music/views/DetailView.svelte | 8 +++- .../src/lib/modules/picture/ListView.svelte | 4 +- .../web/src/lib/modules/picture/queries.ts | 16 ++++--- .../src/lib/modules/storage/ListView.svelte | 8 ++-- .../web/src/lib/modules/storage/queries.ts | 9 ++-- .../modules/storage/stores/files.svelte.ts | 7 ++- .../modules/storage/views/DetailView.svelte | 8 +++- .../web/src/lib/search/providers/music.ts | 17 ++++--- .../web/src/lib/search/providers/picture.ts | 9 ++-- .../web/src/lib/search/providers/storage.ts | 9 ++-- .../routes/(app)/storage/trash/+page.svelte | 8 +++- 20 files changed, 194 insertions(+), 74 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index 30310cc25..07c6102a7 100644 --- a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts @@ -165,13 +165,16 @@ export function useRecentImages(limit = 6) { // Reverse-walk the indexed updatedAt column. Generated images have // updatedAt stamped on creation and rarely move afterwards, so this // is effectively "newest first" for the dashboard widget's purpose. - return db + const recent = await db .table('images') .orderBy('updatedAt') .reverse() .filter((i) => !i.isArchived && !i.deletedAt) .limit(limit) .toArray(); + // prompt is encrypted on disk; the dashboard widget renders it as + // the alt text + caption, so decrypt the small slice we return. + return decryptRecords('images', recent); }, [] as LocalImage[]); } @@ -208,9 +211,13 @@ export function useStorageStats() { const files = await db.table('files').toArray(); const active = files.filter((f) => !f.isDeleted && !f.deletedAt); const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0); - const recent = active + // The recent-files widget renders the file name, so decrypt + // the small slice we return (not the whole table — totalSize + // only needs the plaintext .size column). + const recentRaw = active .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) .slice(0, 5); + const recent = await decryptRecords('files', recentRaw); return { totalFiles: active.length, totalSize, recentFiles: recent }; }, { totalFiles: 0, totalSize: 0, recentFiles: [] as LocalFile[] } @@ -234,9 +241,13 @@ export function useMusicStats() { const playlists = await db.table('mukkePlaylists').toArray(); const activeSongs = songs.filter((s) => !s.deletedAt); const activePlaylists = playlists.filter((p) => !p.deletedAt); - const recent = activeSongs + // title is encrypted on disk; the dashboard widget renders it + // for the recent-songs list, so decrypt the small slice we + // surface (counts only need plaintext flags). + const recentRaw = activeSongs .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) .slice(0, 5); + const recent = await decryptRecords('songs', recentRaw); return { totalSongs: activeSongs.length, totalPlaylists: activePlaylists.length, diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 277a5e093..17684bf06 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -152,14 +152,35 @@ export const ENCRYPTION_REGISTRY: Record = { documents: { enabled: true, fields: ['title', 'content'] }, // ─── Storage ───────────────────────────────────────────── - files: { enabled: false, fields: ['name', 'originalName', 'notes'] }, + // `name` IS indexed but no .where('name') call site exists in the + // app — encryption is safe, the index just becomes a no-op for + // content lookups (the file browser scans+filters in JS anyway). + // LocalFile has no `notes` column on the schema; the user-typed + // values are name (display name) + originalName (uploaded filename). + // mimeType / size / storagePath / checksum stay plaintext for the + // thumbnail + storage-layer code paths. + files: { enabled: true, fields: ['name', 'originalName'] }, // ─── Picture ───────────────────────────────────────────── - images: { enabled: false, fields: ['prompt', 'negativePrompt', 'revisedPrompt', 'notes'] }, + // LocalImage has prompt + negativePrompt as the user-typed text. + // The Phase 1 placeholder also listed `revisedPrompt` and `notes` + // but neither column exists on the schema. `prompt` IS indexed but + // no .where('prompt') call site exists — same as files.name above. + // model / style / format / blurhash stay plaintext (technical + // metadata, not user content). + images: { enabled: true, fields: ['prompt', 'negativePrompt'] }, // ─── Music ─────────────────────────────────────────────── - songs: { enabled: false, fields: ['title', 'artist', 'album', 'lyrics', 'notes'] }, - mukkePlaylists: { enabled: false, fields: ['name', 'description'] }, + // Music metadata is borderline-sensitive: technical ID3 tags vs + // user listening history. Encrypting `title` (which uniquely + // identifies a track) gives meaningful privacy; leaving artist / + // album / albumArtist / genre PLAINTEXT keeps the album+artist + // browsing views fast (they aggregate by those fields and would + // otherwise force a per-song decrypt to render the index). + // `lyrics` / `notes` listed in the Phase 1 placeholder don't + // exist on LocalSong. + songs: { enabled: true, fields: ['title'] }, + mukkePlaylists: { enabled: true, fields: ['name', 'description'] }, // ─── Questions ─────────────────────────────────────────── // LocalQuestion uses `title` + `description`; LocalAnswer uses @@ -170,8 +191,16 @@ export const ENCRYPTION_REGISTRY: Record = { answers: { enabled: true, fields: ['content'] }, // ─── Events (social gatherings) ────────────────────────── - socialEvents: { enabled: false, fields: ['title', 'description', 'notes'] }, - eventGuests: { enabled: false, fields: ['name', 'email', 'phone', 'notes'] }, + // Distinct from calendar.events — these have guest lists, RSVPs, + // and shareable invitation tokens. None of the encrypted columns + // are indexed (status / timeBlockId / hostContactId carry the + // browsing keys), so the rollout is straightforward. Phase 1 + // placeholder listed a `notes` column on socialEvents that doesn't + // exist; the actual user-typed text is title/description/location. + // On eventGuests the user-typed text is name/email/phone/note + // (singular). + socialEvents: { enabled: true, fields: ['title', 'description', 'location'] }, + eventGuests: { enabled: true, fields: ['name', 'email', 'phone', 'note'] }, // ─── Finance ───────────────────────────────────────────── // Transactions are budget-grade PII — amount/date/categoryId stay @@ -188,7 +217,11 @@ export const ENCRYPTION_REGISTRY: Record = { // metadata (title + description) which is the part the user actually // expects to be private, and leave the routing primitives alone. links: { enabled: true, fields: ['title', 'description'] }, - manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] }, + // NOTE: `manaLinks` is intentionally NOT in the registry. Despite + // the name it's the cross-app link table — pure foreign keys + // (sourceAppId / sourceRecordId / targetAppId / targetRecordId) + // with zero user-typed content. The Phase 1 placeholder listed + // label/url/notes which don't exist on the schema. // ─── Inventar ──────────────────────────────────────────── // `name` is indexed (used in where()/sortBy queries). `notes` is an diff --git a/apps/mana/apps/web/src/lib/modules/events/queries.ts b/apps/mana/apps/web/src/lib/modules/events/queries.ts index f419b694a..9fd09c744 100644 --- a/apps/mana/apps/web/src/lib/modules/events/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/events/queries.ts @@ -6,6 +6,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import { timeBlockTable } from '$lib/data/time-blocks/collections'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { @@ -85,7 +86,8 @@ export function toEventGuest(local: LocalEventGuest): EventGuest { export function useAllEvents() { return useLiveQueryWithDefault(async () => { const locals = await db.table('socialEvents').toArray(); - const active = locals.filter((e) => !e.deletedAt); + const visible = locals.filter((e) => !e.deletedAt); + const active = await decryptRecords('socialEvents', visible); const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId)); return active.map((e, i) => toSocialEvent(e, blocks[i] ?? null)); }, [] as SocialEvent[]); @@ -95,7 +97,8 @@ export function useAllEvents() { export function useUpcomingEvents() { return useLiveQueryWithDefault(async () => { const locals = await db.table('socialEvents').toArray(); - const active = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled'); + const visible = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled'); + const active = await decryptRecords('socialEvents', visible); const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId)); const now = Date.now(); return active @@ -109,7 +112,8 @@ export function useUpcomingEvents() { export function usePastEvents() { return useLiveQueryWithDefault(async () => { const locals = await db.table('socialEvents').toArray(); - const active = locals.filter((e) => !e.deletedAt); + const visible = locals.filter((e) => !e.deletedAt); + const active = await decryptRecords('socialEvents', visible); const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId)); const now = Date.now(); return active @@ -125,8 +129,9 @@ export function useEvent(eventId: () => string) { async () => { const id = eventId(); if (!id) return null; - const local = await db.table('socialEvents').get(id); - if (!local || local.deletedAt) return null; + const raw = await db.table('socialEvents').get(id); + if (!raw || raw.deletedAt) return null; + const [local] = await decryptRecords('socialEvents', [raw]); const block = await timeBlockTable.get(local.timeBlockId); return toSocialEvent(local, block ?? null); }, @@ -139,9 +144,10 @@ export function useGuestsByEvent() { return useLiveQueryWithDefault( async () => { const all = await db.table('eventGuests').toArray(); + const visible = all.filter((g) => !g.deletedAt); + const decrypted = await decryptRecords('eventGuests', visible); const map = new Map(); - for (const g of all) { - if (g.deletedAt) continue; + for (const g of decrypted) { const guest = toEventGuest(g); const arr = map.get(guest.eventId); if (arr) arr.push(guest); @@ -163,7 +169,9 @@ export function useEventGuests(eventId: () => string) { .where('eventId') .equals(id) .toArray(); - return guests.filter((g) => !g.deletedAt).map(toEventGuest); + const visible = guests.filter((g) => !g.deletedAt); + const decrypted = await decryptRecords('eventGuests', visible); + return decrypted.map(toEventGuest); }, [] as EventGuest[]); } diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts index f47b35347..1b8f4094c 100644 --- a/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts @@ -8,6 +8,7 @@ import { db } from '$lib/data/database'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { timeBlockTable } from '$lib/data/time-blocks/collections'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import type { LocalSocialEvent, LocalEventItem, EventStatus } from '../types'; import { eventsApi } from '../api'; import { recordTombstone } from '../tombstones'; @@ -68,6 +69,9 @@ export const eventsStore = { updatedAt: new Date().toISOString(), }; + // title / description / location are encrypted at rest. The + // linked TimeBlock was already encrypted by createBlock above. + await encryptRecord('socialEvents', newLocal); await db.table('socialEvents').add(newLocal); return { success: true as const, id: eventId }; } catch (e) { @@ -121,6 +125,7 @@ export const eventsStore = { if (input.status !== undefined) localData.status = input.status; if (input.coverImage !== undefined) localData.coverImage = input.coverImage; + await encryptRecord('socialEvents', localData); await db.table('socialEvents').update(id, localData); // Fire-and-forget snapshot sync if this event is published void this.syncSnapshotIfPublished(id); @@ -167,11 +172,17 @@ export const eventsStore = { async publishEvent(id: string) { error = null; try { - const event = await db.table('socialEvents').get(id); - if (!event) return { success: false as const, error: 'Event not found' }; - const block = await timeBlockTable.get(event.timeBlockId); + const rawEvent = await db.table('socialEvents').get(id); + if (!rawEvent) return { success: false as const, error: 'Event not found' }; + const block = await timeBlockTable.get(rawEvent.timeBlockId); if (!block) return { success: false as const, error: 'TimeBlock missing for event' }; + // Decrypt before pushing to the server snapshot — the public + // RSVP page renders these fields, so the server needs the + // plaintext. By design, publishing intentionally trades local + // confidentiality for the linkable public page. + const event = await decryptRecord('socialEvents', { ...rawEvent }); + const { token } = await eventsApi.publish({ eventId: id, title: event.title, @@ -236,10 +247,13 @@ export const eventsStore = { */ async syncSnapshotIfPublished(id: string) { try { - const event = await db.table('socialEvents').get(id); - if (!event || !event.isPublished) return; - const block = await timeBlockTable.get(event.timeBlockId); + const rawEvent = await db.table('socialEvents').get(id); + if (!rawEvent || !rawEvent.isPublished) return; + const block = await timeBlockTable.get(rawEvent.timeBlockId); if (!block) return; + // Same plaintext-snapshot dance as publishEvent — the public + // page would otherwise render ciphertext blobs. + const event = await decryptRecord('socialEvents', { ...rawEvent }); await eventsApi.updateSnapshot(id, { eventId: id, title: event.title, diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts index 545ca880f..fce43f15d 100644 --- a/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts @@ -3,6 +3,7 @@ */ import { db } from '$lib/data/database'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalEventGuest, RsvpStatus } from '../types'; let error = $state(null); @@ -39,6 +40,10 @@ export const eventGuestsStore = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; + // name / email / phone / note are encrypted at rest. Guest + // records stay local-only — they're never pushed to the + // public RSVP snapshot, so no decrypt-before-publish here. + await encryptRecord('eventGuests', newGuest); await db.table('eventGuests').add(newGuest); return { success: true as const, id }; } catch (e) { @@ -68,6 +73,7 @@ export const eventGuestsStore = { if (input.rsvpStatus !== undefined) { data.rsvpAt = new Date().toISOString(); } + await encryptRecord('eventGuests', data); await db.table('eventGuests').update(id, data); return { success: true as const }; } catch (e) { diff --git a/apps/mana/apps/web/src/lib/modules/music/ListView.svelte b/apps/mana/apps/web/src/lib/modules/music/ListView.svelte index 7f939ec76..cf44d5533 100644 --- a/apps/mana/apps/web/src/lib/modules/music/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/music/ListView.svelte @@ -5,6 +5,7 @@