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