feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE)

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 19:55:30 +02:00
1 parent 36d147e4f4
commit cf27df49c6
2 files changed
+522

No files matched your search

@@ -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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(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<Response>)(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);
});
});
+218
View File
@@ -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));
});