mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
109de61e21
commit
a7e5b39ad0
4 changed files with 59 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue