fix(api): read the error code before the status

A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:48:15 +02:00
1 parent 0057b03699
commit c300574a8e
20 files changed
+212 -128

No files matched your search

@@ -24,9 +24,9 @@ import { apiHandler, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
defaultErrorCodeForStatus,
withErrorEnvelopeDefaults,
} from "@/lib/api-response";
import { defaultErrorCodeForStatus } from "@/lib/api/default-error-codes";
const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001";
+3 -47
View File
@@ -2,6 +2,8 @@ import { isIP } from "node:net";
import { NextResponse } from "next/server";
import type { ZodError, ZodIssue } from "zod/v4";
import { defaultErrorCodeForStatus } from "@/lib/api/default-error-codes";
export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
@@ -308,52 +310,6 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* The `meta.errorCode` an error envelope carries when its route named none.
*
* `meta.errorCode` is what a client is told to branch on, and roughly half of
* the refusals in the tree carried one: the rest answered prose alone, so a
* client could branch on the code for one 404 and had to fall back to the
* status for the next. Every error envelope now has a code. A route that names
* its own keeps it; one that does not gets the status's code from this table,
* so the generic family is predictable from the status alone and a client
* never has to special-case an absent field.
*
* Deliberately coarse. These say which kind of refusal it was, never why —
* a route that has a reason worth branching on names its own code. Statuses
* not listed fall back to `http.client_error` / `http.server_error`.
*/
export const DEFAULT_ERROR_CODES = {
400: "http.bad_request",
401: "http.unauthenticated",
403: "http.forbidden",
404: "http.not_found",
405: "http.method_not_allowed",
409: "http.conflict",
410: "http.gone",
413: "http.payload_too_large",
415: "http.unsupported_media_type",
422: "http.invalid",
429: "http.rate_limited",
500: "http.internal",
502: "http.bad_gateway",
503: "http.unavailable",
504: "http.gateway_timeout",
client: "http.client_error",
server: "http.server_error",
} as const;
/** The default code for an error status, see {@link DEFAULT_ERROR_CODES}. */
export function defaultErrorCodeForStatus(status: number): string {
const listed = (DEFAULT_ERROR_CODES as Record<number, string | undefined>)[
status
];
if (listed) return listed;
return status >= 500
? DEFAULT_ERROR_CODES.server
: DEFAULT_ERROR_CODES.client;
}
/**
* Complete an error envelope: `meta.requestId` always, and `meta.errorCode`
* from the status when the route named none.
@@ -362,7 +318,7 @@ export function defaultErrorCodeForStatus(status: number): string {
* envelope carries the full request id beside the `x-request-id` header, so a
* client behind a proxy that strips headers can still show the id a person
* reads out to the operator, who finds the event in the admin log with it.
* The code follows {@link DEFAULT_ERROR_CODES}.
* The code follows `DEFAULT_ERROR_CODES` (`src/lib/api/default-error-codes.ts`).
*
* Applied once, by `apiHandler`, to whatever the handler answered, so a route
* that builds its error with `apiError`, with `NextResponse.json` by hand or by
+45
View File
@@ -0,0 +1,45 @@
/**
* The `meta.errorCode` an error envelope carries when its route named none.
*
* `meta.errorCode` is what a client is told to branch on, and roughly half of
* the refusals in the tree carried one: the rest answered prose alone, so a
* client could branch on the code for one 404 and had to fall back to the
* status for the next. Every error envelope now has a code. A route that names
* its own keeps it; one that does not gets the status's code from this table,
* so the generic family is predictable from the status alone and a client
* never has to special-case an absent field.
*
* Deliberately coarse. These say which kind of refusal it was, never why —
* a route that has a reason worth branching on names its own code. Statuses
* not listed fall back to `http.client_error` / `http.server_error`.
*/
export const DEFAULT_ERROR_CODES = {
400: "http.bad_request",
401: "http.unauthenticated",
403: "http.forbidden",
404: "http.not_found",
405: "http.method_not_allowed",
409: "http.conflict",
410: "http.gone",
413: "http.payload_too_large",
415: "http.unsupported_media_type",
422: "http.invalid",
429: "http.rate_limited",
500: "http.internal",
502: "http.bad_gateway",
503: "http.unavailable",
504: "http.gateway_timeout",
client: "http.client_error",
server: "http.server_error",
} as const;
/** The default code for an error status, see {@link DEFAULT_ERROR_CODES}. */
export function defaultErrorCodeForStatus(status: number): string {
const listed = (DEFAULT_ERROR_CODES as Record<number, string | undefined>)[
status
];
if (listed) return listed;
return status >= 500
? DEFAULT_ERROR_CODES.server
: DEFAULT_ERROR_CODES.client;
}
+36
View File
@@ -1,3 +1,6 @@
import { AUTH_ERROR_CODES, REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
import { ApiError } from "./api-fetch";
/**
@@ -12,12 +15,45 @@ import { ApiError } from "./api-fetch";
* - `throttled`: 429. The read budget is spent for now; an immediate retry
* would only be refused again.
* - `failed`: everything else, a real read failure worth a retry.
*
* The code decides before the status. A 401 is not always an ended session:
* a step-up or re-proof gate answers 401 too, with the session intact, and
* sending that reader to sign-in would end a session that was fine. So a
* code that names a missing proof reads as `failed`, a code that names an
* ended credential reads as `unauthenticated` whatever the status, and only
* a code this table does not know (or none, from a proxy or an older server)
* falls back to the status.
*/
export type ReadFailureKind =
"unauthenticated" | "refused" | "throttled" | "failed";
/** Codes that say the credential itself is gone: sign in again. */
const SESSION_ENDED_CODES: ReadonlySet<string> = new Set([
AUTH_ERROR_CODES.missing,
AUTH_ERROR_CODES.expired,
AUTH_ERROR_CODES.invalid,
DEFAULT_ERROR_CODES[401],
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
]);
/** Codes that ask for a fresh proof on a session that is still good. */
const PROOF_NEEDED_CODES: ReadonlySet<string> = new Set([
"auth.stepup.required",
REPROOF_REQUIRED_CODE,
"auth.reproof.failed",
"auth.reproof.too_weak",
AUTH_ERROR_CODES.mfaCode,
]);
export function readFailureKind(error: unknown): ReadFailureKind {
if (error instanceof ApiError) {
const code = error.meta?.errorCode;
if (typeof code === "string") {
if (SESSION_ENDED_CODES.has(code)) return "unauthenticated";
if (PROOF_NEEDED_CODES.has(code)) return "failed";
}
if (error.status === 401) return "unauthenticated";
if (error.status === 403) return "refused";
if (error.status === 429) return "throttled";
+2 -1
View File
@@ -1,4 +1,4 @@
import { DEFAULT_ERROR_CODES } from "@/lib/api-response";
import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
/**
* Every `meta.errorCode` the API emits, grouped by the surface that emits it.
@@ -412,6 +412,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"sharing.invite.invalid",
"sharing.invite.invalid_scope",
"sharing.invite.manage_browser_only",
"sharing.invite.no_account",
"sharing.invite.refused",
"sharing.invite.self",
"sharing.not_permitted",
+7 -6
View File
@@ -945,7 +945,8 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "No account on this instance carries that identifier.",
description:
"No account on this instance carries that identifier (`meta.errorCode: sharing.invite.no_account`).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
@@ -1099,7 +1100,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"429": {
description:
"More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none anywhere in this API; branch on the status. Nothing was created.",
"More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status default; a 429 has one meaning here, so the status alone is enough to branch on). Nothing was created.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -1167,7 +1168,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"429": {
description:
"More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.",
"More than ten edits from one caller within the hour — the ceiling creation carries. `meta.errorCode: http.rate_limited`, the status default. Nothing was changed, and the refusal lands before the record named in the path is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -1239,7 +1240,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
summary: "Invite another Guardian",
description:
"Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A client branching on the status first would tell somebody to check a spelling when the real answer is that the record is no longer theirs to administer.",
"Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on `meta.errorCode`, not on the status**, because this route answers 404 twice and the two need different next steps: `sharing.invite.no_account` means no account carries that identifier, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A client branching on the status would tell somebody to check a spelling when the real answer is that the record is no longer theirs to administer.",
requestParams: {
path: z.object({ id: z.string() }),
},
@@ -1272,7 +1273,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"404": {
description:
"Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is not one of its Guardians.",
"Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST /api/account/grants` makes, with the same code, so this family does not answer one way here and another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is not one of its Guardians.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
@@ -1287,7 +1288,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"429": {
description:
"More than ten invitations from one caller within the hour. No error code, as above; branch on the status.",
"More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status default).",
content: { "application/json": { schema: errorEnvelope } },
},
},
+1 -1
View File
@@ -2158,7 +2158,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is off, so its owner can still see it here even though this call will not kill it.",
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is off, so its owner can still see it here even though this call will not kill it.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
+1 -1
View File
@@ -1426,7 +1426,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
// quietly omits the refusal a person actually hits.
"422": {
description:
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its own breadth, so a client must not pre-empt the write with a three-area rule of its own. `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its own breadth, so a client must not pre-empt the write with a three-area rule of its own. `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
+7
View File
@@ -112,3 +112,10 @@ export function toGrantView(
revokedBy: grant.revokedBy,
};
}
/**
* The 404 both invitation routes answer when no account carries the typed
* name or e-mail. Its own code, because each route has a second 404 that
* needs a different next step.
*/
export const INVITE_NO_ACCOUNT_CODE = "sharing.invite.no_account";