From 200cb5b99640f2c975adfd4d39421b4556c84957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 03:57:39 +0200 Subject: [PATCH] fix(ai): gate medication free-text extraction on active consent The medication NL-extraction route egresses user-typed free text (PHI) to the operator's server-managed provider key; require an active consent receipt for the coach surface before the chain runs, matching the other server-managed egress sites. --- .../medications/extract/__tests__/route.test.ts | 14 ++++++++++++++ src/app/api/medications/extract/route.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index a570df213..34006be4a 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -43,6 +43,14 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: vi.fn(async () => []), })); +// v1.12.1 — free-text medication extraction egresses PHI to the operator's +// server-managed key, so it now passes through the consent gate. Mock it to a +// no-op here; the gate's own fail-closed behaviour is covered by +// `consent-guard.test.ts`. +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); + vi.mock("@/lib/ai/provider-runner", async () => { const actual = await vi.importActual( @@ -69,6 +77,7 @@ import { resolveProviderChain, } from "@/lib/ai/provider"; import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; function postReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/medications/extract", { @@ -175,6 +184,11 @@ describe("POST /api/medications/extract — happy path", () => { doseUnit: "mg", cadenceKind: "everyNWeeks", }); + // The PHI free-text egress is gated on an active consent receipt for the + // coach surface before the provider chain runs. + expect(assertConsentForChain).toHaveBeenCalledWith( + expect.objectContaining({ surface: "coach" }), + ); }); it("strips a hallucinated name that is absent from the user's text", async () => { diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 34994079d..901456274 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -57,6 +57,7 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { buildDateKey, @@ -167,6 +168,11 @@ async function handleExtract(request: NextRequest): Promise { chain.push({ providerType: "admin-openai", instance: legacy }); } + // Free-text medication input is PHI. If the resolved chain could egress + // via the operator's server-managed key, an active consent receipt is + // required first — same gate as the Coach (which shares this chain). + await assertConsentForChain({ userId, chain, surface: "coach" }); + const today = todayOverride ?? buildDateKey(); const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ text,