diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 61a5b2f14..a9c2a7ff5 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -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(), }; diff --git a/src/lib/labs/__tests__/biomarker-store.test.ts b/src/lib/labs/__tests__/biomarker-store.test.ts index fd8b849af..c200f0c45 100644 --- a/src/lib/labs/__tests__/biomarker-store.test.ts +++ b/src/lib/labs/__tests__/biomarker-store.test.ts @@ -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(); + }); + }); });