From a7e5b39ad00878c0234198070386e1f7eb85bd21 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 23:57:54 +0200 Subject: [PATCH] feat(picture): encrypt boards + boardItems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes backlog #5 from the Phase 9 audit. Adds two new registry entries (boards, boardItems) and wraps the boards store + queries + search provider so the moodboard names, descriptions and text-item content are sealed at rest like every other user-typed field. Registry -------- - boards: ['name', 'description'] - boardItems: ['textContent'] Inline comments explain that textContent is only set when itemType === 'text' (image-type items have it null, encryptRecord is a pass-through). Coordinates / dimensions / z-index / opacity stay plaintext for the canvas renderer. Boards store ------------ - createBoard: snapshots plaintext for the return value before encryptRecord mutates the row in place - updateBoard: encrypts the diff before update, then re-fetches + decrypts for the return value (so the caller gets plaintext, not the ciphertext we just wrote) - duplicateBoard: NEW behaviour — explicitly decrypts the original board first because the duplicate concatenates "(Kopie)" onto the name string. Concatenating onto a "enc:1:..." prefix would produce a malformed blob that fails to decrypt later. The board items are spread directly because the duplicate uses the SAME master key, so the existing ciphertext stays valid; encryptRecord is idempotent on already-encrypted strings so it's a no-op safety check. Reads ----- - useAllBoards: decrypts the visible board set before mapping. The item count map only reads structural fields (deletedAt + boardId) so it doesn't need a decrypt pass for boardItems. - allBoards$ raw observable: same pattern - search/providers/picture: decrypts before substring scoring against the user query The unified mana app currently has no UI that renders boardItems .textContent (the seed data in collections.ts is exported as PICTURE_GUEST_SEED but never imported anywhere — dead code), so no item-side reader needs touching for this commit. When a future canvas editor lands it'll go through the existing decryptRecord helpers naturally. 78/78 crypto tests still pass (registry shape unchanged at the API level). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/registry.ts | 9 ++++ .../web/src/lib/modules/picture/queries.ts | 15 +++++-- .../modules/picture/stores/boards.svelte.ts | 43 ++++++++++++++----- .../web/src/lib/search/providers/picture.ts | 8 ++-- 4 files changed, 59 insertions(+), 16 deletions(-) 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 17684bf06..7cd7ed18f 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -169,6 +169,15 @@ export const ENCRYPTION_REGISTRY: Record = { // model / style / format / blurhash stay plaintext (technical // metadata, not user content). images: { enabled: true, fields: ['prompt', 'negativePrompt'] }, + // Picture boards live next to images. `name` + `description` on the + // board itself are user-typed and protected. `textContent` on + // LocalBoardItem is the freeform text the user types when they add + // a sticky-note style item to a canvas (only set when + // itemType === 'text'). For image-type items the field is null and + // encryptRecord is a pass-through. Coordinates / dimensions / + // z-index / opacity stay plaintext for the canvas renderer. + boards: { enabled: true, fields: ['name', 'description'] }, + boardItems: { enabled: true, fields: ['textContent'] }, // ─── Music ─────────────────────────────────────────────── // Music metadata is borderline-sensitive: technical ID3 tags vs diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index c09b2ed35..37c44747f 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -96,6 +96,9 @@ export function useAllBoards() { const locals = await db.table('boards').toArray(); const allItems = await db.table('boardItems').toArray(); + // boardItems.textContent is encrypted but the count map only + // looks at structural fields (deletedAt + boardId), so no + // decrypt needed for the counter. const itemCounts = new Map(); for (const item of allItems) { if (!item.deletedAt) { @@ -103,8 +106,12 @@ export function useAllBoards() { } } - return locals - .filter((b) => !b.deletedAt) + const visible = locals.filter((b) => !b.deletedAt); + // boards.name + description are encrypted on disk; the workbench + // + dashboard widgets render them, so decrypt before mapping. + const decrypted = await decryptRecords('boards', visible); + + return decrypted .map( (local): BoardWithCount => ({ ...toBoard(local), @@ -141,7 +148,9 @@ export function allImages$() { export function allBoards$() { return liveQuery(async () => { const locals = await db.table('boards').toArray(); - return locals.filter((b) => !b.deletedAt).map(toBoard); + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('boards', visible); + return decrypted.map(toBoard); }); } diff --git a/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts b/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts index d466b7b26..4d56fca8a 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts @@ -7,6 +7,7 @@ */ import { db } from '$lib/data/database'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import type { LocalBoard, LocalBoardItem } from '../types'; import { toBoard } from '../queries'; @@ -43,8 +44,12 @@ export const boardsStore = { updatedAt: new Date().toISOString(), }; + // Snapshot plaintext for the return value before encryptRecord + // mutates `newLocal` in place — UI consumers expect plaintext. + const plaintextSnapshot = toBoard({ ...newLocal }); + await encryptRecord('boards', newLocal); await db.table('boards').add(newLocal); - return { success: true, data: toBoard(newLocal) }; + return { success: true, data: plaintextSnapshot }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to create board'; return { success: false, error }; @@ -57,13 +62,18 @@ export const boardsStore = { async updateBoard(id: string, input: Partial>) { error = null; try { - await db.table('boards').update(id, { + const diff: Partial = { ...input, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('boards', diff); + await db.table('boards').update(id, diff); + // Re-fetch and decrypt for the return value so the caller + // gets plaintext (not the ciphertext we just wrote). const updated = await db.table('boards').get(id); if (updated) { - return { success: true, data: toBoard(updated) }; + const plain = await decryptRecord('boards', { ...updated }); + return { success: true, data: toBoard(plain) }; } return { success: false, error: 'Board not found' }; } catch (e) { @@ -103,8 +113,14 @@ export const boardsStore = { async duplicateBoard(id: string) { error = null; try { - const original = await db.table('boards').get(id); - if (!original) return { success: false, error: 'Board not found' }; + const rawOriginal = await db.table('boards').get(id); + if (!rawOriginal) return { success: false, error: 'Board not found' }; + + // Decrypt the original FIRST. We can't just spread the + // encrypted row because we modify `name` (string concat with + // "(Kopie)") — concatenating onto a "enc:1:..." prefix would + // produce a malformed blob that fails to decrypt later. + const original = await decryptRecord('boards', { ...rawOriginal }); const newId = crypto.randomUUID(); const now = new Date().toISOString(); @@ -120,9 +136,14 @@ export const boardsStore = { createdAt: now, updatedAt: now, }; + const plaintextSnapshot = toBoard({ ...duplicated }); + await encryptRecord('boards', duplicated); await db.table('boards').add(duplicated); - // Duplicate board items + // Duplicate board items. textContent on each item is + // encrypted but the duplicate uses the SAME master key, + // so we can spread the ciphertext directly — encryptRecord + // is idempotent on already-encrypted strings. const originalItems = await db .table('boardItems') .where('boardId') @@ -130,16 +151,18 @@ export const boardsStore = { .toArray(); for (const item of originalItems) { if (item.deletedAt) continue; - await db.table('boardItems').add({ + const newItem: LocalBoardItem = { ...item, id: crypto.randomUUID(), boardId: newId, createdAt: now, updatedAt: now, - }); + }; + await encryptRecord('boardItems', newItem); + await db.table('boardItems').add(newItem); } - return { success: true, data: toBoard(duplicated) }; + return { success: true, data: plaintextSnapshot }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to duplicate board'; return { success: false, error }; diff --git a/apps/mana/apps/web/src/lib/search/providers/picture.ts b/apps/mana/apps/web/src/lib/search/providers/picture.ts index 44aa8c03d..d60bc1f74 100644 --- a/apps/mana/apps/web/src/lib/search/providers/picture.ts +++ b/apps/mana/apps/web/src/lib/search/providers/picture.ts @@ -47,10 +47,12 @@ export const pictureSearchProvider: SearchProvider = { } } - // Search boards - const boards = await db.table('boards').toArray(); + // Search boards. name + description are encrypted at rest; the + // scorer needs plaintext to do substring matching. + const rawBoards = await db.table('boards').toArray(); + const visibleBoards = rawBoards.filter((b) => !b.deletedAt); + const boards = await decryptRecords('boards', visibleBoards); for (const board of boards) { - if (board.deletedAt) continue; const { score, matchedField } = scoreRecord( [ { name: 'name', value: board.name, weight: 1.0 },