fix(mental-health): repair the dead-on-arrival assessment POST

The handler passed the `safeJson` `{ data, error }` wrapper straight into
`safeParse`, so the schema never matched and every POST returned 422 — the
screener was dead on arrival. Destructure the envelope and read an unbounded
body no more: cap the request at 8 KB before validation, matching the sibling
clinical routes.

Add the missing write protections: gate both verbs on the opt-in module
server-side (a valid Bearer token included), wrap the create in idempotency so
a double-tap/retry cannot mint duplicate assessment + score rows, and add a
tight per-user write rate-limit.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:25:43 +02:00
1 parent f62ba50ff0
commit be4bc3bd10
1 file changed
+51 -3
+51 -3
View File
@@ -19,6 +19,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
@@ -26,6 +27,9 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
createAssessmentSchema,
@@ -73,6 +77,11 @@ function shapeRow(row: {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Opt-in module (default OFF): the screener history is unreachable until the
// account turns the mental-health module on. Enforced server-side even for a
// valid Bearer token (the nav entry / page redirect are UX-only).
const gate = await requireModuleEnabled(user.id, "mentalHealth");
if (!gate.enabled) return gate.response;
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listAssessmentsSchema.safeParse(params);
if (!parsed.success) {
@@ -112,9 +121,48 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({ assessments: rows.map(shapeRow) });
});
export const POST = apiHandler(async (request: NextRequest) => {
// `withIdempotency` lets a double-tap / retry re-send the same
// `Idempotency-Key` without minting a duplicate assessment row AND a duplicate
// `*_SCORE` Measurement row that would skew the trend (the allergy / labs
// create precedent).
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment));
async function postAssessment(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const body = await safeJson(request);
// Opt-in module (default OFF) — same gate as GET.
const gate = await requireModuleEnabled(user.id, "mentalHealth");
if (!gate.enabled) return gate.response;
// Write rate-limit: a screener is a deliberate, infrequent action, so the
// bucket is tight — it only caps a double-tap / scripted loop minting
// duplicate trend points (the recall window is 2 weeks; 30/hour is generous).
const rl = await checkRateLimit(
`mental-health-create:${user.id}`,
30,
60 * 60 * 1000,
);
if (!rl.allowed) {
const res = apiError(
"Too many check-ins. Please wait before submitting another.",
429,
{ errorCode: "mentalHealth.rateLimited" },
);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
// Bounded body read BEFORE validation (the item answers are small; cap so an
// unbounded body can never reach `request.json()`). Destructure the
// `{ data, error }` envelope — passing the wrapper to `safeParse` would never
// match the schema, which is what left this route dead-on-arrival.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
annotate({
@@ -210,4 +258,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
});
}