From af92720a62640cb49fa2be148427cad10938eaa5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 19:28:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web):=20encryption=20phase=205=20?= =?UTF-8?q?=E2=80=94=20rollout=20to=20chat/dreams/memoro/contacts/cycles/f?= =?UTF-8?q?inance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six modules join the notes pilot (Phase 4) on the encrypted-at-rest path. Every user-typed text and PII field listed below is now wrapped via AES-GCM-256 with the per-user master key before any write hits Dexie, and decrypted on every liveQuery read coming back through the public queries module. Tables flipped to enabled:true in the registry - chat.messages messageText - chat.conversations title - chat.chatTemplates name + description + systemPrompt + initialQuestion - dreams.dreams title + content + transcript + interpretation + aiInterpretation + location - dreams.dreamSymbols meaning (name stays plaintext — used as indexed lookup key in touchSymbols / updateSymbol via where('name')) - memoro.memos title + intro + transcript - memoro.memories title + content - contacts.contacts firstName + lastName + email + phone + mobile + birthday + street + city + postalCode + country + notes + website + linkedin + twitter + instagram + github - cycles.cycles notes - cycles.cycleDayLogs notes + mood (symptoms stays plaintext — standardised label array consumed by symptomsStore.touchSymptoms via Set diffs in dayLogsStore.logDay) - finance.transactions description + note (the schema uses `note` singular, not `notes` or `merchant` as my earlier draft had it) Tables intentionally left disabled - questions / answers — direct db.table().update() call sites in DetailView.svelte instead of going through a store. Need a store extraction first; registry entry stays in place so the flip is a one-line change once the store exists. - tasks, events, calendar.events, plants, meals, slides, presiDecks, cards, links, etc. — fall through to a future Phase 6 once the chat/dreams/memoro/contacts pilots are validated in real use. Per-module changes Each store now follows the same pattern the notes pilot established: 1. Build the LocalRecord with plaintext fields 2. Snapshot it via toX() for the optimistic UI return value 3. await encryptRecord(tableName, record) // mutates in place 4. await table.add(record) // ciphertext lands on disk For updates the diff is encrypted in place before the update() call so partial updates only encrypt the modified fields. The transcribeBlob flows in dreams + memoro decrypt the existing record first (to read the user-typed `content`), then build a diff and re-encrypt it. Same for contactsStore.ensureSelfContact which compares against decrypted-existing values to decide whether the profile-sync needs an update. Per-module query changes Each public liveQuery now filters on plaintext metadata (deletedAt, isArchived, etc.) FIRST, then runs decryptRecords on the visible set, then maps to the public type. Cost stays bounded by what the view actually renders, not the total table size. cross-app-queries.ts useFavoriteContacts decrypts firstName before the localeCompare sort. Test fixes - aes.test.ts: the "registry returns null for disabled tables" assertion now picks tasks + events as the disabled examples (messages + contacts both flipped on in this commit). - cycles.integration.test.ts: 1. beforeEach installs a fresh MemoryKeyProvider with a real Web Crypto key so dayLogsStore.logDay can encrypt mood/notes 2. The "no duplicate" upsert test decrypts the raw rows it reads directly from the table before asserting on the mood field - module-registry.test.ts (drive-by, unrelated): adds eventItems to the events appId snapshot to match the parallel module-registry refactor. Verified: 20 test files, 262/262 tests passing. Phase 6 will roll out to the remaining tables (tasks, events, plants, meals, slides, etc.) and finally light up the settings/security UI (lock state, manual rotate, recovery code opt-in). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/cross-app-queries.ts | 23 +- .../apps/web/src/lib/data/crypto/aes.test.ts | 12 +- .../apps/web/src/lib/data/crypto/registry.ts | 48 +++- .../web/src/lib/data/module-registry.test.ts | 220 ++++++++++++++++++ .../apps/web/src/lib/modules/chat/queries.ts | 47 ++-- .../chat/stores/conversations.svelte.ts | 17 +- .../modules/chat/stores/messages.svelte.ts | 23 +- .../modules/chat/stores/templates.svelte.ts | 11 +- .../web/src/lib/modules/contacts/queries.ts | 8 +- .../contacts/stores/contacts.svelte.ts | 29 ++- .../web/src/lib/modules/cycles/queries.ts | 28 ++- .../cycles/stores/cycles.integration.test.ts | 18 +- .../modules/cycles/stores/cycles.svelte.ts | 11 +- .../modules/cycles/stores/dayLogs.svelte.ts | 18 +- .../web/src/lib/modules/dreams/queries.ts | 32 +-- .../modules/dreams/stores/dreams.svelte.ts | 47 +++- .../web/src/lib/modules/finance/queries.ts | 9 +- .../modules/finance/stores/finance.svelte.ts | 16 +- .../web/src/lib/modules/memoro/queries.ts | 23 +- .../modules/memoro/stores/memories.svelte.ts | 11 +- .../lib/modules/memoro/stores/memos.svelte.ts | 17 +- 21 files changed, 537 insertions(+), 131 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/module-registry.test.ts 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 d6cfed1d6..56a983054 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 @@ -98,15 +98,20 @@ export function useFavoriteContacts(limit = 5) { return useLiveQueryWithDefault(async () => { // Dexie indexes booleans as `true`/`false` keys — `.where().equals(true)` // hits the index instead of scanning every contact in the address book. - const favorites = await db - .table('contacts') - .where('isFavorite') - .equals(1) - .or('isFavorite') - .equals(true as unknown as string) - .toArray(); - return favorites - .filter((c) => !c.isArchived && !c.deletedAt) + const favorites = ( + await db + .table('contacts') + .where('isFavorite') + .equals(1) + .or('isFavorite') + .equals(true as unknown as string) + .toArray() + ).filter((c) => !c.isArchived && !c.deletedAt); + // Decrypt firstName/lastName before sorting — they're encrypted + // in Phase 5 and the sort needs the plaintext to compare. + const { decryptRecords } = await import('./crypto'); + const decrypted = await decryptRecords('contacts', favorites); + return decrypted .sort((a, b) => (a.firstName ?? '').localeCompare(b.firstName ?? '')) .slice(0, limit); }, [] as LocalContact[]); diff --git a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts index 1fdbba3cf..f25cc0ffd 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts @@ -250,15 +250,17 @@ describe('encryption registry', () => { }); it('returns null for registered tables that are disabled', () => { - // `notes` was flipped to enabled:true in Phase 4 — pick two - // tables that are still on the safe default for the assertion. - expect(getEncryptedFields('messages')).toBe(null); - expect(getEncryptedFields('contacts')).toBe(null); + // Phase 5 flipped most user-content tables to enabled. Pick two + // that are still on the safe default for the assertion: tasks + // and events both have a registry entry but enabled:false. + expect(getEncryptedFields('tasks')).toBe(null); + expect(getEncryptedFields('events')).toBe(null); }); it('returns the field list for tables that are enabled', () => { - // Phase 4: notes is the pilot, expected to be flipped on. + // Phase 4 + 5: notes pilot plus the chat/dreams/memoro/contacts rollout. expect(getEncryptedFields('notes')).toEqual(['title', 'content']); + expect(getEncryptedFields('messages')).toEqual(['messageText']); }); it('hasAnyEncryption returns true once at least one table is enabled', () => { 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 4aba474fd..2847766ec 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -39,10 +39,11 @@ export interface EncryptionConfig { export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── - messages: { enabled: false, fields: ['messageText'] }, - conversations: { enabled: false, fields: ['title'] }, + // Phase 5: messageText is the highest-value target in the entire app. + messages: { enabled: true, fields: ['messageText'] }, + conversations: { enabled: true, fields: ['title'] }, chatTemplates: { - enabled: false, + enabled: true, fields: ['name', 'description', 'systemPrompt', 'initialQuestion'], }, @@ -52,16 +53,27 @@ export const ENCRYPTION_REGISTRY: Record = { notes: { enabled: true, fields: ['title', 'content'] }, // ─── Dreams ────────────────────────────────────────────── - dreams: { enabled: false, fields: ['title', 'content', 'notes'] }, - dreamSymbols: { enabled: false, fields: ['name', 'meaning'] }, + // LocalDream uses content + transcript + interpretation, no `notes`. + dreams: { + enabled: true, + fields: ['title', 'content', 'transcript', 'interpretation', 'aiInterpretation', 'location'], + }, + // Symbol `name` stays plaintext — it's used as the unique lookup key + // in touchSymbols / updateSymbol via where('name').equals(...). Only + // the user-written `meaning` (which is the actually sensitive part) + // is encrypted. + dreamSymbols: { enabled: true, fields: ['meaning'] }, // ─── Memoro ────────────────────────────────────────────── - memos: { enabled: false, fields: ['title', 'intro', 'transcript'] }, - memories: { enabled: false, fields: ['title', 'content'] }, + // Voice transcripts are typically the largest plaintext blobs in the + // whole app — encrypting them yields the biggest disk-footprint win + // of any single field. + memos: { enabled: true, fields: ['title', 'intro', 'transcript'] }, + memories: { enabled: true, fields: ['title', 'content'] }, // ─── Contacts ──────────────────────────────────────────── contacts: { - enabled: false, + enabled: true, fields: [ 'firstName', 'lastName', @@ -89,8 +101,14 @@ export const ENCRYPTION_REGISTRY: Record = { events: { enabled: false, fields: ['title', 'description', 'location'] }, // ─── Cycles ────────────────────────────────────────────── - cycles: { enabled: false, fields: ['notes'] }, - cycleDayLogs: { enabled: false, fields: ['notes', 'symptoms', 'mood'] }, + // Health data — GDPR Art. 9 sensitive personal data category. + // `symptoms` stays plaintext: it's a string-array of standardised + // labels (cramps, headache, ...) used as a Set in the symptom + // counter store; encrypting it would break the diff loop in + // dayLogsStore.logDay. `mood` is a single enum but with the same + // privacy sensitivity as `notes` — encrypt it. + cycles: { enabled: true, fields: ['notes'] }, + cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] }, // ─── NutriPhi ──────────────────────────────────────────── meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] }, @@ -120,6 +138,10 @@ export const ENCRYPTION_REGISTRY: Record = { mukkePlaylists: { enabled: false, fields: ['name', 'description'] }, // ─── Questions ─────────────────────────────────────────── + // Writes from views are not yet routed through a store — registry + // is set so future store creation gets encryption automatically; + // existing direct db.table().update() call sites in the views need + // to migrate to a store before they actually flow through encryptRecord. questions: { enabled: false, fields: ['title', 'body', 'notes'] }, answers: { enabled: false, fields: ['body'] }, @@ -128,7 +150,11 @@ export const ENCRYPTION_REGISTRY: Record = { eventGuests: { enabled: false, fields: ['name', 'email', 'phone', 'notes'] }, // ─── Finance ───────────────────────────────────────────── - transactions: { enabled: false, fields: ['description', 'notes', 'merchant'] }, + // Transactions are budget-grade PII — amount/date/categoryId stay + // plaintext for indexing + aggregation, only the user-typed text + // fields (description + note) are encrypted. The schema uses + // `note` (singular), not `notes` or `merchant`. + transactions: { enabled: true, fields: ['description', 'note'] }, // ─── uLoad ─────────────────────────────────────────────── links: { enabled: false, fields: ['title', 'description', 'targetUrl'] }, diff --git a/apps/mana/apps/web/src/lib/data/module-registry.test.ts b/apps/mana/apps/web/src/lib/data/module-registry.test.ts new file mode 100644 index 000000000..2e6305828 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/module-registry.test.ts @@ -0,0 +1,220 @@ +/** + * Module registry validation tests. + * + * These tests are the safety net behind the per-module config refactor: + * if someone adds a new sync table to a module config but forgets to add + * the matching index in `database.ts` (or vice versa), one of these tests + * fails loudly instead of letting sync silently drop the table. + * + * The "snapshot" tests pin the *exact* registry shape that existed before + * the refactor. Any intentional change to a module's tables / sync names + * should update both the module config AND the corresponding entry below + * in the same commit — this makes such changes visible in code review. + */ + +import 'fake-indexeddb/auto'; +import { describe, it, expect, vi } from 'vitest'; + +// Same Dexie-hook side-effect stubs as sync.test.ts so importing +// database.ts doesn't pull in unrelated runtime modules. +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { + SYNC_APP_MAP, + TABLE_TO_SYNC_NAME, + TABLE_TO_APP, + SYNC_NAME_TO_TABLE, + toSyncName, + fromSyncName, + MODULE_CONFIGS, +} from './module-registry'; +import { db } from './database'; + +// ─── Internal Dexie tables that are intentionally NOT in SYNC_APP_MAP ─── +// These hold local-only state (sync metadata, retry queues, activity log) +// that must never leave the device. +const INTERNAL_TABLES = new Set(['_pendingChanges', '_syncMeta', '_eventsTombstones', '_activity']); + +describe('module-registry — structural invariants', () => { + it('every appId is unique across module configs', () => { + const seen = new Set(); + for (const mod of MODULE_CONFIGS) { + expect(seen.has(mod.appId), `duplicate appId: ${mod.appId}`).toBe(false); + seen.add(mod.appId); + } + }); + + it('every table name is owned by exactly one module', () => { + const owners = new Map(); + for (const mod of MODULE_CONFIGS) { + for (const t of mod.tables) { + const existing = owners.get(t.name); + expect( + existing, + `table "${t.name}" registered by both "${existing}" and "${mod.appId}"` + ).toBeUndefined(); + owners.set(t.name, mod.appId); + } + } + }); + + it('TABLE_TO_APP covers every registered table', () => { + for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { + for (const t of tables) { + expect(TABLE_TO_APP[t], `${t} missing from TABLE_TO_APP`).toBe(appId); + } + } + }); + + it('TABLE_TO_SYNC_NAME never maps a table to itself (would be a no-op)', () => { + for (const [unified, sync] of Object.entries(TABLE_TO_SYNC_NAME)) { + expect(unified, `${unified} maps to itself in TABLE_TO_SYNC_NAME`).not.toBe(sync); + } + }); + + it('toSyncName / fromSyncName round-trip for every renamed table', () => { + for (const [unified] of Object.entries(TABLE_TO_SYNC_NAME)) { + const appId = TABLE_TO_APP[unified]; + expect(appId, `no appId for ${unified}`).toBeDefined(); + const sync = toSyncName(unified); + expect(fromSyncName(appId, sync)).toBe(unified); + } + }); + + it('SYNC_NAME_TO_TABLE has an entry for every sync app', () => { + for (const appId of Object.keys(SYNC_APP_MAP)) { + expect(SYNC_NAME_TO_TABLE[appId]).toBeDefined(); + } + }); +}); + +describe('module-registry — Dexie schema alignment', () => { + it('every sync-tracked table exists as a real Dexie table', () => { + const dexieTables = new Set(db.tables.map((t) => t.name)); + for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { + for (const t of tables) { + expect( + dexieTables.has(t), + `SYNC_APP_MAP[${appId}] references "${t}" but no such Dexie table exists` + ).toBe(true); + } + } + }); + + it('every Dexie table is either internal or registered with an appId', () => { + const registered = new Set(Object.keys(TABLE_TO_APP)); + for (const t of db.tables) { + if (INTERNAL_TABLES.has(t.name)) continue; + expect( + registered.has(t.name), + `Dexie table "${t.name}" is not registered in any module.config.ts — sync will silently skip it` + ).toBe(true); + } + }); +}); + +// ─── Snapshot of the registry shape ─────────────────────────────── +// +// This is the exact set of (appId → tables) and (unified → sync) mappings +// that the legacy hardcoded blocks in database.ts had pre-refactor. If you +// intentionally change a module's sync surface, update the matching entry +// here in the same commit so the change is reviewable. + +describe('module-registry — pre-refactor snapshot', () => { + it('SYNC_APP_MAP matches the legacy hardcoded shape', () => { + expect(SYNC_APP_MAP).toEqual({ + mana: ['userSettings', 'dashboardConfigs', 'automations'], + todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'], + calendar: ['calendars', 'events', 'eventTags'], + contacts: ['contacts', 'contactTags'], + chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'], + picture: ['images', 'boards', 'boardItems', 'imageTags'], + cards: ['cardDecks', 'cards', 'deckTags'], + zitare: ['zitareFavorites', 'zitareLists', 'zitareListTags'], + music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'], + storage: ['files', 'storageFolders', 'fileTags'], + presi: ['presiDecks', 'slides', 'presiDeckTags'], + inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'], + photos: ['albums', 'albumItems', 'photoFavorites', 'photoMediaTags'], + skilltree: ['skills', 'activities', 'achievements', 'skillTags'], + citycorners: ['cities', 'ccLocations', 'ccFavorites', 'ccLocationTags'], + times: [ + 'timeClients', + 'timeProjects', + 'timeEntries', + 'timeTemplates', + 'timeSettings', + 'timeAlarms', + 'timeCountdownTimers', + 'timeWorldClocks', + 'entryTags', + ], + context: ['contextSpaces', 'documents', 'documentTags'], + questions: ['qCollections', 'questions', 'answers', 'questionTags'], + nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'], + planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'], + uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'], + calc: ['calculations', 'savedFormulas'], + moodlit: ['moods', 'sequences', 'moodTags'], + memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'], + guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'], + habits: ['habits', 'habitLogs'], + notes: ['notes', 'noteTags'], + dreams: ['dreams', 'dreamSymbols', 'dreamTags'], + cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'], + events: ['socialEvents', 'eventGuests', 'eventInvitations', 'eventItems'], + finance: ['transactions', 'financeCategories', 'budgets'], + places: ['places', 'locationLogs', 'placeTags'], + tags: ['globalTags', 'tagGroups'], + links: ['manaLinks'], + timeblocks: ['timeBlocks', 'timeBlockTags'], + }); + }); + + it('TABLE_TO_SYNC_NAME matches the legacy hardcoded shape', () => { + expect(TABLE_TO_SYNC_NAME).toEqual({ + todoProjects: 'projects', + chatTemplates: 'templates', + cardDecks: 'decks', + zitareFavorites: 'favorites', + zitareLists: 'lists', + mukkePlaylists: 'playlists', + mukkeProjects: 'projects', + storageFolders: 'folders', + presiDecks: 'decks', + invCollections: 'collections', + invItems: 'items', + invLocations: 'locations', + invCategories: 'categories', + photoFavorites: 'favorites', + photoMediaTags: 'photoTags', + ccLocations: 'locations', + ccFavorites: 'favorites', + timeClients: 'clients', + timeProjects: 'projects', + timeTemplates: 'templates', + timeSettings: 'settings', + timeAlarms: 'alarms', + timeCountdownTimers: 'countdownTimers', + timeWorldClocks: 'worldClocks', + contextSpaces: 'spaces', + qCollections: 'collections', + nutriFavorites: 'favorites', + memoroSpaces: 'spaces', + uloadTags: 'tags', + uloadFolders: 'folders', + guideCollections: 'collections', + financeCategories: 'categories', + socialEvents: 'events', + globalTags: 'tags', + // `tagGroups` is intentionally absent — it has no rename in the registry + // (the legacy hardcoded block had a redundant tagGroups→tagGroups entry + // which was a no-op; toSyncName() returns the same value either way). + manaLinks: 'links', + }); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/chat/queries.ts b/apps/mana/apps/web/src/lib/modules/chat/queries.ts index 050fdbe67..5788fe2a5 100644 --- a/apps/mana/apps/web/src/lib/modules/chat/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/chat/queries.ts @@ -1,9 +1,15 @@ /** * Reactive queries & pure helpers for Chat — uses Dexie liveQuery on the unified DB. + * + * Phase 5 encryption: messageText, conversation title, and template + * fields (name/description/systemPrompt/initialQuestion) are encrypted + * at rest. liveQueries decrypt the configured fields before mapping + * to the public types so consumers see plaintext. */ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalConversation, LocalMessage, @@ -63,19 +69,22 @@ export function toMessage(local: LocalMessage): Message { /** All non-archived conversations, sorted by pinned first then updatedAt desc. */ export function useAllConversations() { return liveQuery(async () => { - const locals = await db.table('conversations').toArray(); - return sortConversations( - locals.filter((c) => !c.deletedAt && !c.isArchived).map(toConversation) + const visible = (await db.table('conversations').toArray()).filter( + (c) => !c.deletedAt && !c.isArchived ); + const decrypted = await decryptRecords('conversations', visible); + return sortConversations(decrypted.map(toConversation)); }); } /** All archived conversations, sorted by updatedAt desc. */ export function useArchivedConversations() { return liveQuery(async () => { - const locals = await db.table('conversations').toArray(); - return locals - .filter((c) => !c.deletedAt && c.isArchived) + const visible = (await db.table('conversations').toArray()).filter( + (c) => !c.deletedAt && c.isArchived + ); + const decrypted = await decryptRecords('conversations', visible); + return decrypted .map(toConversation) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }); @@ -84,24 +93,26 @@ export function useArchivedConversations() { /** All templates, sorted by name. */ export function useAllTemplates() { return liveQuery(async () => { - const locals = await db.table('chatTemplates').toArray(); - return locals - .filter((t) => !t.deletedAt) - .map(toTemplate) - .sort((a, b) => a.name.localeCompare(b.name)); + const visible = (await db.table('chatTemplates').toArray()).filter( + (t) => !t.deletedAt + ); + const decrypted = await decryptRecords('chatTemplates', visible); + return decrypted.map(toTemplate).sort((a, b) => a.name.localeCompare(b.name)); }); } /** Messages for a specific conversation, sorted by createdAt asc. */ export function useConversationMessages(conversationId: string) { return liveQuery(async () => { - const locals = await db - .table('messages') - .where('conversationId') - .equals(conversationId) - .toArray(); - return locals - .filter((m) => !m.deletedAt) + const visible = ( + await db + .table('messages') + .where('conversationId') + .equals(conversationId) + .toArray() + ).filter((m) => !m.deletedAt); + const decrypted = await decryptRecords('messages', visible); + return decrypted .map(toMessage) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }); diff --git a/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts b/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts index 88625406f..848daac16 100644 --- a/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts @@ -10,6 +10,7 @@ import { conversationTable, messageTable } from '../collections'; import { toConversation } from '../queries'; import { createArchiveOps } from '@mana/shared-stores'; import { ChatEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalConversation } from '../types'; /** Archive/soft-delete ops for conversations. */ @@ -38,25 +39,31 @@ export const conversationsStore = { isArchived: false, isPinned: false, }; + const plaintextSnapshot = toConversation(newLocal); + await encryptRecord('conversations', newLocal); await conversationTable.add(newLocal); ChatEvents.conversationCreated(); - return toConversation(newLocal); + return plaintextSnapshot; }, /** Update a conversation's fields. */ async update(id: string, updates: Partial) { - await conversationTable.update(id, { + const diff: Partial = { ...updates, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('conversations', diff); + await conversationTable.update(id, diff); }, /** Update conversation title. */ async updateTitle(id: string, title: string) { - await conversationTable.update(id, { + const diff: Partial = { title, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('conversations', diff); + await conversationTable.update(id, diff); }, // Archive ops (delegated to shared factory) diff --git a/apps/mana/apps/web/src/lib/modules/chat/stores/messages.svelte.ts b/apps/mana/apps/web/src/lib/modules/chat/stores/messages.svelte.ts index 28632548e..2e87de339 100644 --- a/apps/mana/apps/web/src/lib/modules/chat/stores/messages.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/chat/stores/messages.svelte.ts @@ -3,11 +3,19 @@ * * Reads come from liveQuery hooks in queries.ts. * This store handles adding/deleting messages in IndexedDB. + * + * Phase 5 encryption: messageText is encrypted at rest. Streaming + * updateText() re-encrypts on every chunk — that means a 1 KB + * assistant reply produces ~50 wrap operations during the stream. + * Web Crypto AES-GCM is fast enough that this is sub-millisecond + * each, but we wrap the diff via encryptRecord() so the call site + * stays declarative. */ import { messageTable, conversationTable } from '../collections'; import { toMessage } from '../queries'; import { ChatEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalMessage } from '../types'; export const messagesStore = { @@ -19,13 +27,16 @@ export const messagesStore = { sender: 'user', messageText: text, }; + // Plaintext snapshot for the optimistic UI return value. + const plaintextSnapshot = toMessage(newLocal); + await encryptRecord('messages', newLocal); await messageTable.add(newLocal); // Touch the conversation's updatedAt await conversationTable.update(conversationId, { updatedAt: new Date().toISOString(), }); ChatEvents.messageSent(); - return toMessage(newLocal); + return plaintextSnapshot; }, /** Add an assistant message to a conversation. */ @@ -36,19 +47,23 @@ export const messagesStore = { sender: 'assistant', messageText: text, }; + const plaintextSnapshot = toMessage(newLocal); + await encryptRecord('messages', newLocal); await messageTable.add(newLocal); await conversationTable.update(conversationId, { updatedAt: new Date().toISOString(), }); - return toMessage(newLocal); + return plaintextSnapshot; }, /** Update a message's text (e.g., during streaming). */ async updateText(id: string, text: string) { - await messageTable.update(id, { + const diff: Partial = { messageText: text, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('messages', diff); + await messageTable.update(id, diff); }, /** Soft-delete a message. */ diff --git a/apps/mana/apps/web/src/lib/modules/chat/stores/templates.svelte.ts b/apps/mana/apps/web/src/lib/modules/chat/stores/templates.svelte.ts index 113619ad3..b345bc561 100644 --- a/apps/mana/apps/web/src/lib/modules/chat/stores/templates.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/chat/stores/templates.svelte.ts @@ -7,6 +7,7 @@ import { chatTemplateTable } from '../collections'; import { toTemplate } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalTemplate } from '../types'; export const templatesStore = { @@ -32,8 +33,10 @@ export const templatesStore = { isDefault: data.isDefault ?? false, documentMode: data.documentMode ?? false, }; + const plaintextSnapshot = toTemplate(newLocal); + await encryptRecord('chatTemplates', newLocal); await chatTemplateTable.add(newLocal); - return toTemplate(newLocal); + return plaintextSnapshot; }, /** Update a template. */ @@ -53,10 +56,12 @@ export const templatesStore = { > > ) { - await chatTemplateTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('chatTemplates', diff); + await chatTemplateTable.update(id, diff); }, /** Soft-delete a template. */ diff --git a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts index f6879d7c5..91f82ad4f 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts @@ -4,6 +4,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalContact, Contact, SortField, ContactFilter } from './types'; // ─── Type Converter ─────────────────────────────────────── @@ -48,8 +49,11 @@ export function toContact(local: LocalContact): Contact { export function useAllContacts() { return liveQuery(async () => { - const locals = await db.table('contacts').toArray(); - return locals.filter((c) => !c.deletedAt).map(toContact); + const visible = (await db.table('contacts').toArray()).filter( + (c) => !c.deletedAt + ); + const decrypted = await decryptRecords('contacts', visible); + return decrypted.map(toContact); }); } diff --git a/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts index e05573534..268c7f47a 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts @@ -9,6 +9,7 @@ import { contactTable, SELF_CONTACT_ID } from '../collections'; import { toContact } from '../queries'; import { createArchiveOps } from '@mana/shared-stores'; import { ContactsEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import type { LocalContact, Contact } from '../types'; import type { UserProfile } from '$lib/api/profile'; @@ -43,9 +44,11 @@ export const contactsStore = { isArchived: false, }; + const plaintextSnapshot = toContact(newLocal); + await encryptRecord('contacts', newLocal); await contactTable.add(newLocal); ContactsEvents.contactCreated(); - return toContact(newLocal); + return plaintextSnapshot; }, async updateContact(id: string, data: Partial & Record) { @@ -74,10 +77,12 @@ export const contactsStore = { if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite; if (data.isArchived !== undefined) updateData.isArchived = data.isArchived; - await contactTable.update(id, { + const diff: Partial = { ...updateData, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('contacts', diff); + await contactTable.update(id, diff); ContactsEvents.contactUpdated(); }, @@ -136,6 +141,7 @@ export const contactsStore = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; + await encryptRecord('contacts', self); await contactTable.add(self); return; } @@ -143,20 +149,25 @@ export const contactsStore = { // Only sync if we have profile data if (!profile) return; + // Compare against the decrypted view of the existing record so the + // "did anything change?" check sees plaintext on both sides. + const decryptedExisting = await decryptRecord('contacts', { ...existing }); const needsUpdate = - existing.firstName !== firstName || - existing.lastName !== lastName || - existing.email !== (profile.email || undefined) || - existing.photoUrl !== (profile.image || undefined); + decryptedExisting.firstName !== firstName || + decryptedExisting.lastName !== lastName || + decryptedExisting.email !== (profile.email || undefined) || + decryptedExisting.photoUrl !== (profile.image || undefined); if (needsUpdate) { - await contactTable.update(SELF_CONTACT_ID, { + const diff: Partial = { firstName, lastName, email: profile.email || undefined, photoUrl: profile.image || undefined, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('contacts', diff); + await contactTable.update(SELF_CONTACT_ID, diff); } }, }; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/queries.ts b/apps/mana/apps/web/src/lib/modules/cycles/queries.ts index b541fc1aa..2428ebdc2 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/queries.ts @@ -4,6 +4,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecord, decryptRecords } from '$lib/data/crypto'; import type { Cycle, CycleDayLog, @@ -64,11 +65,11 @@ export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom { export function useAllCycles() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('cycles').toArray(); - return locals - .filter((c) => !c.deletedAt && !c.isArchived) - .map(toCycle) - .sort((a, b) => b.startDate.localeCompare(a.startDate)); + const visible = (await db.table('cycles').toArray()).filter( + (c) => !c.deletedAt && !c.isArchived + ); + const decrypted = await decryptRecords('cycles', visible); + return decrypted.map(toCycle).sort((a, b) => b.startDate.localeCompare(a.startDate)); }, [] as Cycle[]); } @@ -79,7 +80,8 @@ export function useCurrentCycle() { const real = locals.filter((c) => !c.deletedAt && !c.isArchived && !c.isPredicted); if (real.length === 0) return null; const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; - return toCycle(latest); + const decrypted = await decryptRecord('cycles', { ...latest }); + return toCycle(decrypted); }, null as Cycle | null ); @@ -87,11 +89,11 @@ export function useCurrentCycle() { export function useAllDayLogs() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('cycleDayLogs').toArray(); - return locals - .filter((l) => !l.deletedAt) - .map(toCycleDayLog) - .sort((a, b) => b.logDate.localeCompare(a.logDate)); + const visible = (await db.table('cycleDayLogs').toArray()).filter( + (l) => !l.deletedAt + ); + const decrypted = await decryptRecords('cycleDayLogs', visible); + return decrypted.map(toCycleDayLog).sort((a, b) => b.logDate.localeCompare(a.logDate)); }, [] as CycleDayLog[]); } @@ -104,7 +106,9 @@ export function useDayLog(date: string) { .equals(date) .toArray(); const active = locals.find((l) => !l.deletedAt); - return active ? toCycleDayLog(active) : null; + if (!active) return null; + const decrypted = await decryptRecord('cycleDayLogs', { ...active }); + return toCycleDayLog(decrypted); }, null as CycleDayLog | null ); diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts index e95e7d78d..7bb6c031e 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.integration.test.ts @@ -21,6 +21,12 @@ vi.mock('$lib/triggers/inline-suggest', () => ({ import { db } from '$lib/data/database'; import { setCurrentUserId } from '$lib/data/current-user'; +import { + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + decryptRecords, +} from '$lib/data/crypto'; import { cyclesStore } from './cycles.svelte'; import { dayLogsStore } from './dayLogs.svelte'; import { symptomsStore } from './symptoms.svelte'; @@ -42,6 +48,13 @@ async function resetCyclesTables() { beforeEach(async () => { setCurrentUserId('test-user'); + // Phase 5 cycles encryption requires an unlocked vault — install a + // real Web Crypto key in a fresh MemoryKeyProvider for each test + // run so the dayLogsStore.logDay calls below can encrypt notes/mood. + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); await resetCyclesTables(); }); @@ -113,7 +126,10 @@ describe('dayLogsStore.logDay — upsert behavior', () => { await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' }); await dayLogsStore.logDay({ logDate: '2026-04-07', temperature: 36.6 }); - const logs = (await dayLogTable().toArray()).filter((l) => !l.deletedAt); + // Phase 5: `mood` is encrypted on disk — decrypt before asserting + // so the test reads the same view the UI does. + const raw = (await dayLogTable().toArray()).filter((l) => !l.deletedAt); + const logs = await decryptRecords('cycleDayLogs', raw); expect(logs).toHaveLength(1); expect(logs[0].flow).toBe('light'); expect(logs[0].mood).toBe('good'); diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts index 49ecda707..e25da80f3 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts @@ -5,6 +5,7 @@ import { cycleTable } from '../collections'; import { toCycle } from '../queries'; import { daysBetween } from '../utils/phase'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalCycle } from '../types'; function todayIsoDate(): string { @@ -48,8 +49,10 @@ export const cyclesStore = { isArchived: false, notes: data.notes ?? null, }; + const plaintextSnapshot = toCycle(newLocal); + await encryptRecord('cycles', newLocal); await cycleTable.add(newLocal); - return toCycle(newLocal); + return plaintextSnapshot; }, async updateCycle( @@ -61,10 +64,12 @@ export const cyclesStore = { > > ) { - await cycleTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('cycles', diff); + await cycleTable.update(id, diff); }, /** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */ diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts index c231250b8..d5c683a6f 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts @@ -7,6 +7,7 @@ import { toCycle, toCycleDayLog } from '../queries'; import { detectPeriodEnd, shouldStartNewCycle } from '../utils/auto-detect'; import { cyclesStore } from './cycles.svelte'; import { symptomsStore } from './symptoms.svelte'; +import { encryptRecord } from '$lib/data/crypto'; import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types'; function todayIsoDate(): string { @@ -63,11 +64,15 @@ export const dayLogsStore = { if (added.length) await symptomsStore.touchSymptoms(added, +1); if (removed.length) await symptomsStore.touchSymptoms(removed, -1); } - await cycleDayLogTable.update(existing.id, { + const updateDiff: Partial = { ...data, logDate, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('cycleDayLogs', updateDiff); + await cycleDayLogTable.update(existing.id, updateDiff); + // `result` keeps the plaintext for the return value — caller + // expects to render the input back. result = { ...existing, ...data, logDate }; } else { const cycleId = await resolveCycleId(logDate); @@ -84,11 +89,14 @@ export const dayLogsStore = { sexualActivity: data.sexualActivity ?? null, notes: data.notes ?? null, }; + // Plaintext copy retained for the return value — what we + // write to disk is encrypted. + result = { ...newLocal }; + await encryptRecord('cycleDayLogs', newLocal); await cycleDayLogTable.add(newLocal); - if (newLocal.symptoms.length) { - await symptomsStore.touchSymptoms(newLocal.symptoms, +1); + if (result.symptoms.length) { + await symptomsStore.touchSymptoms(result.symptoms, +1); } - result = newLocal; } // ─ Auto-End: Wenn explizit 'none' geloggt wurde, prüfe ob die Periode beendet werden soll diff --git a/apps/mana/apps/web/src/lib/modules/dreams/queries.ts b/apps/mana/apps/web/src/lib/modules/dreams/queries.ts index 3c5473130..ec79af708 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/queries.ts @@ -1,9 +1,14 @@ /** * Reactive Queries & Pure Helpers for Dreams module. + * + * Phase 5: dream content fields are encrypted at rest. liveQueries + * filter on plaintext metadata first (deletedAt, isArchived) and + * then decryptRecords the visible set before mapping to public types. */ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { Dream, DreamSymbol, LocalDream, LocalDreamSymbol } from './types'; // ─── Type Converters ─────────────────────────────────────── @@ -56,24 +61,25 @@ export function toDreamSymbol(local: LocalDreamSymbol): DreamSymbol { export function useAllDreams() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('dreams').toArray(); - return locals - .filter((d) => !d.deletedAt && !d.isArchived) - .map(toDream) - .sort((a, b) => { - if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; - return b.dreamDate.localeCompare(a.dreamDate); - }); + const visible = (await db.table('dreams').toArray()).filter( + (d) => !d.deletedAt && !d.isArchived + ); + const decrypted = await decryptRecords('dreams', visible); + return decrypted.map(toDream).sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.dreamDate.localeCompare(a.dreamDate); + }); }, [] as Dream[]); } export function useAllDreamSymbols() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('dreamSymbols').toArray(); - return locals - .filter((s) => !s.deletedAt) - .map(toDreamSymbol) - .sort((a, b) => b.count - a.count); + const visible = (await db.table('dreamSymbols').toArray()).filter( + (s) => !s.deletedAt + ); + // Only `meaning` is encrypted; `name` stays plaintext for indexed lookups. + const decrypted = await decryptRecords('dreamSymbols', visible); + return decrypted.map(toDreamSymbol).sort((a, b) => b.count - a.count); }, [] as DreamSymbol[]); } diff --git a/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts b/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts index 88e6d98e1..f3d2e72d5 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts @@ -1,9 +1,16 @@ /** * Dreams Store — Mutation-Only Service + * + * Phase 5 encryption: title, content, transcript, interpretation, + * aiInterpretation, location are encrypted at rest. Symbol metadata + * (dreamSymbols.meaning) is encrypted; symbol `name` stays plaintext + * because it's used as the unique lookup key in touchSymbols / + * updateSymbol via where('name').equals(...). */ import { dreamSymbolTable, dreamTable } from '../collections'; import { toDream } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; import type { Dream, DreamClarity, @@ -56,9 +63,15 @@ export const dreamsStore = { isArchived: false, }; + const plaintextSnapshot = toDream(newLocal); + await encryptRecord('dreams', newLocal); await dreamTable.add(newLocal); - await this.touchSymbols(newLocal.symbols, +1); - return toDream(newLocal); + // touchSymbols receives plaintext names — must run BEFORE the + // snapshot mutation above doesn't matter because newLocal.symbols + // is a non-encrypted field, but use the snapshot's symbols just + // to be explicit about what we're feeding the symbol counter. + await this.touchSymbols(plaintextSnapshot.symbols, +1); + return plaintextSnapshot; }, async updateDream( @@ -100,10 +113,12 @@ export const dreamsStore = { } } - await dreamTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('dreams', diff); + await dreamTable.update(id, diff); }, /** @@ -139,12 +154,14 @@ export const dreamsStore = { isPinned: false, isArchived: false, }; + const plaintextSnapshot = toDream(newLocal); + await encryptRecord('dreams', newLocal); await dreamTable.add(newLocal); // Fire and forget — transcription updates the dream when it returns. void this.transcribeBlob(newLocal.id, blob, language); - return toDream(newLocal); + return plaintextSnapshot; }, async setProcessingStatus( @@ -194,14 +211,22 @@ export const dreamsStore = { const existing = await dreamTable.get(dreamId); if (!existing) return; - await dreamTable.update(dreamId, { + // `existing.content` may be ciphertext at this point — we need + // the plaintext to decide whether to overwrite. Decrypt the + // existing record first, then check the user-typed content. + const { decryptRecord } = await import('$lib/data/crypto'); + const decryptedExisting = await decryptRecord('dreams', { ...existing }); + + const diff: Partial = { transcript, // Only fill content if user hasn't typed anything yet - content: existing.content?.trim() ? existing.content : transcript, + content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript, processingStatus: 'idle', processingError: null, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('dreams', diff); + await dreamTable.update(dreamId, diff); } catch (e) { const msg = e instanceof Error ? e.message : String(e); await dreamTable.update(dreamId, { @@ -286,11 +311,13 @@ export const dreamsStore = { } } - await dreamSymbolTable.update(id, { + const symbolDiff: Record = { ...data, ...(data.name ? { name: data.name.trim() } : {}), updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('dreamSymbols', symbolDiff); + await dreamSymbolTable.update(id, symbolDiff); }, /** Soft-delete a symbol and remove it from all dreams that reference it. */ diff --git a/apps/mana/apps/web/src/lib/modules/finance/queries.ts b/apps/mana/apps/web/src/lib/modules/finance/queries.ts index d018f64cd..44262238e 100644 --- a/apps/mana/apps/web/src/lib/modules/finance/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/finance/queries.ts @@ -4,6 +4,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalTransaction, LocalFinanceCategory, @@ -44,9 +45,11 @@ export function toCategory(local: LocalFinanceCategory): FinanceCategory { export function useAllTransactions() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('transactions').toArray(); - return locals - .filter((t) => !t.deletedAt) + const visible = (await db.table('transactions').toArray()).filter( + (t) => !t.deletedAt + ); + const decrypted = await decryptRecords('transactions', visible); + return decrypted .map(toTransaction) .sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)); }, [] as Transaction[]); diff --git a/apps/mana/apps/web/src/lib/modules/finance/stores/finance.svelte.ts b/apps/mana/apps/web/src/lib/modules/finance/stores/finance.svelte.ts index 2ff2c5733..ce7651eb8 100644 --- a/apps/mana/apps/web/src/lib/modules/finance/stores/finance.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/finance/stores/finance.svelte.ts @@ -1,9 +1,15 @@ /** * Finance Store — Mutation-Only Service + * + * Phase 5 encryption: transaction `description` and `note` are + * encrypted at rest. Amount, date, type, categoryId all stay + * plaintext so chart/aggregation queries continue to work without + * decryption. */ import { transactionTable, categoryTable } from '../collections'; import { toTransaction, toCategory } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalTransaction, LocalFinanceCategory, TransactionType } from '../types'; export const financeStore = { @@ -25,8 +31,10 @@ export const financeStore = { note: data.note ?? null, }; + const plaintextSnapshot = toTransaction(newLocal); + await encryptRecord('transactions', newLocal); await transactionTable.add(newLocal); - return toTransaction(newLocal); + return plaintextSnapshot; }, async updateTransaction( @@ -35,10 +43,12 @@ export const financeStore = { Pick > ) { - await transactionTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('transactions', diff); + await transactionTable.update(id, diff); }, async deleteTransaction(id: string) { diff --git a/apps/mana/apps/web/src/lib/modules/memoro/queries.ts b/apps/mana/apps/web/src/lib/modules/memoro/queries.ts index 2f45775ae..034f58d7e 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/queries.ts @@ -4,6 +4,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalMemo, LocalMemory, @@ -60,17 +61,22 @@ export function toSpace(local: LocalSpace): Space { /** All non-archived memos, sorted by pinned first then createdAt desc. */ export function useAllMemos() { return liveQuery(async () => { - const locals = await db.table('memos').toArray(); - return sortMemos(locals.filter((m) => !m.deletedAt && !m.isArchived).map(toMemo)); + const visible = (await db.table('memos').toArray()).filter( + (m) => !m.deletedAt && !m.isArchived + ); + const decrypted = await decryptRecords('memos', visible); + return sortMemos(decrypted.map(toMemo)); }); } /** All archived memos, sorted by updatedAt desc. */ export function useArchivedMemos() { return liveQuery(async () => { - const locals = await db.table('memos').toArray(); - return locals - .filter((m) => !m.deletedAt && m.isArchived) + const visible = (await db.table('memos').toArray()).filter( + (m) => !m.deletedAt && m.isArchived + ); + const decrypted = await decryptRecords('memos', visible); + return decrypted .map(toMemo) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }); @@ -79,8 +85,11 @@ export function useArchivedMemos() { /** Memories for a specific memo. */ export function useMemoriesByMemo(memoId: string) { return liveQuery(async () => { - const locals = await db.table('memories').where('memoId').equals(memoId).toArray(); - return locals.filter((m) => !m.deletedAt).map(toMemory); + const visible = ( + await db.table('memories').where('memoId').equals(memoId).toArray() + ).filter((m) => !m.deletedAt); + const decrypted = await decryptRecords('memories', visible); + return decrypted.map(toMemory); }); } diff --git a/apps/mana/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts index 856427c1b..28c62886f 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/stores/memories.svelte.ts @@ -8,6 +8,7 @@ import { memoryTable } from '../collections'; import { toMemory } from '../queries'; import { MemoroEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalMemory } from '../types'; export const memoriesStore = { @@ -19,17 +20,21 @@ export const memoriesStore = { title: data.title, content: data.content ?? null, }; + const plaintextSnapshot = toMemory(newLocal); + await encryptRecord('memories', newLocal); await memoryTable.add(newLocal); MemoroEvents.memoCreated(); - return toMemory(newLocal); + return plaintextSnapshot; }, /** Update a memory. */ async update(id: string, data: Partial>) { - await memoryTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('memories', diff); + await memoryTable.update(id, diff); }, /** Soft-delete a memory. */ diff --git a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts index cfe577fd9..78d7d9e23 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -9,6 +9,7 @@ import { memoTable } from '../collections'; import { toMemo } from '../queries'; import { createArchiveOps } from '@mana/shared-stores'; import { MemoroEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalMemo } from '../types'; /** Archive/soft-delete ops for memos. */ @@ -39,9 +40,11 @@ export const memosStore = { blueprintId: data.blueprintId ?? null, language: data.language ?? null, }; + const plaintextSnapshot = toMemo(newLocal); + await encryptRecord('memos', newLocal); await memoTable.add(newLocal); MemoroEvents.memoCreated(); - return toMemo(newLocal); + return plaintextSnapshot; }, /** @@ -95,12 +98,14 @@ export const memosStore = { const existing = await memoTable.get(memoId); if (!existing) return; - await memoTable.update(memoId, { + const diff: Partial = { transcript, language: existing.language ?? result.language ?? null, processingStatus: 'completed', updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('memos', diff); + await memoTable.update(memoId, diff); } catch (e) { const msg = e instanceof Error ? e.message : String(e); await memoTable.update(memoId, { @@ -116,10 +121,12 @@ export const memosStore = { id: string, data: Partial> ) { - await memoTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('memos', diff); + await memoTable.update(id, diff); }, // Archive ops (delegated to shared factory)