mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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