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)