diff --git a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts index b6fccb345..86402796c 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts @@ -47,6 +47,71 @@ export class VaultLockedError extends Error { } } +/** + * Dev-only registry-vs-record shape check. + * + * Called from encryptRecord when `import.meta.env.DEV` is truthy (Vite + * strips the call in production builds). Catches the most common silent + * failure mode: a registry entry names a field the record doesn't have, + * because of a case typo. Without this warning, the field stays plaintext + * forever and no error is ever thrown. + * + * False-positive strategy: + * - We only warn on close matches (case-insensitive). An optional field + * that happens to be omitted from a given write won't light up. + * - A record that has NONE of the registered fields is also flagged, + * which catches wrong-table-name call sites. + * + * Throttled per (tableName, field) pair so liveQuery loops don't spam. + */ +const _registryWarnings = new Set(); +function devCheckRegistryShape( + tableName: string, + record: Record, + fields: readonly string[] +): void { + const recordKeys = Object.keys(record); + const recordKeySet = new Set(recordKeys); + const lcMap = new Map(); + for (const k of recordKeys) lcMap.set(k.toLowerCase(), k); + + let exactHits = 0; + for (const field of fields) { + if (recordKeySet.has(field)) { + exactHits++; + continue; + } + // Case-insensitive near-miss → almost certainly a typo in the registry. + const near = lcMap.get(field.toLowerCase()); + if (near && near !== field) { + const key = `${tableName}.${field}`; + if (!_registryWarnings.has(key)) { + _registryWarnings.add(key); + console.error( + `[mana-crypto] DEV: registry field '${field}' not on ${tableName} record, ` + + `but case-insensitive match '${near}' exists. Registry typo? ` + + `This field is SILENTLY staying plaintext in production.` + ); + } + } + } + + // Record has no registered field at all — probably wrong tableName or + // a record shape that diverged from the type the registry was written for. + if (exactHits === 0 && recordKeys.length > 0) { + const key = `${tableName}:no-fields`; + if (!_registryWarnings.has(key)) { + _registryWarnings.add(key); + console.warn( + `[mana-crypto] DEV: encryptRecord('${tableName}', ...) called but the record ` + + `has none of the registered fields [${fields.join(', ')}]. ` + + `Keys on record: [${recordKeys.slice(0, 10).join(', ')}${recordKeys.length > 10 ? ', …' : ''}]. ` + + `Wrong table name?` + ); + } + } +} + /** * Encrypts the configured fields of `record` in place. Returns the * same record reference for chaining. No-op if the table is not in @@ -67,6 +132,8 @@ export async function encryptRecord(tableName: string, record: if (!fields) return record; const view = record as unknown as Record; + if (import.meta.env.DEV) devCheckRegistryShape(tableName, view, fields); + // Build the work list first so we don't half-encrypt a record on // vault-locked failure mid-loop. const todo: string[] = []; 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 89f7860bf..5ec8d5bdb 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -28,6 +28,19 @@ * - When in doubt about a free-form field, encrypt it * - When in doubt about a structural field, leave it plaintext (the * query layer needs it) + * + * Authoring pattern — use `entry()` for type-safety: + * + * import type { LocalNote } from '$lib/modules/notes/types'; + * notes: entry(['title', 'content']), + * + * TypeScript rejects field names that don't exist on the row type. This + * catches the #1 silent-failure mode: a registry typo (wrong case, wrong + * name) → the field quietly ships in plaintext forever, with no error + * anywhere. Plain object-literal entries (`{ enabled, fields }`) still + * compile — migrate them opportunistically, new entries should always use + * the helper. A dev-only runtime check in `record-helpers.ts` catches + * typos for untyped entries as a fallback. */ export interface EncryptionConfig { @@ -37,15 +50,39 @@ export interface EncryptionConfig { readonly enabled: boolean; } +/** + * Type-safe registry entry. Pass the Local* row type explicitly — TypeScript + * rejects field names that don't exist on the type, catching the most common + * silent failure mode (registry typo → field stays plaintext forever). + * + * import type { LocalMessage } from '$lib/modules/chat/types'; + * messages: entry(['messageText']), + * + * Object-literal entries still work (untyped) so this can be adopted + * incrementally, one module at a time. New entries should always use `entry`. + */ +export function entry( + fields: readonly (keyof T & string)[], + opts: { enabled?: boolean } = {} +): EncryptionConfig { + return { enabled: opts.enabled ?? true, fields }; +} + +// Typed imports for migrated entries. Kept as `import type` so this file +// produces no runtime dependencies on the module tree — the registry must +// stay bootable during Dexie schema init, before any module code runs. +import type { LocalMessage, LocalConversation, LocalTemplate } from '../../modules/chat/types'; +import type { LocalNote } from '../../modules/notes/types'; +import type { LocalDream, LocalDreamSymbol } from '../../modules/dreams/types'; +import type { LocalJournalEntry } from '../../modules/journal/types'; +import type { LocalMemo } from '../../modules/memoro/types'; + export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── // Phase 5: messageText is the highest-value target in the entire app. - messages: { enabled: true, fields: ['messageText'] }, - conversations: { enabled: true, fields: ['title'] }, - chatTemplates: { - enabled: true, - fields: ['name', 'description', 'systemPrompt', 'initialQuestion'], - }, + messages: entry(['messageText']), + conversations: entry(['title']), + chatTemplates: entry(['name', 'description', 'systemPrompt', 'initialQuestion']), // ─── Who (LLM character guessing game) ────────────────── // Conversation content + the revealed character name + free-form @@ -57,31 +94,35 @@ export const ENCRYPTION_REGISTRY: Record = { // ─── Notes ─────────────────────────────────────────────── // Phase 4 pilot — first table flipped to enabled:true. The schema // uses `title` + `content` (no separate `body` column). - notes: { enabled: true, fields: ['title', 'content'] }, + notes: entry(['title', 'content']), // ─── Journal ───────────────────────────────────────────── // Daily freeform entries — title and content are the user-typed parts. // entryDate, mood (enum), tags (string[]), isPinned/isArchived/isFavorite, // wordCount stay plaintext for indexing, sorting, and insights. - journalEntries: { enabled: true, fields: ['title', 'content'] }, + journalEntries: entry(['title', 'content']), // ─── Dreams ────────────────────────────────────────────── // LocalDream uses content + transcript + interpretation, no `notes`. - dreams: { - enabled: true, - fields: ['title', 'content', 'transcript', 'interpretation', 'aiInterpretation', 'location'], - }, + dreams: entry([ + 'title', + 'content', + 'transcript', + 'interpretation', + 'aiInterpretation', + 'location', + ]), // Symbol `name` stays plaintext — it's used as the unique lookup key // in touchSymbols / updateSymbol via where('name').equals(...). Only // the user-written `meaning` (which is the actually sensitive part) // is encrypted. - dreamSymbols: { enabled: true, fields: ['meaning'] }, + dreamSymbols: entry(['meaning']), // ─── Memoro ────────────────────────────────────────────── // Voice transcripts are typically the largest plaintext blobs in the // whole app — encrypting them yields the biggest disk-footprint win // of any single field. - memos: { enabled: true, fields: ['title', 'intro', 'transcript'] }, + memos: entry(['title', 'intro', 'transcript']), memories: { enabled: true, fields: ['title', 'content'] }, // ─── Contacts ────────────────────────────────────────────