feat(security): encrypt medication free-text notes at rest + password-change MFA step-up

Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.

Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:05:10 +02:00
1 parent bc617adc2c
commit 5ea17398bc
27 files changed
+1111 -60

No files matched your search

@@ -0,0 +1,35 @@
-- v1.25 — encrypt medication free-text notes at rest.
--
-- Adds the AES-256-GCM ciphertext columns for the three medication
-- free-text fields that were still plaintext: the side-effect note
-- (`medication_side_effects.notes`), the dose-change titration note
-- (`medication_dose_changes.note`), and the inventory-item note
-- (`medication_inventory_items.notes`). These were the last plaintext
-- PHI columns left after the v1.23 note-encryption rollout.
--
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
-- encrypted-note convention already in the schema (mood_entries.note_
-- encrypted, measurements.notes_encrypted, illness_day_logs.note_encrypted,
-- labs.note_encrypted, coach_*.{...}_encrypted) — the `encrypt()`
-- ciphertext string stored UTF-8 as `bytea`, the shape the rotation
-- script's `rotateBytesColumn` already covers. All three columns are
-- registered in ENCRYPTED_COLUMNS + the rotation script in the same change.
--
-- This migration is ADDITIVE ONLY. The legacy plaintext columns
-- (`medication_side_effects.notes`, `medication_dose_changes.note`,
-- `medication_inventory_items.notes`) are intentionally NOT dropped here:
-- an idempotent, transactional, fail-closed boot-time job encrypts the
-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the
-- read path can fall back to plaintext for not-yet-migrated rows. Dropping
-- the plaintext columns is a deliberate FOLLOW-UP release, once the backfill
-- reports zero remaining plaintext-but-no-ciphertext rows — the same
-- boundary the encryption-key-rotation playbook uses before dropping a
-- legacy key.
ALTER TABLE "medication_side_effects"
ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA;
ALTER TABLE "medication_dose_changes"
ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA;
ALTER TABLE "medication_inventory_items"
ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA;
+12
View File
@@ -2266,6 +2266,10 @@ model MedicationDoseChange {
/// User-entered titration note ("stepped up after 4 weeks on 5 mg,
/// tolerated 7.5 mg with mild nausea day 2").
note String?
/// v1.25 — AES-256-GCM ciphertext for `note`. The plaintext column
/// stays for read-fallback until the backfill reports zero remaining
/// rows; the plaintext drop is DEFERRED to a later release.
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@ -2357,6 +2361,10 @@ model MedicationInventoryItem {
purchasedAt DateTime? @map("purchased_at")
/// Free-text note, application-layer max 200 chars.
notes String?
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
/// stays for read-fallback until the backfill reports zero remaining
/// rows; the plaintext drop is DEFERRED to a later release.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2413,6 +2421,10 @@ model MedicationSideEffect {
/// application layer. The DB column is unbounded TEXT for
/// consistency with sibling "notes" columns on this schema.
notes String?
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
/// stays for read-fallback until the backfill reports zero remaining
/// rows; the plaintext drop is DEFERRED to a later release.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+27
View File
@@ -421,6 +421,33 @@ async function main() {
),
);
// ───── v1.25 medication free-text notes (Bytes columns) ─────
// "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) +
// "noteEncrypted" (MedicationDoseChange). Same shared-codec shape as the
// other free-text note columns. NULL on rows whose note is still in the
// legacy plaintext column (pre-backfill) — `rotateBytesColumn` skips those.
results.push(
await rotateBytesColumn(
"MedicationSideEffect",
"notesEncrypted",
prisma.medicationSideEffect,
),
);
results.push(
await rotateBytesColumn(
"MedicationDoseChange",
"noteEncrypted",
prisma.medicationDoseChange,
),
);
results.push(
await rotateBytesColumn(
"MedicationInventoryItem",
"notesEncrypted",
prisma.medicationInventoryItem,
),
);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
@@ -28,7 +28,10 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
* Files allowed to write `medicationInventoryItem` rows:
* - the consumption module (intake-driven consume / restore);
* - the inventory persistence helpers (expire cron `updateMany`);
* - the two inventory CRUD routes (register / mutate / delete).
* - the two inventory CRUD routes (register / mutate / delete);
* - the v1.25 note-encryption backfill, whose update only rewrites the
* `notes` / `notesEncrypted` columns (never units / state) and so cannot
* touch the consumption stamp this guard protects.
*/
const ALLOWED_WRITERS = new Set(
[
@@ -36,6 +39,7 @@ const ALLOWED_WRITERS = new Set(
"src/lib/medications/inventory/service.ts",
"src/app/api/medications/[id]/inventory/route.ts",
"src/app/api/medications/[id]/inventory/[itemId]/route.ts",
"src/lib/jobs/med-notes-encryption-backfill.ts",
].map((p) => p.split("/").join(sep)),
);
@@ -4,6 +4,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.25 — password change requires a fresh MFA step-up for enrolled accounts.
// Spread the real module so `apiHandler` keeps working; override the step-up
// guard so each test drives the enrolled / fresh-factor outcome directly.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireFreshMfaIfEnrolled: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
@@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
// Fail-open breach check: a null result (corpus unreachable) never blocks.
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
StepUpRequiredError,
} from "@/lib/api-handler";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +103,44 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// Default: account clears the step-up (no MFA enrolled, or a fresh factor).
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
it("blocks an MFA-enrolled account without a fresh second factor", async () => {
vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue(
new StepUpRequiredError(),
);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(401);
// The credential is never rotated when the step-up fails.
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => {
// vi.resetAllMocks() wiped the factory defaults; restore the success path.
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
confirmPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
});
});
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
+11 -2
View File
@@ -15,7 +15,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.25 — for an account with a second factor active a password change
// requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition
// to the current-password re-proof below, so a hijacked live session cannot
// rotate the credential. Accounts without MFA keep the current-password-only
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`auth:password:${user.id}`,
+3 -5
View File
@@ -29,7 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id the layout accepts. The
@@ -211,13 +211,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// body is routed through `redactForExcerpt` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: {
+2 -1
View File
@@ -12,6 +12,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readNote } from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
const DEFAULT_LIMIT = 60;
@@ -131,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
doseDelta: delta,
note: dc.note,
note: readNote(dc.noteEncrypted, dc.note),
});
}
for (const intake of med.intakeEvents) {
+2 -4
View File
@@ -35,7 +35,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
@@ -129,9 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// round-trip; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "insights.layout.validation-failed" },
meta: {
+3 -3
View File
@@ -20,7 +20,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { VALUE_RANGES } from "@/lib/validations/measurement";
@@ -158,11 +158,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// do not currently accept any but the truncation keeps that
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// query map is routed through `redactForExcerpt` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(rawQuery),
redactForExcerpt(rawQuery),
);
annotate({
action: { name: "measurements.series.validation-failed" },
+10 -3
View File
@@ -39,6 +39,11 @@ import {
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
encryptNote,
readNote,
shapeDoseChangeNote,
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -193,7 +198,7 @@ export const GET = apiHandler(
effectiveFrom: dc.effectiveFrom.toISOString(),
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
note: dc.note,
note: readNote(dc.noteEncrypted, dc.note),
})),
recentIntakes: medication.intakeEvents.map((iv) => ({
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
@@ -261,7 +266,9 @@ export const POST = apiHandler(
effectiveFrom,
doseValue,
doseUnit,
note: note ?? null,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
@@ -291,7 +298,7 @@ export const POST = apiHandler(
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ doseChange: created }, 201);
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
@@ -37,6 +37,7 @@ import {
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -142,6 +143,14 @@ export const PATCH = apiHandler(
Date.now(),
);
// Only touch the note columns when the caller supplied a value; an absent
// `notes` leaves the existing ciphertext untouched. When present, encrypt
// at rest and null the plaintext column.
const notesData =
notes === undefined
? {}
: { notesEncrypted: encryptNote(notes), notes: null };
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -150,7 +159,7 @@ export const PATCH = apiHandler(
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
notes: notes === undefined ? existing.notes : notes,
...notesData,
},
});
@@ -180,7 +189,7 @@ export const PATCH = apiHandler(
// read instead of after the `cachedSwr` stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(updated));
return apiSuccess(serializeInventoryItem(shapeInventoryItemNotes(updated)));
},
);
@@ -35,6 +35,7 @@ import {
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string }> };
@@ -60,7 +61,11 @@ export const GET = apiHandler(
}),
]);
const serialized = items.map(serializeInventoryItem);
// Decrypt each note and strip the raw `notesEncrypted` ciphertext before
// the decimal-unit serialisation runs.
const serialized = items.map((item) =>
serializeInventoryItem(shapeInventoryItemNotes(item)),
);
// v1.19.0 (iOS#25) — the canonical supply summary is computed HERE,
// server-side, through the one source of truth (`summariseSupply`).
// The detail-page client renders these ready figures instead of
@@ -162,6 +167,9 @@ export const POST = apiHandler(
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(created), 201);
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
},
);
@@ -57,6 +57,13 @@ import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
// The POST path encrypts the free-text note at rest; drive the legacy single
// key so encryptNote has material to work with.
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -233,17 +240,33 @@ describe("POST /api/medications/[id]/side-effects", () => {
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
notes: "after breakfast",
}),
const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as {
data: {
userId: string;
medicationId: string;
category: string;
entry: string;
severity: number;
occurredAt: Date;
notes: string | null;
notesEncrypted: Uint8Array | null;
};
};
expect(createArgs.data).toMatchObject({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
// v1.25 — the plaintext column is nulled; the note lands encrypted.
notes: null,
});
expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array);
expect(readNote(createArgs.data.notesEncrypted, null)).toBe(
"after breakfast",
);
});
it("defaults occurredAt to now when omitted", async () => {
@@ -36,6 +36,7 @@ import {
} from "@/lib/medications/side-effects/validators";
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string }> };
@@ -85,7 +86,12 @@ export const GET = apiHandler(
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
// Decrypt each note and strip the raw `notesEncrypted` ciphertext so it
// never leaves the server.
return apiSuccess({
items: items.map(shapeSideEffectNotes),
meta: { total: items.length },
});
},
);
@@ -139,7 +145,9 @@ export const POST = apiHandler(
entry,
severity,
occurredAt: occurredAt ?? new Date(),
notes: notes ?? null,
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
@@ -163,6 +171,6 @@ export const POST = apiHandler(
meta: { medication_id: id, entry, severity },
});
return apiSuccess(created, 201);
return apiSuccess(shapeSideEffectNotes(created), 201);
},
);
+2 -4
View File
@@ -34,7 +34,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
@@ -92,9 +92,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// layout routes; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "medication.layout.validation-failed" },
meta: {
+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";
@@ -1244,7 +1245,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;
}
@@ -586,4 +656,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),
);
}