fix(labs): fail-soft the biomarker list context decrypt

The catalog GET eagerly threw on the first un-decryptable context row,
500ing the whole list. The list serializer now decrypts fail-soft (null
on a bad-key/malformed row) via decryptContextSoft, bounding the
fail-closed blast radius to one marker; the single-resource GET stays the
authoritative throwing read for the plaintext context.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:11:54 +02:00
1 parent 9141b8f74f
commit 6df0c64d85
2 files changed
+31 -5

No files matched your search

+12 -5
View File
@@ -13,7 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import {
decryptContextFromBytes,
decryptContextSoft,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { annotate } from "@/lib/logging/context";
@@ -22,6 +22,12 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
/**
* v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
*
* Module gate: like `/api/labs/*`, this is intentionally NOT server-gated.
* The Labs module toggle is a UX-only nav preference; the data is owner-scoped
* and read by cross-feature surfaces (Vorsorge reminder resolution, the
* doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
* to the born-gated `/api/illness/*` routes.
*
* The catalog is the Labs feature's primary object: a marker is defined ONCE
* (name, unit, reference bounds, optional context) and every later reading
* just picks it. GET lists the caller's markers; POST defines a new one.
@@ -33,7 +39,10 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
*/
// The encrypted context bytes are never echoed back; `hasContext` flags
// presence and the single-resource GET returns the decrypted text.
// presence. The list path decrypts FAIL-SOFT (`decryptContextSoft` → null on
// a bad-key / malformed row) so one un-decryptable row never 500s the whole
// catalog — the single-resource GET is the authoritative read for the
// plaintext context. This bounds the fail-closed blast radius to one marker.
function serialiseBiomarker(row: {
id: string;
name: string;
@@ -53,9 +62,7 @@ function serialiseBiomarker(row: {
upperBound: row.upperBound,
panel: row.panel,
hasContext: row.contextEncrypted !== null,
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
context: decryptContextSoft(row.contextEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -4,6 +4,7 @@ import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
decryptContextFromBytes,
decryptContextSoft,
encryptContextToBytes,
} from "../biomarker-store";
@@ -32,4 +33,22 @@ describe("biomarker context codec", () => {
const asString = Buffer.from(bytes).toString("utf8");
expect(asString).not.toContain(plain);
});
describe("decryptContextSoft (fail-soft list decrypt)", () => {
it("returns null for a null payload", () => {
expect(decryptContextSoft(null)).toBeNull();
});
it("round-trips a valid payload like the throwing variant", () => {
const bytes = encryptContextToBytes("morning fasting panel");
expect(decryptContextSoft(bytes)).toBe("morning fasting panel");
});
it("returns null instead of throwing on a malformed / bad-key row", () => {
// A non-ciphertext payload would throw in `decryptContextFromBytes`.
const garbage = new Uint8Array(Buffer.from("not-a-ciphertext"));
expect(() => decryptContextFromBytes(garbage)).toThrow();
expect(decryptContextSoft(garbage)).toBeNull();
});
});
});