From 9399b77a74a6cd5cd4fe350b545941d3bc2c911d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 19:46:07 +0200 Subject: [PATCH] fix(measurements): shape the single-measurement note so ciphertext never leaves the server Wrap GET /api/measurements/:id through shapeMeasurementNotes like the list, create, and update paths already do, so the encrypted note column is decrypted into notes and the raw ciphertext is stripped from the response. Harden the rotation-coverage guard to require both the field literal and the model delegate, so a same-named encrypted column whose table is not rotated is caught. --- src/app/api/measurements/[id]/route.ts | 2 +- .../__tests__/encrypted-columns.test.ts | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index f25ab7710..4354eb3c1 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -46,7 +46,7 @@ export const GET = apiHandler( }, }); - return apiSuccess(measurement); + return apiSuccess(shapeMeasurementNotes(measurement)); }, ); diff --git a/src/lib/crypto/__tests__/encrypted-columns.test.ts b/src/lib/crypto/__tests__/encrypted-columns.test.ts index 203873687..558b367a6 100644 --- a/src/lib/crypto/__tests__/encrypted-columns.test.ts +++ b/src/lib/crypto/__tests__/encrypted-columns.test.ts @@ -147,12 +147,29 @@ describe("encrypted-column registry", () => { it("is fully referenced by the key-rotation script", () => { const script = readFileSync(ROTATION_SCRIPT_PATH, "utf8"); - const unreferenced = ENCRYPTED_COLUMNS.filter( - (c: EncryptedColumn) => !script.includes(`"${c.field}"`), - ).map(encryptedColumnKey); + // Field names are NOT unique across models (e.g. `noteEncrypted` lives on + // five models, `notesEncrypted` on two, `accessToken`/`refreshToken` on + // three connection tables each). A bare field-name substring match passes + // on the first occurrence and is blind to a same-named sibling whose model + // was never wired into rotation — exactly the "silently skipped → + // undecryptable after the old key is dropped" failure the registry exists + // to prevent. So require, per registry entry, BOTH the field literal AND + // the model's Prisma delegate (`prisma.`) to appear in the script + // — the delegate proves that model's table is actually walked (some tables + // are rotated through a shared `for (const field of [...])` loop, where the + // field literal appears once but each delegate is named explicitly). + const delegate = (model: string) => + `prisma.${model.charAt(0).toLowerCase()}${model.slice(1)}`; + const unreferenced = (ENCRYPTED_COLUMNS as EncryptedColumn[]) + .filter( + (c) => + !script.includes(`"${c.field}"`) || + !script.includes(delegate(c.model)), + ) + .map(encryptedColumnKey); expect( unreferenced, - "registry columns NOT referenced by scripts/rotate-encryption-key.ts", + "registry columns NOT fully referenced (field + model delegate) by scripts/rotate-encryption-key.ts", ).toEqual([]); }); });