From cf27df49c6367aeedb33617780e53edb0c0fb028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 19:55:30 +0200 Subject: [PATCH] feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three handlers at /api/auth/me/research-mode gate the opt-in GLP-1 PK research-view chart behind an MDR-disclaimer acknowledgment. GET — returns the persisted flag/version + the live currentDisclaimerVersion so the Settings UI can detect copy drift and re-prompt. POST — { acknowledged: true, version } turns the flag on. Rejects 400 when the submitted version does not match the server-side RESEARCH_MODE_DISCLAIMER_VERSION; rejects 422 on malformed body. Per-user rate-limit 5/min — this is a one-tap dialog, not a hot path. DELETE — idempotent: clears all three columns and writes an audit row even when the flag is already off. Prompt-injection guard — the body shape accepts the version string only (not free text); the value is matched against a server-side constant before any DB write and never reaches the LLM. Hand-rolled validation keeps the surface visibly tiny in code review. 11 route tests cover unauth/enabled/disabled GET; happy-path POST with audit; stale-version POST; malformed POST; rate-limited POST; unauth/successful/idempotent DELETE with audit. --- .../me/research-mode/__tests__/route.test.ts | 304 ++++++++++++++++++ src/app/api/auth/me/research-mode/route.ts | 218 +++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 src/app/api/auth/me/research-mode/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/research-mode/route.ts diff --git a/src/app/api/auth/me/research-mode/__tests__/route.test.ts b/src/app/api/auth/me/research-mode/__tests__/route.test.ts new file mode 100644 index 000000000..4de7c44ca --- /dev/null +++ b/src/app/api/auth/me/research-mode/__tests__/route.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE, GET, POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function mkPost(body: unknown): Request { + return new Request("http://localhost/api/auth/me/research-mode", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +function mkDelete(): Request { + return new Request("http://localhost/api/auth/me/research-mode", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/research-mode", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/research-mode"), + ); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns the disabled default for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/research-mode"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { + enabled: boolean; + acknowledgedAt: string | null; + acknowledgedVersion: string | null; + currentDisclaimerVersion: string; + }; + }; + expect(env.data.enabled).toBe(false); + expect(env.data.acknowledgedAt).toBeNull(); + expect(env.data.acknowledgedVersion).toBeNull(); + expect(env.data.currentDisclaimerVersion).toBe( + RESEARCH_MODE_DISCLAIMER_VERSION, + ); + }); + + it("returns the enabled state with the persisted version", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const ackAt = new Date("2026-05-14T10:00:00Z"); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + researchModeEnabled: true, + researchModeAcknowledgedAt: ackAt, + researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/research-mode"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { + enabled: boolean; + acknowledgedAt: string; + acknowledgedVersion: string; + currentDisclaimerVersion: string; + }; + }; + expect(env.data.enabled).toBe(true); + expect(env.data.acknowledgedAt).toBe(ackAt.toISOString()); + expect(env.data.acknowledgedVersion).toBe( + RESEARCH_MODE_DISCLAIMER_VERSION, + ); + }); +}); + +describe("POST /api/auth/me/research-mode", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (POST as (r: Request) => Promise)( + mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("enables Research Mode and writes the audit log on a valid ack", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (POST as (r: Request) => Promise)( + mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { enabled: boolean; acknowledgedVersion: string }; + }; + expect(env.data.enabled).toBe(true); + expect(env.data.acknowledgedVersion).toBe( + RESEARCH_MODE_DISCLAIMER_VERSION, + ); + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "user-1" }, + data: expect.objectContaining({ + researchModeEnabled: true, + researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + }), + }), + ); + + expect(auditLog).toHaveBeenCalledWith( + "user.research-mode.enable", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + next: expect.objectContaining({ + researchModeEnabled: true, + researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + }), + }), + }), + ); + }); + + it("rejects a stale disclaimer version with 400", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (POST as (r: Request) => Promise)( + mkPost({ acknowledged: true, version: "1970-01-01.0" }), + ); + expect(res.status).toBe(400); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }); + + it("rejects a body without acknowledged=true with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (POST as (r: Request) => Promise)( + mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await (POST as (r: Request) => Promise)( + mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/auth/me/research-mode", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (DELETE as (r: Request) => Promise)(mkDelete()); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("disables Research Mode and clears the acknowledgment", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + researchModeEnabled: true, + researchModeAcknowledgedAt: new Date(), + researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (DELETE as (r: Request) => Promise)(mkDelete()); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { + enabled: boolean; + acknowledgedAt: string | null; + acknowledgedVersion: string | null; + }; + }; + expect(env.data.enabled).toBe(false); + expect(env.data.acknowledgedAt).toBeNull(); + expect(env.data.acknowledgedVersion).toBeNull(); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + }, + }); + + expect(auditLog).toHaveBeenCalledWith( + "user.research-mode.disable", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + next: { + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + }, + }), + }), + ); + }); + + it("is idempotent when Research Mode is already off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (DELETE as (r: Request) => Promise)(mkDelete()); + expect(res.status).toBe(200); + // The route writes the canonical off-state even when the + // column is already off — guarantees audit trail symmetry. + expect(prisma.user.update).toHaveBeenCalledTimes(1); + expect(auditLog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/auth/me/research-mode/route.ts b/src/app/api/auth/me/research-mode/route.ts new file mode 100644 index 000000000..fc8372599 --- /dev/null +++ b/src/app/api/auth/me/research-mode/route.ts @@ -0,0 +1,218 @@ +/** + * v1.4.25 W19c — Research Mode acknowledgment endpoint. + * + * GET /api/auth/me/research-mode — returns the current flag, + * the timestamp + version of + * the last acknowledgment, and + * the live disclaimer version + * so the Settings UI can + * compare and re-prompt. + * POST /api/auth/me/research-mode — body { acknowledged: true, + * version }. Flips the flag + * on. Rejects 400 if `version` + * is not the live version. + * DELETE /api/auth/me/research-mode — disables Research Mode and + * clears the acknowledgment. + * Idempotent. + * + * Per-user POST rate-limit is intentionally low (5/min) — this is a + * one-tap acknowledgment dialog, not a hot path. Anything above that + * traffic looks like a malformed client retry-loop or an exploit. + * + * Prompt-injection surface — the API accepts the disclaimer + * `version` string and nothing else from the client. The version + * value never reaches the LLM; it is matched against the + * server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then + * persisted as the stamp on the user row. No free-text body. + * + * Audit-log entries fire on every state-changing call (POST, DELETE) + * with the previous + next state, mirroring the doctor-report-prefs + * route's reconcile pattern (W10 security M-3). + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk"; + +export const dynamic = "force-dynamic"; + +const POST_RATE_LIMIT = 5; +const POST_WINDOW_MS = 60_000; + +type ResearchModeResponse = { + enabled: boolean; + acknowledgedAt: string | null; + acknowledgedVersion: string | null; + currentDisclaimerVersion: string; +}; + +function shape(row: { + researchModeEnabled: boolean; + researchModeAcknowledgedAt: Date | null; + researchModeAcknowledgedVersion: string | null; +}): ResearchModeResponse { + return { + enabled: row.researchModeEnabled, + acknowledgedAt: row.researchModeAcknowledgedAt + ? row.researchModeAcknowledgedAt.toISOString() + : null, + acknowledgedVersion: row.researchModeAcknowledgedVersion, + currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.research-mode.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + researchModeEnabled: true, + researchModeAcknowledgedAt: true, + researchModeAcknowledgedVersion: true, + }, + }); + // `requireAuth` already proved the user exists; defensive default + // here only matters for the (impossible-in-practice) race where + // the user is deleted mid-request. + return apiSuccess( + shape( + row ?? { + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + }, + ), + ); +}); + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + // Per-user rate-limit. 5/min is generous for a once-per-update + // acknowledgment dialog; anything more looks like a retry loop. + const rl = await checkRateLimit( + `research-mode:post:${user.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "research-mode.body.invalid_json"); + } + + // Hand-rolled validation — the body is tiny (two fields), and + // staying off Zod keeps the prompt-injection surface visibly + // simple in a code review. + if ( + !body || + typeof body !== "object" || + (body as { acknowledged?: unknown }).acknowledged !== true || + typeof (body as { version?: unknown }).version !== "string" + ) { + annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } }); + throw new HttpError(422, "research-mode.body.invalid_shape"); + } + + const submittedVersion = (body as { version: string }).version; + + // Stale-version guard — the dialog must show the *live* copy at + // the moment of acknowledgment. A stale version means the user + // is acknowledging older wording; we refuse and force the + // Settings UI to re-render with the new disclaimer. + if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) { + annotate({ + action: { name: "auth.me.research-mode.post.stale_version" }, + meta: { + submitted: submittedVersion, + current: RESEARCH_MODE_DISCLAIMER_VERSION, + }, + }); + throw new HttpError(400, "research-mode.version.stale"); + } + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + researchModeEnabled: true, + researchModeAcknowledgedAt: true, + researchModeAcknowledgedVersion: true, + }, + }); + + const next = { + researchModeEnabled: true, + researchModeAcknowledgedAt: new Date(), + researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, + }; + await prisma.user.update({ + where: { id: user.id }, + data: next, + }); + + await auditLog("user.research-mode.enable", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous ?? null, + next: { + ...next, + researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(), + }, + }, + }); + + annotate({ + action: { name: "auth.me.research-mode.post" }, + meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION }, + }); + return apiSuccess(shape(next)); +}); + +export const DELETE = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + researchModeEnabled: true, + researchModeAcknowledgedAt: true, + researchModeAcknowledgedVersion: true, + }, + }); + + // Idempotent — if Research Mode is already off, we still write + // the canonical "off" state so the audit row exists. + const next = { + researchModeEnabled: false, + researchModeAcknowledgedAt: null, + researchModeAcknowledgedVersion: null, + }; + await prisma.user.update({ + where: { id: user.id }, + data: next, + }); + + await auditLog("user.research-mode.disable", { + userId: user.id, + ipAddress: getClientIp(req), + details: { previous: previous ?? null, next }, + }); + + annotate({ action: { name: "auth.me.research-mode.delete" } }); + return apiSuccess(shape(next)); +});