fix(api): answer an unparseable body with 400 everywhere

`safeJson` refuses a body that will not parse with 400, and so does
`apiHandler`'s own SyntaxError branch — about two hundred and twenty
routes inherit that. Seven `/api/auth/me/*` writes hand-rolled the
parse in a try/catch and threw 422 for the identical condition, so a
client's "my serializer produced garbage" branch had to accept two
statuses on a subset of routes it had no way to predict.

All seven answer 400 now, with the sentence `safeJson` already sends
and the dotted token each one carried moved to `meta.errorCode`. The
400 is published on those seven operations through a shared response;
it is deliberately not folded into the standard set, which is spread
onto reads as well and a GET has no body to refuse.

`/api/auth/me/health-score-config` keeps its 422: the operation's own
description names that status and its exact message, so moving it is a
contract decision rather than a cleanup. The guard carries it as an
allowlist entry with that reason rather than quietly skipping it.

Three route tests pinned the old status; they assert the new one and
say why. The guard reads the hand-rolled parses out of the tree and
asserts it still finds them, so it cannot pass on a matcher that
stopped matching.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:26:32 +02:00
1 parent 9ce0bd5027
commit 3e44d68518
15 files changed
+434 -191

No files matched your search

+5 -2
View File
@@ -15,8 +15,9 @@
* helper. The Coach prompt builder + snapshot builder both read this
* row on every turn — there's no caching layer to invalidate.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
@@ -61,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "coach-prefs.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "coach-prefs.body.invalid_json",
});
}
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
+1 -1
View File
@@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
return apiError("Invalid JSON body", 422, {
return apiError("Invalid JSON body", 400, {
errorCode: "cycle-prefs.body.invalid_json",
});
}
@@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/disable-coach", {
@@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "disable-coach.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
+4 -2
View File
@@ -21,7 +21,7 @@
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "disable-coach.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "disable-coach.body.invalid_json",
});
}
const parsed = patchBodySchema.safeParse(body);
@@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/modules", {
method: "PATCH",
@@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => {
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "modules.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
+4 -2
View File
@@ -25,7 +25,7 @@
* carries it. The semantics are a DISABLED allowlist: a key set to
* `false` disables that module; `true` (or absence) leaves it enabled.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "modules.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "modules.body.invalid_json",
});
}
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
@@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
);
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
@@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "notification-prefs.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
@@ -23,7 +23,7 @@
* extra round-trip. Rate-limit is intentionally generous (60/min) —
* matches the disable-coach route which is the closest analogue.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "notification-prefs.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "notification-prefs.body.invalid_json",
});
}
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
@@ -18,6 +18,7 @@
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
@@ -53,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "report-selection.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "report-selection.body.invalid_json",
});
}
const parsed = savedReportProfileSchema.safeParse(body ?? {});
+5 -2
View File
@@ -18,8 +18,9 @@
* decides which source's rows the cached targets / derived / analytics
* payloads were built from.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
@@ -53,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "source-priority.body.invalid_json",
});
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});