feat(mana/web): encryption phase 6.1 — cards, presi, inventar, planta

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 19:44:38 +02:00
parent b2bddfefab
commit 73f294b298
11 changed files with 106 additions and 43 deletions

View file

@ -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<LocalPresiDeck>('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,
};
},
{

View file

@ -114,15 +114,24 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
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<string, EncryptionConfig> = {
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'] },
};
/**

View file

@ -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<LocalDeck>('cardDecks').toArray();
return locals.filter((d) => !d.deletedAt).map(toDeck);
const visible = (await db.table<LocalDeck>('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<LocalDeck>('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<LocalCard>('cards')
.where('deckId')
.equals(deckId)
.sortBy('order');
return locals.filter((c) => !c.deletedAt).map(toCard);
const visible = (
await db.table<LocalCard>('cards').where('deckId').equals(deckId).sortBy('order')
).filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('cards', visible);
return decrypted.map(toCard);
});
}

View file

@ -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<string | null>(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<LocalCard> = {
...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);

View file

@ -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<LocalDeck> = {
...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);

View file

@ -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<LocalItem>('invItems').toArray();
return locals.filter((i) => !i.deletedAt).map(toItem);
const visible = (await db.table<LocalItem>('invItems').toArray()).filter((i) => !i.deletedAt);
const decrypted = await decryptRecords('invItems', visible);
return decrypted.map(toItem);
});
}

View file

@ -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<LocalItem> = {
...data,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('invItems', diff);
await invItemTable.update(id, diff);
InventarEvents.itemUpdated();
},

View file

@ -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<LocalPlant>('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;

View file

@ -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<LocalPlant>('plants').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlant);
const visible = (await db.table<LocalPlant>('plants').toArray()).filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('plants', visible);
return decrypted.map(toPlant);
});
}

View file

@ -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<LocalDeck>('presiDecks').toArray();
return locals
.filter((d) => !d.deletedAt)
const visible = (await db.table<LocalDeck>('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<LocalSlide>('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<LocalSlide>('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<LocalDeck>('presiDecks').get(id);
if (!local || local.deletedAt) return null;
return toDeck(local);
const decrypted = await decryptRecord('presiDecks', { ...local });
return toDeck(decrypted);
});
}

View file

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