Merge branch 'wave/v125-sec' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:40:39 +02:00
commit 52e8dab3d7
27 files changed
+1111 -60

No files matched your search

+11 -6
View File
@@ -20,6 +20,7 @@
*/
import { prisma } from "@/lib/db";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { readNote } from "@/lib/crypto/note-cipher";
/**
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
@@ -325,12 +326,16 @@ export async function buildGlp1SnapshotBlock(
const genericSafe = sanitizeForPrompt(generic, 80);
const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null;
const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => ({
value: dc.doseValue,
unit: sanitizeForPrompt(dc.doseUnit, 20),
effectiveFrom: isoDate(dc.effectiveFrom),
note: dc.note === null ? null : sanitizeForPrompt(dc.note, 200),
}));
const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => {
const decryptedNote = readNote(dc.noteEncrypted, dc.note);
return {
value: dc.doseValue,
unit: sanitizeForPrompt(dc.doseUnit, 20),
effectiveFrom: isoDate(dc.effectiveFrom),
note:
decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200),
};
});
const currentDose: CurrentDose | null = latestChange
? {
@@ -4,6 +4,9 @@ import {
readNote,
shapeMeasurementNotes,
shapeMoodNote,
shapeSideEffectNotes,
shapeInventoryItemNotes,
shapeDoseChangeNote,
} from "../note-cipher";
// A 32-byte key as 64 hex chars (the legacy single-key path).
@@ -102,3 +105,65 @@ describe("shapeMeasurementNotes / shapeMoodNote", () => {
expect(shaped.notes).toBe("legacy plaintext");
});
});
describe("v1.25 medication note shapers", () => {
it("shapeSideEffectNotes decrypts onto `notes` and strips the ciphertext", () => {
const notesEncrypted = encryptNote("nausea on day 2");
const row = {
id: "se1",
entry: "NAUSEA",
severity: 3,
notes: null,
notesEncrypted,
};
const shaped = shapeSideEffectNotes(row);
expect(shaped.notes).toBe("nausea on day 2");
expect("notesEncrypted" in shaped).toBe(false);
expect(shaped.entry).toBe("NAUSEA");
expect(shaped.severity).toBe(3);
});
it("shapeInventoryItemNotes decrypts onto `notes` and strips the ciphertext", () => {
const notesEncrypted = encryptNote("opened pen #2");
const row = { id: "i1", state: "IN_USE", notes: null, notesEncrypted };
const shaped = shapeInventoryItemNotes(row);
expect(shaped.notes).toBe("opened pen #2");
expect("notesEncrypted" in shaped).toBe(false);
expect(shaped.state).toBe("IN_USE");
});
it("shapeDoseChangeNote decrypts onto `note` and strips the ciphertext", () => {
const noteEncrypted = encryptNote("stepped up to 7.5 mg");
const row = { id: "dc1", doseValue: 7.5, note: null, noteEncrypted };
const shaped = shapeDoseChangeNote(row);
expect(shaped.note).toBe("stepped up to 7.5 mg");
expect("noteEncrypted" in shaped).toBe(false);
expect(shaped.doseValue).toBe(7.5);
});
it("falls back to the legacy plaintext for not-yet-backfilled rows", () => {
expect(
shapeSideEffectNotes({ id: "se2", notes: "legacy", notesEncrypted: null })
.notes,
).toBe("legacy");
expect(
shapeInventoryItemNotes({
id: "i2",
notes: "legacy",
notesEncrypted: null,
}).notes,
).toBe("legacy");
expect(
shapeDoseChangeNote({ id: "dc2", note: "legacy", noteEncrypted: null })
.note,
).toBe("legacy");
});
it("is fail-closed: a side-effect ciphertext under a rotated key throws", () => {
const notesEncrypted = encryptNote("secret note");
vi.stubEnv("ENCRYPTION_KEY", KEY_B);
expect(() =>
shapeSideEffectNotes({ id: "se3", notes: null, notesEncrypted }),
).toThrow();
});
});
+7
View File
@@ -149,6 +149,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// IllnessDayLog / LabResult note columns above; Bytes via the shared codec.
{ model: "MoodEntry", field: "noteEncrypted", kind: "bytes" },
{ model: "Measurement", field: "notesEncrypted", kind: "bytes" },
// ───── v1.25 medication free-text notes (Bytes columns) ─────
// Side-effect log note, dose-change titration note, and inventory-item
// note — the last plaintext PHI columns left after the v1.23 rollout.
{ model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" },
{ model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" },
{ model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */
+41
View File
@@ -7,6 +7,13 @@
* encrypt-on-write + prefer-ciphertext-fallback-plaintext contract stays
* consistent.
*
* v1.25 extends the same boundary to the three medication free-text columns —
* `MedicationSideEffect.notes` -> `notesEncrypted`,
* `MedicationDoseChange.note` -> `noteEncrypted`, and
* `MedicationInventoryItem.notes` -> `notesEncrypted` — the last plaintext PHI
* columns left after the v1.23 rollout. They reuse `encryptNote` / `readNote`
* unchanged and get their own row-shapers below.
*
* Storage shape is the shared Bytes codec (`@/lib/ai/coach/bytes-codec`) used
* by every other free-text encrypted-note column (IllnessDayLog, LabResult,
* Coach memory) — the `encrypt()` ciphertext string stored UTF-8 as `bytea`.
@@ -73,3 +80,37 @@ export function shapeMoodNote<
const { noteEncrypted, ...rest } = row;
return { ...rest, note: readNote(noteEncrypted, rest.note) };
}
/**
* v1.25 — shape a medication side-effect row for a response/export: surface
* the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext
* so it never leaves the server.
*/
export function shapeSideEffectNotes<
T extends { notes: string | null; notesEncrypted: Uint8Array | null },
>(row: T): Omit<T, "notesEncrypted"> & { notes: string | null } {
const { notesEncrypted, ...rest } = row;
return { ...rest, notes: readNote(notesEncrypted, rest.notes) };
}
/**
* v1.25 — shape a medication inventory-item row for a response/export: surface
* the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext.
*/
export function shapeInventoryItemNotes<
T extends { notes: string | null; notesEncrypted: Uint8Array | null },
>(row: T): Omit<T, "notesEncrypted"> & { notes: string | null } {
const { notesEncrypted, ...rest } = row;
return { ...rest, notes: readNote(notesEncrypted, rest.notes) };
}
/**
* v1.25 — shape a medication dose-change row for a response/export: surface the
* decrypted note on `note` and strip the raw `noteEncrypted` ciphertext.
*/
export function shapeDoseChangeNote<
T extends { note: string | null; noteEncrypted: Uint8Array | null },
>(row: T): Omit<T, "noteEncrypted"> & { note: string | null } {
const { noteEncrypted, ...rest } = row;
return { ...rest, note: readNote(noteEncrypted, rest.note) };
}
+2 -1
View File
@@ -28,6 +28,7 @@ import type {
MeasurementType,
} from "@/generated/prisma/client";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { readNote } from "@/lib/crypto/note-cipher";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/resolver";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
@@ -1250,7 +1251,7 @@ export async function collectDoctorReportData(
value: dc.doseValue,
unit: dc.doseUnit,
effectiveFrom: dc.effectiveFrom.toISOString(),
note: dc.note,
note: readNote(dc.noteEncrypted, dc.note),
})),
lastInjection:
lastIntake && lastIntake.takenAt
@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readNote } from "@/lib/crypto/note-cipher";
// ── in-memory store backing a minimal prisma mock ──────────────────────────
interface SideEffectRow {
id: string;
userId: string;
notes: string | null;
notesEncrypted: Uint8Array | null;
}
interface DoseChangeRow {
id: string;
// Dose-changes carry no userId column — ownership is the parent medication's.
medicationUserId: string;
note: string | null;
noteEncrypted: Uint8Array | null;
}
interface InventoryRow {
id: string;
userId: string;
notes: string | null;
notesEncrypted: Uint8Array | null;
}
const store = vi.hoisted(() => ({
sideEffects: [] as SideEffectRow[],
doseChanges: [] as DoseChangeRow[],
inventory: [] as InventoryRow[],
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => {
const delegates = {
medicationSideEffect: {
findMany: async (args: { where: { userId: string }; take: number }) =>
store.sideEffects
.filter(
(r) =>
r.userId === args.where.userId &&
r.notes !== null &&
r.notesEncrypted === null,
)
.slice(0, args.take)
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.sideEffects.find((x) => x.id === args.where.id);
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
},
update: async (args: {
where: { id: string };
data: { notes: string | null; notesEncrypted: Uint8Array | null };
}) => {
const r = store.sideEffects.find((x) => x.id === args.where.id)!;
r.notes = args.data.notes;
r.notesEncrypted = args.data.notesEncrypted;
return r;
},
},
medicationDoseChange: {
findMany: async (args: {
where: { medication: { userId: string } };
take: number;
}) =>
store.doseChanges
.filter(
(r) =>
r.medicationUserId === args.where.medication.userId &&
r.note !== null &&
r.noteEncrypted === null,
)
.slice(0, args.take)
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.doseChanges.find((x) => x.id === args.where.id);
return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null;
},
update: async (args: {
where: { id: string };
data: { note: string | null; noteEncrypted: Uint8Array | null };
}) => {
const r = store.doseChanges.find((x) => x.id === args.where.id)!;
r.note = args.data.note;
r.noteEncrypted = args.data.noteEncrypted;
return r;
},
},
medicationInventoryItem: {
findMany: async (args: { where: { userId: string }; take: number }) =>
store.inventory
.filter(
(r) =>
r.userId === args.where.userId &&
r.notes !== null &&
r.notesEncrypted === null,
)
.slice(0, args.take)
.map((r) => ({ id: r.id })),
findUnique: async (args: { where: { id: string } }) => {
const r = store.inventory.find((x) => x.id === args.where.id);
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
},
update: async (args: {
where: { id: string };
data: { notes: string | null; notesEncrypted: Uint8Array | null };
}) => {
const r = store.inventory.find((x) => x.id === args.where.id)!;
r.notes = args.data.notes;
r.notesEncrypted = args.data.notesEncrypted;
return r;
},
},
};
const prisma = {
...delegates,
$transaction: async (fn: (tx: typeof delegates) => unknown) =>
fn(delegates),
};
return { prisma };
});
import { runMedNotesEncryptionBackfillForUser } from "@/lib/jobs/med-notes-encryption-backfill";
const KEY = "a".repeat(64);
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
store.sideEffects = [
{ id: "se1", userId: "u1", notes: "felt dizzy", notesEncrypted: null },
{ id: "se2", userId: "u1", notes: null, notesEncrypted: null }, // no note
{ id: "se3", userId: "u2", notes: "other user", notesEncrypted: null },
];
store.doseChanges = [
{
id: "dc1",
medicationUserId: "u1",
note: "stepped up to 7.5mg",
noteEncrypted: null,
},
{
id: "dc2",
medicationUserId: "u2",
note: "other user dose note",
noteEncrypted: null,
},
];
store.inventory = [
{ id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null },
{
id: "i2",
userId: "u1",
notes: null,
notesEncrypted: new Uint8Array(0), // already migrated-ish; left alone
},
];
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("runMedNotesEncryptionBackfillForUser", () => {
it("encrypts plaintext notes, nulls the plaintext, and round-trips each table", async () => {
const summary = await runMedNotesEncryptionBackfillForUser("u1");
expect(summary.sideEffectsMigrated).toBe(1);
expect(summary.doseChangesMigrated).toBe(1);
expect(summary.inventoryItemsMigrated).toBe(1);
const se1 = store.sideEffects.find((r) => r.id === "se1")!;
expect(se1.notes).toBeNull();
expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy");
const dc1 = store.doseChanges.find((r) => r.id === "dc1")!;
expect(dc1.note).toBeNull();
expect(readNote(dc1.noteEncrypted, null)).toBe("stepped up to 7.5mg");
const i1 = store.inventory.find((r) => r.id === "i1")!;
expect(i1.notes).toBeNull();
expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2");
});
it("leaves note-less rows untouched and never writes a both-null content row", async () => {
await runMedNotesEncryptionBackfillForUser("u1");
const se2 = store.sideEffects.find((r) => r.id === "se2")!;
expect(se2.notes).toBeNull();
expect(se2.notesEncrypted).toBeNull();
});
it("does not touch another user's rows (incl. dose-changes via the parent medication)", async () => {
await runMedNotesEncryptionBackfillForUser("u1");
const se3 = store.sideEffects.find((r) => r.id === "se3")!;
expect(se3.notes).toBe("other user");
expect(se3.notesEncrypted).toBeNull();
const dc2 = store.doseChanges.find((r) => r.id === "dc2")!;
expect(dc2.note).toBe("other user dose note");
expect(dc2.noteEncrypted).toBeNull();
});
it("is idempotent: a second run migrates zero rows and keeps the ciphertext", async () => {
await runMedNotesEncryptionBackfillForUser("u1");
const se1First = store.sideEffects.find(
(r) => r.id === "se1",
)!.notesEncrypted;
const second = await runMedNotesEncryptionBackfillForUser("u1");
expect(second.sideEffectsMigrated).toBe(0);
expect(second.doseChangesMigrated).toBe(0);
expect(second.inventoryItemsMigrated).toBe(0);
const se1 = store.sideEffects.find((r) => r.id === "se1")!;
expect(se1.notesEncrypted).toBe(se1First);
expect(se1.notes).toBeNull();
expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy");
});
it("is fail-closed: a key error leaves every plaintext row intact (no data loss)", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
vi.stubEnv("ENCRYPTION_KEYS", "");
await expect(runMedNotesEncryptionBackfillForUser("u1")).rejects.toThrow();
const se1 = store.sideEffects.find((r) => r.id === "se1")!;
expect(se1.notes).toBe("felt dizzy");
expect(se1.notesEncrypted).toBeNull();
});
});
@@ -0,0 +1,305 @@
/**
* v1.25 — boot-time converging backfill that migrates the three medication
* free-text note columns from plaintext to AES-256-GCM at rest:
* `MedicationSideEffect.notes` -> `notesEncrypted`,
* `MedicationDoseChange.note` -> `noteEncrypted`, and
* `MedicationInventoryItem.notes` -> `notesEncrypted`. These are the last
* plaintext PHI columns left after the v1.23 note-encryption rollout.
*
* Modelled on `note-encryption-backfill.ts`: a discovery query enqueues one
* job per user still holding an un-migrated row, the per-user handler walks
* that user's rows, and the pass is idempotent across reboots — once a row is
* migrated it drops off the discovery + candidate sets.
*
* OWNERSHIP: `MedicationSideEffect` and `MedicationInventoryItem` carry a
* direct `userId` column, so they page/discover on `userId` directly.
* `MedicationDoseChange` has no `userId` — it hangs off a parent `Medication`
* — so its candidate + discovery queries join through `medication: { userId }`.
*
* DATA-LOSS SAFETY (the property the security review must confirm):
* - Per row, the encrypt-then-null happens in a SINGLE interactive
* transaction guarded by a re-read inside the tx, so the plaintext column is
* only nulled AFTER the ciphertext is committed in the same atomic unit. A
* row that had content is never left both-null.
* - FAIL-CLOSED: `encryptNote` throws on a missing / malformed key. The throw
* aborts the transaction (the plaintext row is untouched) and propagates so
* pg-boss retries — the backfill never silently drops a note or writes a
* plaintext-shaped value into the ciphertext column.
* - IDEMPOTENT: the per-row guard (`*Encrypted IS NULL AND * != null`) is
* re-checked inside the tx, so a re-run — or two workers racing — migrates a
* row at most once; a second pass migrates zero rows.
*
* The legacy plaintext columns are intentionally NOT dropped in this release.
* That is a deliberate FOLLOW-UP release boundary, once this backfill reports
* zero remaining un-migrated rows on every instance — the same discipline the
* encryption-key-rotation playbook uses before dropping a legacy key.
*
* The queue name MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at
* boot; an unregistered queue silently never drains.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { encryptNote } from "@/lib/crypto/note-cipher";
export const MED_NOTES_ENCRYPTION_BACKFILL_QUEUE =
"med-notes-encryption-backfill";
/**
* Serial concurrency — the populator walks a user's plaintext-note rows and
* writes one transaction per row; concurrency-1 keeps it off the request pool,
* matching the v1.23 note-encryption-backfill convention.
*/
export const MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY = 1;
/** How many candidate rows to pull per page within a user's pass. */
const PAGE_SIZE = 200;
export interface MedNotesEncryptionBackfillPayload {
/** Absent on the daily discovery tick; present for a per-user job. */
userId?: string;
enqueuedAt?: string;
}
export interface MedNotesEncryptionBackfillSummary {
sideEffectsMigrated: number;
doseChangesMigrated: number;
inventoryItemsMigrated: number;
}
/**
* Migrate one side-effect row's plaintext `notes` into `notesEncrypted` and
* null the plaintext — atomically and idempotently. Returns true if it
* migrated the row, false if a concurrent pass already did.
*/
async function migrateSideEffectRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.medicationSideEffect.findUnique({
where: { id },
select: { notes: true, notesEncrypted: true },
});
// Already migrated (ciphertext present) or nothing to migrate.
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
// FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the
// plaintext row untouched.
const encrypted = encryptNote(fresh.notes);
await tx.medicationSideEffect.update({
where: { id },
data: { notesEncrypted: encrypted, notes: null },
});
return true;
});
}
/**
* Migrate one dose-change row's plaintext `note` into `noteEncrypted` and null
* the plaintext — atomically and idempotently.
*/
async function migrateDoseChangeRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.medicationDoseChange.findUnique({
where: { id },
select: { note: true, noteEncrypted: true },
});
if (!fresh || fresh.noteEncrypted || fresh.note === null) return false;
const encrypted = encryptNote(fresh.note);
await tx.medicationDoseChange.update({
where: { id },
data: { noteEncrypted: encrypted, note: null },
});
return true;
});
}
/**
* Migrate one inventory-item row's plaintext `notes` into `notesEncrypted` and
* null the plaintext — atomically and idempotently.
*/
async function migrateInventoryItemRow(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.medicationInventoryItem.findUnique({
where: { id },
select: { notes: true, notesEncrypted: true },
});
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
const encrypted = encryptNote(fresh.notes);
await tx.medicationInventoryItem.update({
where: { id },
data: { notesEncrypted: encrypted, notes: null },
});
return true;
});
}
/**
* Per-user queue handler. Walks every un-migrated medication note for one
* account across all three tables and migrates each in its own transaction.
* Safe to re-run. Dose-changes carry no `userId`, so they are scoped through
* the parent medication's owner.
*/
export async function runMedNotesEncryptionBackfillForUser(
userId: string,
): Promise<MedNotesEncryptionBackfillSummary> {
let sideEffectsMigrated = 0;
let doseChangesMigrated = 0;
let inventoryItemsMigrated = 0;
// Side-effects — direct userId. Each migration nulls `notes`, so a migrated
// row drops out of the next `notes: { not: null }, notesEncrypted: null` page.
for (;;) {
const rows = await prisma.medicationSideEffect.findMany({
where: { userId, notes: { not: null }, notesEncrypted: null },
select: { id: true },
take: PAGE_SIZE,
});
if (rows.length === 0) break;
let migratedThisPage = 0;
for (const { id } of rows) {
if (await migrateSideEffectRow(id)) {
sideEffectsMigrated += 1;
migratedThisPage += 1;
}
}
// Guard against an unexpected non-converging page (every row already
// migrated by a racing worker) so the loop always terminates.
if (migratedThisPage === 0) break;
}
// Dose-changes — no userId column; scope through the parent medication owner.
for (;;) {
const rows = await prisma.medicationDoseChange.findMany({
where: {
medication: { userId },
note: { not: null },
noteEncrypted: null,
},
select: { id: true },
take: PAGE_SIZE,
});
if (rows.length === 0) break;
let migratedThisPage = 0;
for (const { id } of rows) {
if (await migrateDoseChangeRow(id)) {
doseChangesMigrated += 1;
migratedThisPage += 1;
}
}
if (migratedThisPage === 0) break;
}
// Inventory items — direct userId.
for (;;) {
const rows = await prisma.medicationInventoryItem.findMany({
where: { userId, notes: { not: null }, notesEncrypted: null },
select: { id: true },
take: PAGE_SIZE,
});
if (rows.length === 0) break;
let migratedThisPage = 0;
for (const { id } of rows) {
if (await migrateInventoryItemRow(id)) {
inventoryItemsMigrated += 1;
migratedThisPage += 1;
}
}
if (migratedThisPage === 0) break;
}
annotate({
action: {
name: "med.notes.encryption.backfill",
details: {
side_effects_migrated: sideEffectsMigrated,
dose_changes_migrated: doseChangesMigrated,
inventory_items_migrated: inventoryItemsMigrated,
},
},
});
return { sideEffectsMigrated, doseChangesMigrated, inventoryItemsMigrated };
}
/**
* Boot-time discovery. Finds every user holding at least one medication note
* still in the legacy plaintext column (no ciphertext yet) across any of the
* three tables and enqueues one backfill job per account.
*
* Idempotent across reboots: once a user's rows are migrated, the
* `*Encrypted IS NULL AND * != null` predicate drops them from the discovery
* set. pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors
* come back through the result value so worker boot never fails on a miss.
*/
export async function enqueueBootTimeMedNotesEncryptionBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const [sideEffectUsers, doseChangeRows, inventoryUsers] = await Promise.all(
[
prisma.medicationSideEffect.findMany({
where: { notes: { not: null }, notesEncrypted: null },
select: { userId: true },
distinct: ["userId"],
}),
// Dose-changes carry no userId — surface the parent medication's owner
// and dedupe in JS (distinct cannot span a relation field).
prisma.medicationDoseChange.findMany({
where: { note: { not: null }, noteEncrypted: null },
select: { medication: { select: { userId: true } } },
}),
prisma.medicationInventoryItem.findMany({
where: { notes: { not: null }, notesEncrypted: null },
select: { userId: true },
distinct: ["userId"],
}),
],
);
const userIds = new Set<string>();
for (const { userId } of sideEffectUsers) userIds.add(userId);
for (const { medication } of doseChangeRows) userIds.add(medication.userId);
for (const { userId } of inventoryUsers) userIds.add(userId);
if (userIds.size === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const userId of userIds) {
const payload: MedNotesEncryptionBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
payload,
{
retryLimit: 5,
retryDelay: 60,
retryBackoff: true,
singletonKey: `med-notes-encryption-backfill|${userId}`,
},
);
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -62,6 +62,13 @@ import {
enqueueBootTimeNoteEncryptionBackfill,
type NoteEncryptionBackfillPayload,
} from "@/lib/jobs/note-encryption-backfill";
import {
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY,
runMedNotesEncryptionBackfillForUser,
enqueueBootTimeMedNotesEncryptionBackfill,
type MedNotesEncryptionBackfillPayload,
} from "@/lib/jobs/med-notes-encryption-backfill";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_CONCURRENCY,
@@ -198,6 +205,15 @@ const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *";
const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup";
const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *";
// v1.25 — converging backfill that migrates the three medication free-text
// note columns (side-effect note, dose-change note, inventory-item note) from
// plaintext to AES-256-GCM at rest. Boot discovery enqueues one per-user job;
// a daily 04:05 Europe/Berlin discovery tick (empty payload) re-fans for any
// rows that landed between worker reboots. Idempotent + fail-closed per row.
// Slots after the MCP-token prune (04:00) so the two don't pile up on the same
// boss poll.
const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *";
const allQueues = [
DATA_BACKUP_QUEUE,
@@ -253,6 +269,11 @@ const allQueues = [
// entry the daily schedule silently no-ops and the api_tokens table grows
// unbounded with dead 60-minute connector rows.
MCP_TOKEN_CLEANUP_QUEUE,
// v1.25 — medication free-text note encryption backfill. Boot discovery + a
// daily discovery cron enqueue one per-user job per account still holding a
// plaintext medication note; without this entry pg-boss never provisions the
// queue and both the boot enqueue and the cron silently no-op.
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -310,6 +331,11 @@ const schedules: ScheduleEntry[] = [
[NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON],
// v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens.
[MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON],
// v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill
// discovery. The empty cron payload (no `userId`) is the handler's signal to
// fan out one per-user job per account still holding a plaintext medication
// note.
[MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON],
];
/**
@@ -531,6 +557,50 @@ export async function registerMaintenanceQueues(
handleEncryptionKeyRotate,
);
// v1.25 — medication-note encryption backfill worker. The boot enqueue
// helper (and the daily 04:05 discovery tick) send one job per user still
// holding a plaintext medication note; this handler migrates that user's
// rows across all three tables to the encrypted columns. An empty-userId
// payload is the daily discovery tick and re-fans the per-user jobs
// (singletonKey-coalesced, identical to the boot pass). Serial concurrency
// so the migration never crowds the request pool.
await boss.work<Partial<MedNotesEncryptionBackfillPayload>>(
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
{ localConcurrency: MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
if (!userId) {
const result = await enqueueBootTimeMedNotesEncryptionBackfill();
workerLog(
"info",
`[med-notes-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`,
);
continue;
}
try {
const {
sideEffectsMigrated,
doseChangesMigrated,
inventoryItemsMigrated,
} = await runMedNotesEncryptionBackfillForUser(userId);
workerLog(
"info",
`[med-notes-encryption-backfill] user=${userId} sideEffects=${sideEffectsMigrated} doseChanges=${doseChangesMigrated} inventoryItems=${inventoryItemsMigrated}`,
);
} catch (err) {
recordError();
workerLog(
"error",
`[med-notes-encryption-backfill] user=${userId} failed`,
err,
);
throw err;
}
}
},
);
return allQueues;
}
@@ -593,4 +663,31 @@ export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
err,
);
}
// v1.25 — medication free-text note encryption backfill. Finds every user
// still holding a plaintext side-effect / dose-change / inventory-item note
// (no ciphertext yet) and enqueues one migration job per account. Idempotent
// across reboots: a migrated row nulls its plaintext column and drops off the
// discovery predicate.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeMedNotesEncryptionBackfill();
if (error) {
workerLog(
"error",
`[med-notes-encryption-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[med-notes-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[med-notes-encryption-backfill] boot discovery threw an unexpected error",
err,
);
}
}
+4 -1
View File
@@ -13,6 +13,7 @@
import { prisma } from "@/lib/db";
import type { MedicationContainerType } from "@/generated/prisma/client";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { computeExpiresAt } from "./state-machine";
import {
summariseSupply,
@@ -156,6 +157,8 @@ export function buildCreateInventoryInput(input: {
printedExpiry: input.printedExpiry,
purchasedAt: input.purchasedAt,
expiresAt: computeExpiresAt(null, input.printedExpiry),
notes: input.notes,
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(input.notes),
notes: null,
};
}
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveFields,
redactForExcerpt,
SENSITIVE_KEY_PATTERNS,
} from "../redact-payload";
import { buildPayloadDiagnostic } from "@/lib/api-response";
// v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt`
// surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must
@@ -178,3 +180,59 @@ describe("redactSensitiveFields", () => {
});
});
});
describe("redactForExcerpt (v1.25 — note/notes excerpt-only redaction)", () => {
it("redacts a free-text `notes` value the generic denylist deliberately keeps", () => {
const body = {
entry: "NAUSEA",
severity: 3,
notes: "felt dizzy after the 7.5 mg step-up — Nürnberg",
};
// The generic denylist leaves note/notes verbatim (too generic to add).
expect(redactSensitiveFields(body)).toEqual(body);
// The excerpt path redacts the value so it cannot reach a wide event.
expect(redactForExcerpt(body)).toEqual({
entry: "NAUSEA",
severity: 3,
notes: "[redacted]",
});
});
it("redacts a singular `note` key and recurses into nested objects + arrays", () => {
const body = {
doseChange: { doseValue: 7.5, note: "titration note" },
logs: [
{ id: "1", note: "a" },
{ id: "2", note: "b" },
],
};
expect(redactForExcerpt(body)).toEqual({
doseChange: { doseValue: 7.5, note: "[redacted]" },
logs: [
{ id: "1", note: "[redacted]" },
{ id: "2", note: "[redacted]" },
],
});
});
it("does not over-redact note-adjacent keys (noteId / footnotes stay)", () => {
const body = { noteId: "abc", footnotes: "kept", noteCount: 3 };
expect(redactForExcerpt(body)).toEqual(body);
});
it("still redacts the global sensitive keys on the excerpt path", () => {
const body = { note: "secret", token: "abc", keep: 1 };
expect(redactForExcerpt(body)).toEqual({
note: "[redacted]",
token: "[redacted]",
keep: 1,
});
});
it("keeps a free-text note value out of the wide-event excerpt end to end", () => {
const body = { notes: "a very private free-text health note value" };
const diag = buildPayloadDiagnostic(redactForExcerpt(body));
expect(diag.received_shape_excerpt).not.toContain("private free-text");
expect(diag.received_shape_excerpt).toContain("[redacted]");
});
});
+53 -12
View File
@@ -98,6 +98,18 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/verifier/i,
];
/**
* v1.25 — keys whose VALUE must be kept out of a wide-event excerpt but which
* are too generic to live on the global `SENSITIVE_KEY_PATTERNS` denylist:
* redacting every `note` / `notes` everywhere `redactSensitiveFields` runs
* would over-redact unrelated observability. These apply ONLY on the
* excerpt path via {@link redactForExcerpt}, where a verbatim free-text health
* note (now encrypted at rest) must never land in `received_shape_excerpt`.
* Anchored so only the exact `note` / `notes` keys match — not, say, `noteId`
* or `footnotes`.
*/
export const EXCERPT_ONLY_KEY_PATTERNS: readonly RegExp[] = [/^notes?$/i];
const REDACTED = "[redacted]";
function keyIsSensitive(key: string): boolean {
@@ -107,20 +119,28 @@ function keyIsSensitive(key: string): boolean {
return false;
}
function keyIsExcerptSensitive(key: string): boolean {
for (const re of EXCERPT_ONLY_KEY_PATTERNS) {
if (re.test(key)) return true;
}
return false;
}
/**
* Returns a deep clone of `body` with any key matching the sensitive
* denylist replaced by the literal `"[redacted]"`. Arrays + plain
* objects are recursed into; scalars, `null`, `undefined`, `Date` and
* other non-plain objects pass through unchanged.
*
* The clone is intentionally shallow on non-plain-object boundaries so
* the helper stays cheap on the hot 422 path; the only goal is to keep
* the credentialed key's value out of the eventual JSON excerpt.
* Deep clone of `body` with any key matching `isSensitive` replaced by the
* literal `"[redacted]"`. Arrays + plain objects are recursed into; scalars,
* `null`, `undefined`, `Date` and other non-plain objects pass through
* unchanged. The clone is intentionally shallow on non-plain-object boundaries
* so the helper stays cheap on the hot 422 path; the only goal is to keep the
* matched key's value out of the eventual JSON excerpt.
*/
export function redactSensitiveFields(body: unknown): unknown {
function redactWith(
body: unknown,
isSensitive: (key: string) => boolean,
): unknown {
if (body === null || body === undefined) return body;
if (Array.isArray(body)) {
return body.map((entry) => redactSensitiveFields(entry));
return body.map((entry) => redactWith(entry, isSensitive));
}
if (typeof body !== "object") return body;
// Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll
@@ -131,11 +151,32 @@ export function redactSensitiveFields(body: unknown): unknown {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (keyIsSensitive(key)) {
if (isSensitive(key)) {
out[key] = REDACTED;
continue;
}
out[key] = redactSensitiveFields(value);
out[key] = redactWith(value, isSensitive);
}
return out;
}
/**
* Returns a deep clone of `body` with any key matching the sensitive
* denylist replaced by the literal `"[redacted]"`.
*/
export function redactSensitiveFields(body: unknown): unknown {
return redactWith(body, keyIsSensitive);
}
/**
* v1.25 — excerpt-path redactor. Applies the global sensitive denylist PLUS
* the excerpt-only `note` / `notes` keys, so a free-text health-note value can
* never reach the `received_shape_excerpt` verbatim. Use this — not
* `redactSensitiveFields` — for anything that feeds `buildPayloadDiagnostic`.
*/
export function redactForExcerpt(body: unknown): unknown {
return redactWith(
body,
(key) => keyIsSensitive(key) || keyIsExcerptSensitive(key),
);
}