refactor(labs): drop the fail-soft lab-note decrypt, which has no caller left

The export was its only user, and the export now reads through a helper that
marks an undecryptable note instead of blanking it. Nothing else wanted the
silent null: the list DTO never carries the note at all, only `hasNote`, and
the single-resource GET has always used the throwing decrypt so a key gap
surfaces there.

Deleted rather than kept behind a dead-code exemption. A fail-soft twin
sitting unused next to a strict one is the exact shape this branch has been
clearing out — the next person writing an export reaches for whatever is
nearest, and the whole point was that the nearest thing should not silently
swallow a row. It had no test of its own, so none was removed; the three
comments that pointed at it are rewritten to say what is true now.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 02:29:37 +02:00
1 parent ae731b92d9
commit 83b1400e5b
4 files changed
+19 -36

No files matched your search

+7 -7
View File
@@ -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.
+6 -4
View File
@@ -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 {
+5 -24
View File
@@ -5,11 +5,15 @@
* free-text note is the only sensitive column on the model; it shares the
* `encrypt()` string format (`"<keyId>.<base64>"`) 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<ArrayBuffer> {
@@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
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;
}
}
+1 -1
View File
@@ -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