diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 383212b0c..2948689e8 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -50,13 +50,13 @@ export const WORKOUTS_MANIFEST_NOTE = * Decrypt an encrypted free-text column for a portable export, or say plainly * that this one could not be read. * - * `decryptNoteSoft` / `decryptContextSoft` answer `null` on a wrong or dropped - * key. In a list view that is right — one bad row must not take the page down. - * In an export it is not: `null` is exactly what a column that was never - * written looks like, so the restore importer writes nothing and a note the - * person did write disappears without anyone being told. The Coach transcript - * and the doctor report both refuse that trade, one with a placeholder - * sentence and one with a rendered flag; this follows the same rule. + * A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view + * that is right — one bad row must not take the page down. In an export it is + * not: `null` is exactly what a column that was never written looks like, so + * the restore importer writes nothing and a note the person did write + * disappears without anyone being told. The Coach transcript and the doctor + * report both refuse that trade, one with a placeholder sentence and one with + * a rendered flag; this follows the same rule. * * The warning the soft helpers emitted is kept, so a systemic key gap still * shows up in the wide event rather than only in the file. diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index b6cefe5e1..f1e11411e 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -33,10 +33,12 @@ export function decryptContextFromBytes(buf: Uint8Array): string { /** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error. * - * Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key / - * malformed row must not 500 the whole catalog list. The single-resource GET - * path uses the throwing `decryptContextFromBytes` so a genuine decrypt - * failure there surfaces instead of silently masking a key-rotation gap. */ + * A single bad-key / malformed row must not 500 the whole catalog list, and + * the list surface renders a missing context as nothing either way. The + * single-resource GET path uses the throwing `decryptContextFromBytes` so a + * genuine decrypt failure there surfaces instead of silently masking a + * key-rotation gap, and the backup export reads through its own helper that + * marks the row rather than blanking it. */ export function decryptContextSoft(buf: Uint8Array | null): string | null { if (!buf) return null; try { diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts index 11a18e45e..2a215fa16 100644 --- a/src/lib/labs/store.ts +++ b/src/lib/labs/store.ts @@ -5,11 +5,15 @@ * free-text note is the only sensitive column on the model; it shares the * `encrypt()` string format (`"."`) every other `*Encrypted` * column uses, encoded as UTF-8 bytes. + * + * There is one decrypt here and it throws. A fail-soft twin used to sit + * beside it for bulk reads; the list DTO never carries the note (it reports + * `hasNote` only) and the export now writes a visible marker instead of a + * null, so nothing was left that wanted a silent one. */ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; -import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */ export function encryptNoteToBytes(plaintext: string): Uint8Array { @@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array { export function decryptNoteFromBytes(buf: Uint8Array): string { return decrypt(Buffer.from(buf).toString("utf8")); } - -/** - * Decrypt a stored `Bytes` note, fail-soft to `null` on any error. - * - * Mirrors `decryptContextSoft` in the biomarker store: a single bad-key / - * malformed row must not fail a bulk read (list view, backup export) that - * spans many rows. The single-resource GET path uses the throwing - * `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead - * of silently masking a key-rotation gap. - */ -export function decryptNoteSoft(buf: Uint8Array | null): string | null { - if (!buf || buf.byteLength === 0) return null; - try { - return decryptNoteFromBytes(buf); - } catch (err) { - getEvent()?.addWarning( - `lab note decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return null; - } -} diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index aba427cd9..2d22ba3bf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -2627,7 +2627,7 @@ describe("every model the plan claims two-ended survives a real restore", () => * A note this instance can no longer decrypt leaves the export as a marker, * not as nothing. * - * `decryptNoteSoft` answers `null` on a wrong or dropped key, and in a + * A fail-soft decrypt answers `null` on a wrong or dropped key, and in a * portable file `null` is byte-for-byte what a note that was never written * looks like. The restore importer then writes no note, so a note the person * did write is gone and no one is told — the whole loss fits inside a