From 73f294b29860361cd5cae3b050bd1982336f1206 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 19:44:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web):=20encryption=20phase=206.1=20?= =?UTF-8?q?=E2=80=94=20cards,=20presi,=20inventar,=20planta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more modules join the encrypted-at-rest path. Tables flipped: - cards.cards front + back (no `notes` column on LocalCard) - cards.cardDecks name + description (schema uses `name` not `title`) - presi.presiDecks title + description - presi.slides content (LocalSlide has only the SlideContent object — no separate `notes`. The JSON-stringify in wrapValue handles nested-object content cleanly) - inventar.invItems description (only — `name` is in the schema index used by where()/sortBy queries, and `notes` is an array of {id, content, createdAt} that addNote/deleteNote splice in place; encrypting either would force per-mutation decrypt+ re-encrypt of the whole array. Phase 7 concern.) - planta.plants name + careNotes + temperature + soilType (`name` is NOT indexed for plants — the schema only indexes id/isActive/healthStatus, so it's safe to encrypt unlike inventar/dreamSymbols) Per-module mutations Each store now follows the established Phase 4/5 pattern: - createX: build LocalRecord, snapshot via toX() for the optimistic return, encryptRecord, then table.add - updateX: build diff, encryptRecord on the diff, then table.update - The Sprint 1 atomic-cascade deleteDeck (cards + presi) is unchanged because deletes only touch plaintext deletedAt/updatedAt fields. planta.update() reads the row back after the write to return a Plant to its caller; that read goes through decryptRecord because the raw row is now encrypted on disk. Per-module queries useAllDecks / useDeck / useCardsByDeck (cards) useAllDecks / useDeck / useDeckSlides (presi) useAllItems (inventar) useAllPlants (planta) All filter on plaintext metadata first, then decryptRecords on the visible set. cross-app-queries dashboard widgets - useRecentDecks (presi) decrypts the title/description before the dashboard widget renders the deck name - useCardsProgress decrypts the deck name list — counts continue to work on plaintext fields Skipped intentionally - tasks / calendar.events / habits — title is duplicated to the cross-module timeBlocks table. Encrypting only the task copy would still leak the title via the timeBlock. Needs a coordinated timeBlocks encryption pass (Phase 6.1.5). - picture.images / storage.files / music.songs — records are server-pushed (image generation, file uploads, library imports). Client-side encryptRecord can't help; needs the API service to encrypt before pushing, or a sync-time wrap step. Documented as a Phase 7 concern. - nutriphi.meals / uload.links / context.documents / questions / answers — write directly from views, no store. Need a store extraction first. Verified: 20 test files, 262/262 tests passing. Pre-existing TS errors in context/index.ts, picture/images.svelte.ts, planta/ quick-input-adapter.ts and questions/index.ts are unrelated parallel refactor drift. Phase 6.2 next: settings/security UI showing vault status, encrypted- table list, manual rotate button. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/cross-app-queries.ts | 12 +++++++-- .../apps/web/src/lib/data/crypto/registry.ts | 27 ++++++++++++++----- .../apps/web/src/lib/modules/cards/queries.ts | 21 ++++++++------- .../lib/modules/cards/stores/cards.svelte.ts | 11 +++++--- .../lib/modules/cards/stores/decks.svelte.ts | 11 +++++--- .../web/src/lib/modules/inventar/queries.ts | 6 +++-- .../modules/inventar/stores/items.svelte.ts | 11 +++++--- .../web/src/lib/modules/planta/mutations.ts | 13 +++++++-- .../web/src/lib/modules/planta/queries.ts | 6 +++-- .../apps/web/src/lib/modules/presi/queries.ts | 20 +++++++------- .../lib/modules/presi/stores/decks.svelte.ts | 11 ++++++-- 11 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index 56a983054..70d5dea17 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 @@ -248,13 +248,17 @@ export function useMusicStats() { /** Recent presentation decks. */ export function useRecentDecks(limit = 5) { return useLiveQueryWithDefault(async () => { - return db + const visible = await db .table('presiDecks') .orderBy('updatedAt') .reverse() .filter((d) => !d.deletedAt) .limit(limit) .toArray(); + // Phase 6: presiDecks title/description encrypted — decrypt for the + // dashboard widget so the user sees the deck name, not a blob. + const { decryptRecords } = await import('./crypto'); + return decryptRecords('presiDecks', visible); }, [] as LocalPresiDeck[]); } @@ -306,12 +310,16 @@ export function useCardsProgress() { const activeCards = cards.filter((c) => !c.deletedAt); const now = new Date().toISOString(); const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now); + // Phase 6: cardDecks.name is encrypted — the widget renders the + // deck names so they need decryption. Counts work plaintext. + const { decryptRecords } = await import('./crypto'); + const decryptedDecks = await decryptRecords('cardDecks', activeDecks); return { totalDecks: activeDecks.length, totalCards: activeCards.length, cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length, dueForReview: dueCards.length, - decks: activeDecks, + decks: decryptedDecks, }; }, { 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 2847766ec..c1f190dd7 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -114,15 +114,24 @@ export const ENCRYPTION_REGISTRY: Record = { meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] }, // ─── Planta ────────────────────────────────────────────── - plants: { enabled: false, fields: ['name', 'notes', 'careNotes'] }, + // `name` is NOT in the schema index for plants (only isActive + + // healthStatus), so encrypting it is safe. LocalPlant uses + // `careNotes` (no separate `notes`) plus the user-typed metadata. + plants: { enabled: true, fields: ['name', 'careNotes', 'temperature', 'soilType'] }, // ─── Cards ─────────────────────────────────────────────── - cards: { enabled: false, fields: ['front', 'back', 'notes'] }, - cardDecks: { enabled: false, fields: ['title', 'description'] }, + // `cards` has no `notes` column on LocalCard — only front + back are + // user content. cardDecks uses `name` (not `title`) on the schema + // even though the public DTO translates it to `title`. + cards: { enabled: true, fields: ['front', 'back'] }, + cardDecks: { enabled: true, fields: ['name', 'description'] }, // ─── Presi ─────────────────────────────────────────────── - presiDecks: { enabled: false, fields: ['title', 'description'] }, - slides: { enabled: false, fields: ['content', 'notes'] }, + // LocalSlide only has `content` (SlideContent object) — no separate + // notes column on the schema. JSON-stringify in wrapValue handles + // the nested object cleanly. + presiDecks: { enabled: true, fields: ['title', 'description'] }, + slides: { enabled: true, fields: ['content'] }, // ─── Context ───────────────────────────────────────────── documents: { enabled: false, fields: ['title', 'content', 'body'] }, @@ -161,7 +170,13 @@ export const ENCRYPTION_REGISTRY: Record = { manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] }, // ─── Inventar ──────────────────────────────────────────── - invItems: { enabled: false, fields: ['name', 'description', 'notes'] }, + // `name` is indexed (used in where()/sortBy queries). `notes` is an + // array of {id, content, createdAt} that addNote/deleteNote splice + // in place — encrypting it would force every mutation to decrypt+ + // re-encrypt the whole array. Encrypt only the description field + // for now; broader coverage is a Phase 7 concern that needs a + // different storage layout. + invItems: { enabled: true, fields: ['description'] }, }; /** diff --git a/apps/mana/apps/web/src/lib/modules/cards/queries.ts b/apps/mana/apps/web/src/lib/modules/cards/queries.ts index 5ffa55236..48d0d56a5 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/queries.ts @@ -6,6 +6,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecord, decryptRecords } from '$lib/data/crypto'; import type { LocalDeck, LocalCard, Deck, Card } from './types'; // ─── Type Converters ─────────────────────────────────────── @@ -44,8 +45,9 @@ export function toCard(local: LocalCard): Card { /** All decks, auto-updates on any change. */ export function useAllDecks() { return liveQuery(async () => { - const locals = await db.table('cardDecks').toArray(); - return locals.filter((d) => !d.deletedAt).map(toDeck); + const visible = (await db.table('cardDecks').toArray()).filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('cardDecks', visible); + return decrypted.map(toDeck); }); } @@ -53,19 +55,20 @@ export function useAllDecks() { export function useDeck(deckId: string) { return liveQuery(async () => { const local = await db.table('cardDecks').get(deckId); - return local && !local.deletedAt ? toDeck(local) : null; + if (!local || local.deletedAt) return null; + const decrypted = await decryptRecord('cardDecks', { ...local }); + return toDeck(decrypted); }); } /** All cards for a specific deck, sorted by order. Auto-updates on any change. */ export function useCardsByDeck(deckId: string) { return liveQuery(async () => { - const locals = await db - .table('cards') - .where('deckId') - .equals(deckId) - .sortBy('order'); - return locals.filter((c) => !c.deletedAt).map(toCard); + const visible = ( + await db.table('cards').where('deckId').equals(deckId).sortBy('order') + ).filter((c) => !c.deletedAt); + const decrypted = await decryptRecords('cards', visible); + return decrypted.map(toCard); }); } diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts index ea0fea079..fde0b03c8 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts @@ -8,6 +8,7 @@ import { CardsEvents } from '@mana/shared-utils/analytics'; import { cardTable, cardDeckTable } from '../collections'; import { toCard } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalCard, Card, CreateCardInput, UpdateCardInput } from '../types'; let error = $state(null); @@ -30,6 +31,8 @@ export const cardStore = { order: currentCardCount, }; + const plaintextSnapshot = toCard(newLocal); + await encryptRecord('cards', newLocal); await cardTable.add(newLocal); // Update deck card count @@ -42,7 +45,7 @@ export const cardStore = { } CardsEvents.cardCreated(); - return toCard(newLocal); + return plaintextSnapshot; } catch (err: any) { error = err.message || 'Failed to create card'; console.error('Create card error:', err); @@ -59,10 +62,12 @@ export const cardStore = { if (updates.difficulty !== undefined) localUpdates.difficulty = updates.difficulty; if (updates.order !== undefined) localUpdates.order = updates.order; - await cardTable.update(id, { + const diff: Partial = { ...localUpdates, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('cards', diff); + await cardTable.update(id, diff); } catch (err: any) { error = err.message || 'Failed to update card'; console.error('Update card error:', err); diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts index 87c76f0e7..0b5eb378d 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts @@ -9,6 +9,7 @@ import { CardsEvents } from '@mana/shared-utils/analytics'; import { db } from '$lib/data/database'; import { cardDeckTable, cardTable } from '../collections'; import { toDeck } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalDeck } from '../types'; import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types'; @@ -31,9 +32,11 @@ export const deckStore = { isPublic: input.isPublic ?? false, }; + const plaintextSnapshot = toDeck(newLocal); + await encryptRecord('cardDecks', newLocal); await cardDeckTable.add(newLocal); CardsEvents.deckCreated(); - return toDeck(newLocal); + return plaintextSnapshot; } catch (err: any) { error = err.message || 'Failed to create deck'; console.error('Create deck error:', err); @@ -49,10 +52,12 @@ export const deckStore = { if (updates.description !== undefined) localUpdates.description = updates.description; if (updates.isPublic !== undefined) localUpdates.isPublic = updates.isPublic; - await cardDeckTable.update(id, { + const diff: Partial = { ...localUpdates, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('cardDecks', diff); + await cardDeckTable.update(id, diff); } catch (err: any) { error = err.message || 'Failed to update deck'; console.error('Update deck error:', err); diff --git a/apps/mana/apps/web/src/lib/modules/inventar/queries.ts b/apps/mana/apps/web/src/lib/modules/inventar/queries.ts index 3f307f257..de4efec29 100644 --- a/apps/mana/apps/web/src/lib/modules/inventar/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/inventar/queries.ts @@ -6,6 +6,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types'; // ─── Shared Types (inline to avoid @inventar/shared dependency) ─── @@ -167,8 +168,9 @@ export function useAllCollections() { export function useAllItems() { return liveQuery(async () => { - const locals = await db.table('invItems').toArray(); - return locals.filter((i) => !i.deletedAt).map(toItem); + const visible = (await db.table('invItems').toArray()).filter((i) => !i.deletedAt); + const decrypted = await decryptRecords('invItems', visible); + return decrypted.map(toItem); }); } diff --git a/apps/mana/apps/web/src/lib/modules/inventar/stores/items.svelte.ts b/apps/mana/apps/web/src/lib/modules/inventar/stores/items.svelte.ts index eb9013eaa..713abaeb2 100644 --- a/apps/mana/apps/web/src/lib/modules/inventar/stores/items.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/inventar/stores/items.svelte.ts @@ -10,6 +10,7 @@ import { toItem } from '../queries'; import type { LocalItem } from '../types'; import type { ItemStatus } from '../queries'; import { InventarEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; export const itemsStore = { async create(data: { @@ -45,9 +46,11 @@ export const itemsStore = { tags: data.tags || [], order: collectionItems.length, }; + const plaintextSnapshot = toItem(newLocal); + await encryptRecord('invItems', newLocal); await invItemTable.add(newLocal); InventarEvents.itemCreated(); - return toItem(newLocal); + return plaintextSnapshot; }, async update( @@ -67,10 +70,12 @@ export const itemsStore = { > > ) { - await invItemTable.update(id, { + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('invItems', diff); + await invItemTable.update(id, diff); InventarEvents.itemUpdated(); }, diff --git a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts index f5d51a0f1..3a6c9b5f2 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts @@ -7,6 +7,7 @@ import { db } from '$lib/data/database'; import { toPlant, toWateringSchedule } from './queries'; import { PlantaEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalPlant, LocalWateringSchedule, @@ -38,9 +39,11 @@ export const plantMutations = { createdAt: now, updatedAt: now, }; + const plaintextSnapshot = toPlant(newLocal); + await encryptRecord('plants', newLocal); await db.table('plants').add(newLocal); PlantaEvents.plantCreated(); - return toPlant(newLocal); + return plaintextSnapshot; } catch (e) { console.error('Failed to create plant:', e); return null; @@ -63,9 +66,15 @@ export const plantMutations = { updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null; if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null; + await encryptRecord('plants', updateData); await db.table('plants').update(id, updateData); + // Re-read decrypts via the queries layer if a query is consumed. + // Direct returns from this function need explicit decryption. + const { decryptRecord } = await import('$lib/data/crypto'); const updated = await db.table('plants').get(id); - return updated ? toPlant(updated) : null; + if (!updated) return null; + const decrypted = await decryptRecord('plants', { ...updated }); + return toPlant(decrypted); } catch (e) { console.error('Failed to update plant:', e); return null; diff --git a/apps/mana/apps/web/src/lib/modules/planta/queries.ts b/apps/mana/apps/web/src/lib/modules/planta/queries.ts index 509bf3b12..1b7add0d8 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/queries.ts @@ -8,6 +8,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalPlant, LocalPlantPhoto, @@ -93,8 +94,9 @@ export function toWateringLog(local: LocalWateringLog): WateringLog { /** All plants. Auto-updates on any change. */ export function useAllPlants() { return liveQuery(async () => { - const locals = await db.table('plants').toArray(); - return locals.filter((p) => !p.deletedAt).map(toPlant); + const visible = (await db.table('plants').toArray()).filter((p) => !p.deletedAt); + const decrypted = await decryptRecords('plants', visible); + return decrypted.map(toPlant); }); } diff --git a/apps/mana/apps/web/src/lib/modules/presi/queries.ts b/apps/mana/apps/web/src/lib/modules/presi/queries.ts index bf220fb64..7e5eebd5c 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/presi/queries.ts @@ -6,6 +6,7 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; +import { decryptRecord, decryptRecords } from '$lib/data/crypto'; import type { LocalDeck, LocalSlide, Deck, Slide } from './types'; // ─── Type Converters ────────────────────────────────────── @@ -39,9 +40,9 @@ export function toSlide(local: LocalSlide): Slide { /** All decks, sorted by updatedAt descending. Auto-updates on any change. */ export function useAllDecks() { return liveQuery(async () => { - const locals = await db.table('presiDecks').toArray(); - return locals - .filter((d) => !d.deletedAt) + const visible = (await db.table('presiDecks').toArray()).filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('presiDecks', visible); + return decrypted .map(toDeck) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }); @@ -50,11 +51,11 @@ export function useAllDecks() { /** Slides for a specific deck, sorted by order. Auto-updates on any change. */ export function useDeckSlides(deckId: string) { return liveQuery(async () => { - const locals = await db.table('slides').where('deckId').equals(deckId).toArray(); - return locals - .filter((s) => !s.deletedAt) - .map(toSlide) - .sort((a, b) => a.order - b.order); + const visible = ( + await db.table('slides').where('deckId').equals(deckId).toArray() + ).filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('slides', visible); + return decrypted.map(toSlide).sort((a, b) => a.order - b.order); }); } @@ -63,7 +64,8 @@ export function useDeck(id: string) { return liveQuery(async () => { const local = await db.table('presiDecks').get(id); if (!local || local.deletedAt) return null; - return toDeck(local); + const decrypted = await decryptRecord('presiDecks', { ...local }); + return toDeck(decrypted); }); } diff --git a/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts index f19682209..71568f5d1 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts @@ -9,6 +9,7 @@ import { db } from '$lib/data/database'; import { presiDeckTable, slideTable } from '../collections'; import { toDeck, toSlide } from '../queries'; import { PresiEvents } from '@mana/shared-utils/analytics'; +import { encryptRecord } from '$lib/data/crypto'; import type { LocalDeck, LocalSlide, @@ -35,9 +36,11 @@ function createDecksStore() { themeId: dto.themeId || null, isPublic: false, }; + const plaintextSnapshot = toDeck(newLocal); + await encryptRecord('presiDecks', newLocal); await presiDeckTable.add(newLocal); PresiEvents.deckCreated(); - return toDeck(newLocal); + return plaintextSnapshot; } catch (e) { error = e instanceof Error ? e.message : 'Failed to create deck'; console.error('Failed to create deck:', e); @@ -58,6 +61,7 @@ function createDecksStore() { if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId; if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic; + await encryptRecord('presiDecks', localUpdates); await presiDeckTable.update(id, localUpdates); return true; } catch (e) { @@ -100,9 +104,11 @@ function createDecksStore() { order, content: dto.content, }; + const plaintextSnapshot = toSlide(newLocal); + await encryptRecord('slides', newLocal); await slideTable.add(newLocal); PresiEvents.slideCreated(); - return toSlide(newLocal); + return plaintextSnapshot; } catch (e) { error = e instanceof Error ? e.message : 'Failed to create slide'; console.error('Failed to create slide:', e); @@ -119,6 +125,7 @@ function createDecksStore() { if (dto.content !== undefined) localUpdates.content = dto.content; if (dto.order !== undefined) localUpdates.order = dto.order; + await encryptRecord('slides', localUpdates); await slideTable.update(id, localUpdates); return true; } catch (e) {