chore(release): v1.4.25

Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-14 23:49:32 +02:00
1 parent de0689e419
commit 17b8d8d2c2
528 files changed
+104033 -6524

No files matched your search

@@ -0,0 +1,191 @@
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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
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 mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/doctor-report-prefs", () => {
it("returns defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
});
});
describe("PUT /api/auth/me/doctor-report-prefs", () => {
// ── Case 1 — happy path ─────────────────────────────────────────────
it("persists the full shape and returns the canonical form", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = {
bp: true,
weight: true,
pulse: false,
bmi: true,
mood: true,
compliance: true,
sleep: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
expect(env.data).toEqual(body);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: body },
});
});
// ── Case 2 — partial update layers over current persisted row ───────
it("merges a partial update over the persisted row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: true,
compliance: false,
sleep: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// User only flips mood off — every other key keeps its persisted value.
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
expect(env.data).toEqual({ ...current, mood: false });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: { ...current, mood: false } },
});
});
// ── Case 3 — invalid shape ─────────────────────────────────────────
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ bp: "nope" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
it("writes an audit-log entry with the previous and new pref shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false,
compliance: true,
sleep: true,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: true }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.doctor-report-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: current,
next: expect.objectContaining({ mood: true }),
}),
}),
);
});
});
@@ -0,0 +1,112 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
* (defaults when the row is null).
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
* shape over the current row
* and persists the canonical
* fully-resolved object so a
* future schema migration
* doesn't need to back-fill
* missing keys.
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The doctor-report aggregator + PDF renderer both read this
* row at generation time so mood data is dropped at the aggregator
* layer when `mood = false` — the data never leaves the DB.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
doctorReportPrefsSchema,
parseDoctorReportPrefs,
resolveDoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
}
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
}
// Merge the partial input over the current row (or defaults) so a
// dialog that only toggles `mood` doesn't have to re-state every other
// flag. Persist the canonical fully-resolved object so the column
// shape stays stable across future schema additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
const merged = resolveDoctorReportPrefs(
current?.doctorReportPrefsJson,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
// index signature so cast through `unknown` rather than widen the
// typed return surface. Shape is identical to the validated input.
data: {
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
},
});
// v1.4.25 W10 reconcile (security M-3): record the previous + new
// pref shape in the audit log. Doctor-Report pref toggles
// (especially `mood: true`) widen the PDF surface; without an
// audit trail a silent compromise (or unintended client write)
// is invisible. Mirrors the timezone-route audit pattern.
await auditLog("user.doctor-report-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: current?.doctorReportPrefsJson ?? null,
next: merged,
},
});
annotate({
action: { name: "auth.me.doctor-report-prefs.put" },
meta: {
bp: merged.bp,
weight: merged.weight,
pulse: merged.pulse,
bmi: merged.bmi,
mood: merged.mood,
compliance: merged.compliance,
sleep: merged.sleep,
},
});
return apiSuccess(merged);
});
@@ -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));
});
@@ -0,0 +1,138 @@
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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -0,0 +1,94 @@
/**
* v1.4.25 W5e — per-user, per-metric-class source priority.
*
* GET /api/auth/me/source-priority — returns the fully-defaulted
* shape (missing keys filled
* from `DEFAULT_SOURCE_PRIORITY`).
* PUT /api/auth/me/source-priority — replaces the persisted shape;
* body is validated against
* `sourcePrioritySchema` and
* persisted as the partial form
* (missing keys keep the default
* at read time).
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The analytics aggregator reads this row on every call and
* the read path is cheap (one column) — no caching layer to flush.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
parseSourcePriority,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
// before overwriting so the audit-log entry records the actual
// delta. Source-priority drives every aggregator's source pick,
// so a silent compromise (or accidental client write) was
// previously invisible — mirroring the timezone-route audit
// pattern closes the gap.
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
// Persist the partial form — missing keys read as defaults via
// `parseSourcePriority`. Storing only the user-edited subset keeps
// the Json blob narrow and future-proofs the shape: when a new
// metric class is added, every existing row reads the new key's
// default until the user explicitly changes it.
await prisma.user.update({
where: { id: user.id },
data: { sourcePriorityJson: parsed.data },
});
await auditLog("user.source-priority.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: before?.sourcePriorityJson ?? null,
next: parsed.data,
},
});
annotate({
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
});
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: 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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* PUT /api/auth/me/timezone
*
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
* out of `PUT /api/auth/profile` so the picker UI can post the
* single field without round-tripping email/heightCm/etc, and so
* the change pipeline (validate → write → invalidate-cache) is
* isolated from the heavier profile patch.
*
* Auth: cookie session OR Bearer token.
* Audit: `user.timezone.update` with the old + new zone.
*
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
* accept any string the runtime's `Intl.DateTimeFormat` constructor
* accepts — i.e. every IANA zone the active Node engine knows about.
* Rejecting unknown zones at the API surface stops a malformed
* client from poisoning the column with a value the resolver
* would later have to defend against.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const tz =
body && typeof (body as { timezone?: unknown }).timezone === "string"
? ((body as { timezone: string }).timezone || "").trim()
: "";
if (!isValidTimezone(tz)) {
return apiError("Not a valid IANA timezone.", 422);
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
await prisma.user.update({
where: { id: user.id },
data: { timezone: tz },
});
invalidateUserTimezone(user.id);
await auditLog("user.timezone.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: before?.timezone ?? null,
next: tz,
},
});
annotate({
meta: {
timezone_previous: before?.timezone ?? null,
timezone_next: tz,
},
});
return apiSuccess({ timezone: tz });
});
+21 -2
View File
@@ -15,6 +15,10 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -59,7 +63,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { email, username, password } = parsed.data;
const { email, username, password, timezone: timezoneInput } = parsed.data;
// v1.4.25 W7 — accept a browser-detected timezone from the
// registration form. Validate against the runtime IANA list; on
// any invalid value (or absence), fall back to the admin-
// configured server default. The `User.timezone` column has a
// hard-coded "Europe/Berlin" default at the schema layer, so the
// worst-case chain still produces a usable string.
let timezone: string;
const trimmedTimezone = timezoneInput?.trim() ?? "";
if (trimmedTimezone && isValidTimezone(trimmedTimezone)) {
timezone = trimmedTimezone;
} else {
timezone = await resolveServerDefaultTimezone();
}
// Check if email or username already taken (unified message to prevent enumeration)
const [existingEmail, existingUsername] = await Promise.all([
@@ -91,6 +109,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
username,
passwordHash,
role,
timezone,
},
});
@@ -104,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
await auditLog("auth.register", {
userId: user.id,
ipAddress: ip,
details: { method: "password" },
details: { method: "password", timezone },
});
annotate({ action: { name: "auth.register" } });