mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'wave/v125-sec' into release/v1.25.0
This commit is contained in:
commit
52e8dab3d7
27 files changed
+1111
-60
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user