fix(api): answer the unknown-leaf refusal with a sentence and a code

`PUT /api/auth/me/report-selection` threw a bare 422 whose whole `error`
string was `report-selection.leaves.unknown` — a machine token in the
field a client shows a person, with nothing beside it to branch on and
no way to learn which leaves were refused. It now answers a sentence,
with the token in `meta.errorCode` and the refused ids in
`meta.unknownLeaves`, and the operation's description says so.

The site was invisible to the refusal guard because the refusal is
decided by `if (!minted.ok)` rather than by a parse result, so the guard
now reads the refused string as well as the deciding block: every 422
under `src/app/api` whose `error` is a dotted token is inventoried with
the reason it still is one. Eleven remain, each with its reason, and the
inventory may only shrink. Removing the fix above turns it red naming
that route.

The four sibling shape refusals keep their token in `error` on purpose,
and the comment now says why that is not the same decision as the seven
`invalid_json` sites: those moved their string because their status
moved to 400 anyway, while a shape refusal that keeps its 422 would be
changing the wire for nothing. Both halves publish `meta.errorCode`.

Two loose ends in the same guard: a block calling `returnAllZodIssues`
or `apiValidationError` without spelling `422` is now judged (their
default status is 422, so a future `refuse422(err)` helper cannot slip
past), and the site floor moves from 40 to 200 against the 248 the tree
carries — the old floor would have survived losing five matches in six.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 08:35:35 +02:00
1 parent f9561395c7
commit b2c8fe8567
9 files changed
+186 -41

No files matched your search

+5 -3
View File
@@ -81,9 +81,11 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.coach-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach-prefs.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
+17 -5
View File
@@ -16,7 +16,7 @@
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
* outside that route ever read.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -65,9 +65,11 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"report-selection.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
@@ -82,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { unknownLeaves: minted.error.unknownLeaves },
});
throw new HttpError(422, "report-selection.leaves.unknown");
// Not a Zod refusal, so there is no issue list to carry — but the same
// rule applies: a sentence in `error`, the machine token in
// `meta.errorCode`, and the leaves that were refused named beside it.
return apiError(
"Report selection names leaves this build does not know",
422,
{
errorCode: "report-selection.leaves.unknown",
unknownLeaves: minted.error.unknownLeaves,
},
);
}
// Persist the canonical ordering rather than the caller's, so two clients
+5 -3
View File
@@ -65,9 +65,11 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"source-priority.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
@@ -93,9 +93,11 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
action: { name: "insights.coach.message.feedback" },
meta: { outcome: "validation_failed" },
});
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"feedback.body.invalid",
sanitiseZodIssues(parsed.error.issues),
+5 -3
View File
@@ -214,9 +214,11 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
action: { name: "insights.coach.invalid" },
meta: { issues: parsed.error.issues.length },
});
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach.request.invalid",
sanitiseZodIssues(parsed.error.issues),