feat(picture): encrypt boards + boardItems

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 23:57:54 +02:00
parent 109de61e21
commit a7e5b39ad0
4 changed files with 59 additions and 16 deletions

View file

@ -169,6 +169,15 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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

View file

@ -96,6 +96,9 @@ export function useAllBoards() {
const locals = await db.table<LocalBoard>('boards').toArray();
const allItems = await db.table<LocalBoardItem>('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<string, number>();
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<LocalBoard>('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);
});
}

View file

@ -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<LocalBoard>('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<Omit<LocalBoard, 'id'>>) {
error = null;
try {
await db.table('boards').update(id, {
const diff: Partial<LocalBoard> = {
...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<LocalBoard>('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<LocalBoard>('boards').get(id);
if (!original) return { success: false, error: 'Board not found' };
const rawOriginal = await db.table<LocalBoard>('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<LocalBoard>('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<LocalBoardItem>('boardItems')
.where('boardId')
@ -130,16 +151,18 @@ export const boardsStore = {
.toArray();
for (const item of originalItems) {
if (item.deletedAt) continue;
await db.table<LocalBoardItem>('boardItems').add({
const newItem: LocalBoardItem = {
...item,
id: crypto.randomUUID(),
boardId: newId,
createdAt: now,
updatedAt: now,
});
};
await encryptRecord('boardItems', newItem);
await db.table<LocalBoardItem>('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 };

View file

@ -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 },