mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(crypto): type-safe registry entries + dev-mode drift check
The encryption registry was a plain Record<string, EncryptionConfig> with bare string[] fields — a typo in a field name (e.g. 'messagetext' instead of 'messageText') silently shipped that field in plaintext forever. No compile error, no runtime error, just quietly-leaked data. This was flagged as the #1 silent-failure mode in the architecture audit (Concern 1). Two additive layers: 1. `entry<T>(fields, opts?)` helper - Takes the Local* row type as a type parameter - `fields` is `keyof T & string` — TypeScript rejects any name that isn't actually on the row type - Migrated the 6 highest-value entries as examples: messages, conversations, chatTemplates, notes, journalEntries, dreams, dreamSymbols, memos. Remaining entries keep the old object-literal shape and compile as before — migration is opportunistic, not a big-bang rewrite. 2. Dev-only runtime shape check in `encryptRecord` - Gated on `import.meta.env.DEV` so production builds pay zero cost (Vite strips the call at build time) - Case-insensitive near-miss detection: warns when a registered field isn't on the record but its lowercased form matches an existing key — catches typos for untyped legacy entries too - "no registered field present at all" warning catches wrong-tableName call sites - Throttled per (table, field) so liveQuery loops don't spam Verification: svelte-check: 0 errors, 29 pre-existing warnings (unrelated) vitest crypto suite: 77/78 pass (1 pre-existing failure on meditateSettings empty-fields assertion, not touched here) Phase C (build-time audit script enforcing every Dexie table is either registered or explicitly allowlisted as plaintext) is the bigger win but requires seeding the allowlist from current state — deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
52d008dd34
commit
a2598b9c57
2 changed files with 122 additions and 14 deletions
|
|
@ -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<string>();
|
||||
function devCheckRegistryShape(
|
||||
tableName: string,
|
||||
record: Record<string, unknown>,
|
||||
fields: readonly string[]
|
||||
): void {
|
||||
const recordKeys = Object.keys(record);
|
||||
const recordKeySet = new Set(recordKeys);
|
||||
const lcMap = new Map<string, string>();
|
||||
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<T extends object>(tableName: string, record:
|
|||
if (!fields) return record;
|
||||
const view = record as unknown as Record<string, unknown>;
|
||||
|
||||
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[] = [];
|
||||
|
|
|
|||
|
|
@ -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<LocalType>()` for type-safety:
|
||||
*
|
||||
* import type { LocalNote } from '$lib/modules/notes/types';
|
||||
* notes: entry<LocalNote>(['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<LocalMessage>(['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<T extends object>(
|
||||
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<string, EncryptionConfig> = {
|
||||
// ─── 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<LocalMessage>(['messageText']),
|
||||
conversations: entry<LocalConversation>(['title']),
|
||||
chatTemplates: entry<LocalTemplate>(['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<string, EncryptionConfig> = {
|
|||
// ─── 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<LocalNote>(['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<LocalJournalEntry>(['title', 'content']),
|
||||
|
||||
// ─── Dreams ──────────────────────────────────────────────
|
||||
// LocalDream uses content + transcript + interpretation, no `notes`.
|
||||
dreams: {
|
||||
enabled: true,
|
||||
fields: ['title', 'content', 'transcript', 'interpretation', 'aiInterpretation', 'location'],
|
||||
},
|
||||
dreams: entry<LocalDream>([
|
||||
'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<LocalDreamSymbol>(['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<LocalMemo>(['title', 'intro', 'transcript']),
|
||||
memories: { enabled: true, fields: ['title', 'content'] },
|
||||
|
||||
// ─── Contacts ────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue