From 40b7069eb03143a095fbea4e0036adb6ffc737f1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 21:29:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20phase=207.2=20=E2=80=94=20encry?= =?UTF-8?q?pt=20storeless=20modules=20(questions,=20links,=20documents,=20?= =?UTF-8?q?meals)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five storeless modules whose writes happen directly from view files (no central store yet) get the same encryption treatment by wrapping each .add/.update call site with encryptRecord and each read site with decryptRecord(s). Registry entries are also corrected to match the actual schemas — the previous Phase 1 placeholder names guessed the wrong field names. Registry corrections + flips ---------------------------- - meals: was ['description', 'notes', 'aiAnalysis'] → now ['description', 'portionSize'] (LocalMeal has neither notes nor aiAnalysis on the schema; portionSize is a short user label same sensitivity as description) - documents: was ['title', 'content', 'body'] → now ['title', 'content'] (LocalDocument uses content, no body column) - links: was ['title', 'description', 'targetUrl'] → now ['title', 'description']. originalUrl STAYS PLAINTEXT — the public redirect handler resolves shortCode → originalUrl on every click, encrypting it would force the redirect path to do an async decrypt before issuing the 302 - questions: was ['title', 'body', 'notes'] → now ['title', 'description'] (LocalQuestion uses description) - answers: was ['body'] → now ['content'] (LocalAnswer uses content) All five tables flipped to enabled:true. Write sites wrapped ------------------- Each call site builds the row/diff as a typed object, runs encryptRecord on it, then calls table.add / table.update: - questions/views/DetailView.svelte (saveField) - questions/[id]/+page.svelte (saveEdit + answer.add) - questions/new/+page.svelte (initial create) - uload/+page.svelte (createLink + saveEdit) - uload/views/DetailView.svelte (saveField) - context/documents/+page.svelte (handleCreateDocument) - context/documents/[id]/+page.svelte (handleSave with encrypted diff) - context/spaces/[id]/+page.svelte (handleCreateDocument) - nutriphi/add/+page.svelte (handleSubmit) Pure metadata writes (toggle pinned, toggle isActive, soft-delete via deletedAt) are intentionally NOT wrapped — they touch zero encrypted fields so encryptRecord would be a no-op anyway. Read sites decrypted -------------------- - questions/queries.ts: useAllQuestions, useAnswersByQuestion - questions/views/DetailView.svelte (liveQuery clone) - questions/ListView.svelte (Workbench) - uload/queries.ts: allLinks$, useAllLinks, useLinkById - uload/views/DetailView.svelte (liveQuery clone) - uload/ListView.svelte - uload/settings/+page.svelte (decrypts before serializing the JSON export — otherwise the user would download ciphertext) - context/queries.ts: useAllDocuments, useSpaceDocuments - context/ListView.svelte - cross-app-queries.useRecentDocuments (dashboard widget) - nutriphi/queries.ts: useAllMeals - nutriphi/ListView.svelte The cards/dashboard widget for nutrition only reads m.nutrition (the plaintext numeric breakdown), so it stays untouched. nutriphi/history benefits transparently because it consumes useAllMeals which now decrypts. Why --- Closes the second-tier plaintext gaps. The five tables flipped here were on the registry from day one but stuck behind enabled:false because no central store existed to hook into. Phase 7.2 takes the pragmatic approach of wrapping at each call site rather than blocking on a store extraction refactor — same end result for security, much smaller diff. A future store consolidation pass can collapse the duplication without changing the encryption surface. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/cross-app-queries.ts | 6 +++- .../apps/web/src/lib/data/crypto/registry.ts | 33 ++++++++++++++----- .../src/lib/modules/context/ListView.svelte | 8 ++--- .../web/src/lib/modules/context/queries.ts | 11 ++++--- .../src/lib/modules/nutriphi/ListView.svelte | 8 ++--- .../web/src/lib/modules/nutriphi/queries.ts | 5 ++- .../src/lib/modules/questions/ListView.svelte | 8 ++--- .../web/src/lib/modules/questions/queries.ts | 9 +++-- .../modules/questions/views/DetailView.svelte | 32 ++++++++++-------- .../web/src/lib/modules/uload/ListView.svelte | 8 ++--- .../apps/web/src/lib/modules/uload/queries.ts | 12 +++++-- .../lib/modules/uload/views/DetailView.svelte | 14 ++++++-- .../(app)/context/documents/+page.svelte | 9 +++-- .../(app)/context/documents/[id]/+page.svelte | 7 ++-- .../(app)/context/spaces/[id]/+page.svelte | 9 +++-- .../routes/(app)/nutriphi/add/+page.svelte | 7 ++-- .../routes/(app)/questions/[id]/+page.svelte | 13 +++++--- .../routes/(app)/questions/new/+page.svelte | 7 ++-- .../web/src/routes/(app)/uload/+page.svelte | 13 +++++--- .../routes/(app)/uload/settings/+page.svelte | 6 +++- 20 files changed, 152 insertions(+), 73 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 4a686eaaf..30310cc25 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 @@ -272,13 +272,17 @@ export function useRecentDecks(limit = 5) { /** Recent documents + spaces. */ export function useRecentDocuments(limit = 5) { return useLiveQueryWithDefault(async () => { - return db + // title + content are encrypted on disk; the dashboard surfaces the + // title so we have to decrypt before returning. limit is applied + // pre-decrypt to keep the batch small. + const visible = await db .table('documents') .orderBy('updatedAt') .reverse() .filter((d) => !d.deletedAt) .limit(limit) .toArray(); + return decryptRecords('documents', visible); }, [] as LocalDocument[]); } 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 349a62801..277a5e093 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -117,7 +117,12 @@ export const ENCRYPTION_REGISTRY: Record = { cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] }, // ─── NutriPhi ──────────────────────────────────────────── - meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] }, + // LocalMeal only has `description` as user-typed text (mealType / + // inputType / nutrition numbers stay plaintext for the daily-summary + // aggregations and the calorie-progress widget). portionSize is a + // short label like "1 Tasse" — same sensitivity as description, so + // we encrypt it too. + meals: { enabled: true, fields: ['description', 'portionSize'] }, // ─── Planta ────────────────────────────────────────────── // `name` is NOT in the schema index for plants (only isActive + @@ -140,7 +145,11 @@ export const ENCRYPTION_REGISTRY: Record = { slides: { enabled: true, fields: ['content'] }, // ─── Context ───────────────────────────────────────────── - documents: { enabled: false, fields: ['title', 'content', 'body'] }, + // LocalDocument has `title` + `content` (no `body` column on the + // schema). DocumentType (text/context/prompt) and the spaceId + // foreign key stay plaintext so the workspace tree still groups + // documents per space without a key. + documents: { enabled: true, fields: ['title', 'content'] }, // ─── Storage ───────────────────────────────────────────── files: { enabled: false, fields: ['name', 'originalName', 'notes'] }, @@ -153,12 +162,12 @@ export const ENCRYPTION_REGISTRY: Record = { mukkePlaylists: { enabled: false, fields: ['name', 'description'] }, // ─── Questions ─────────────────────────────────────────── - // Writes from views are not yet routed through a store — registry - // is set so future store creation gets encryption automatically; - // existing direct db.table().update() call sites in the views need - // to migrate to a store before they actually flow through encryptRecord. - questions: { enabled: false, fields: ['title', 'body', 'notes'] }, - answers: { enabled: false, fields: ['body'] }, + // LocalQuestion uses `title` + `description`; LocalAnswer uses + // `content` (not `body`). The view-driven write sites are wrapped + // directly via encryptRecord at each call site since this module + // has no central store yet. + questions: { enabled: true, fields: ['title', 'description'] }, + answers: { enabled: true, fields: ['content'] }, // ─── Events (social gatherings) ────────────────────────── socialEvents: { enabled: false, fields: ['title', 'description', 'notes'] }, @@ -172,7 +181,13 @@ export const ENCRYPTION_REGISTRY: Record = { transactions: { enabled: true, fields: ['description', 'note'] }, // ─── uLoad ─────────────────────────────────────────────── - links: { enabled: false, fields: ['title', 'description', 'targetUrl'] }, + // `originalUrl` STAYS PLAINTEXT — the redirect handler resolves + // shortCode → originalUrl on every click, encrypting it would force + // the public redirect path to do an async decrypt before the 302. + // shortCode is a public lookup key. We encrypt the user-typed + // metadata (title + description) which is the part the user actually + // expects to be private, and leave the routing primitives alone. + links: { enabled: true, fields: ['title', 'description'] }, manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] }, // ─── Inventar ──────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/context/ListView.svelte b/apps/mana/apps/web/src/lib/modules/context/ListView.svelte index 1b36c551d..a9ba4ab25 100644 --- a/apps/mana/apps/web/src/lib/modules/context/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/context/ListView.svelte @@ -5,6 +5,7 @@