diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c851293d9..4f64d47af 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -80,10 +80,10 @@ paths: 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a - password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the - route refused the credential it was handed, not that the response is malformed. A session that could not be - checked because the session store was unreachable is NOT a 401: it answers 503 with - `auth.session.unavailable`, and the client keeps its credential and retries." + password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route + refused the credential it was handed. A session that could not be checked because the session store was + unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its + credential and retries." content: application/json: schema: @@ -36097,15 +36097,19 @@ components: description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped - client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a - refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. - Three families are outside it on purpose: `assistant.disabled.` is built from a template so the - last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, - and `assistant.disabled.coach` is the one the native client names; the integration-probe classes - (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the - per-provider additions) are enumerated in each `/test` operation instead, where the differences can be - stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, + client branches on. Every error envelope carries a code: when the route has no reason of its own to + name, the code is the status's generic one from the `http` family (`http.not_found` for a 404, + `http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own + entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you + would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — + since the list grows with the surfaces. Three families are outside it on purpose: + `assistant.disabled.` is built from a template so the last segment is open — it is one of + `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the + one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, + `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each + `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a + credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, + ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — @@ -36152,18 +36156,22 @@ components: environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found; export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy, - import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, - insights.generate.outboundScreened, insights.generate.profileScopeChanged, - insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, - labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, - labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, - labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, - labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, - labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error, + http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid, + http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error, + http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid, + illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — + insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, @@ -36260,10 +36268,11 @@ components: - data - error additionalProperties: false - description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable - machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue - validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in - one round-trip." + description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable + machine code — the route's own where it names one, otherwise the status's generic `http.*` code — + `meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context. + A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, + so a client can fix them in one round-trip." ValidationIssue: type: object properties: diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index cd905a742..9966968a8 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -344,7 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { await expect(res.json()).resolves.toEqual({ data: null, error: "Medication intake import already in progress", - meta: { requestId: expect.any(String) }, + meta: { requestId: expect.any(String), errorCode: "http.conflict" }, }); expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled(); expect(getGlobalBoss).not.toHaveBeenCalled(); diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts index c54eec97a..0ee03d7c5 100644 --- a/src/app/api/settings/__tests__/email-test-route.test.ts +++ b/src/app/api/settings/__tests__/email-test-route.test.ts @@ -122,7 +122,10 @@ describe("POST /api/settings/email/test", () => { expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toEqual({ requestId: expect.any(String) }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.internal", + }); }); it("answers a delivered test with 200", async () => { diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index a7c46caf3..cd4bd86eb 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -187,7 +187,11 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); expect(json.error).toBe("Invalid data"); - expect(json.meta).toEqual({ requestId: expect.any(String) }); + // No private-origin code: only the status's generic one. + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.invalid", + }); }); it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { @@ -476,7 +480,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toEqual({ requestId: expect.any(String) }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.internal", + }); expect(emitIfSampledMock).toHaveBeenCalledWith( expect.objectContaining({ level: "error" }), ); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index ecceadea5..5386b3929 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -368,7 +368,12 @@ export function AccountSection() { setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); if (errorCode === "profile.update.nothingSaved" && field) { setSaveMsg({ key: "settings.profileNothingSaved", params: { field } }); - } else if (errorCode) { + } else if ( + errorCode && + t(`apiErrors.${errorCode}`) !== `apiErrors.${errorCode}` + ) { + // Only a code with a sentence of its own: every refusal carries a + // code now, and the generic `http.*` family has no translation. setSaveMsg({ key: `apiErrors.${errorCode}` }); } else { setSaveMsg({ key: "settings.savingError" }); diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index 57688c550..de8e74f76 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -21,7 +21,12 @@ vi.mock("next/headers", () => ({ import { NextRequest, NextResponse } from "next/server"; import { apiHandler, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess, withErrorRequestId } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + defaultErrorCodeForStatus, + withErrorEnvelopeDefaults, +} from "@/lib/api-response"; const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001"; @@ -48,6 +53,7 @@ describe("meta.requestId on every error envelope", () => { it("adds it to an envelope without meta and to a hand-built one", async () => { const plain = await call(async () => apiError("Not found", 404)); expect((await plain.json()).meta).toEqual({ + errorCode: "http.not_found", requestId: plain.headers.get("x-request-id"), }); const hand = await call(async () => @@ -127,7 +133,7 @@ describe("meta.requestId on every error envelope", () => { }); it("never overwrites an id the handler set itself", async () => { - const res = await withErrorRequestId( + const res = await withErrorEnvelopeDefaults( NextResponse.json( { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, { status: 500 }, @@ -137,3 +143,62 @@ describe("meta.requestId on every error envelope", () => { expect((await res.json()).meta.requestId).toBe("kept-by-handler"); }); }); + +describe("meta.errorCode on every error envelope", () => { + it("defaults the code from the status when the route named none", async () => { + const thrown = await call(async () => { + throw new HttpError(403, "Forbidden"); + }); + expect((await thrown.json()).meta.errorCode).toBe("http.forbidden"); + + const crash = await call(async () => { + throw new Error("boom"); + }); + expect((await crash.json()).meta.errorCode).toBe("http.internal"); + + const hand = await call(async () => + NextResponse.json({ data: null, error: "Gone" }, { status: 410 }), + ); + expect((await hand.json()).meta.errorCode).toBe("http.gone"); + + const bad = await call(async () => { + throw new SyntaxError("Unexpected token"); + }); + expect((await bad.json()).meta.errorCode).toBe("http.bad_request"); + }); + + it("keeps a code the route named, and its other meta", async () => { + const res = await call(async () => + apiError("Nope", 409, { errorCode: "probe.conflict", extra: 1 }), + ); + const meta = (await res.json()).meta; + expect(meta.errorCode).toBe("probe.conflict"); + expect(meta.extra).toBe(1); + }); + + it("falls back to the class code for an unlisted status", () => { + expect(defaultErrorCodeForStatus(418)).toBe("http.client_error"); + expect(defaultErrorCodeForStatus(507)).toBe("http.server_error"); + expect(defaultErrorCodeForStatus(422)).toBe("http.invalid"); + }); + + it("adds the code even when the handler already set the id", async () => { + const res = await withErrorEnvelopeDefaults( + NextResponse.json( + { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, + { status: 404 }, + ), + "ignored-0000", + ); + expect(await res.json()).toEqual({ + data: null, + error: "x", + meta: { requestId: "kept-by-handler", errorCode: "http.not_found" }, + }); + }); + + it("leaves a success untouched", async () => { + const ok = await call(async () => apiSuccess({ fine: true })); + expect((await ok.json()).meta).toBeUndefined(); + }); +}); diff --git a/src/lib/__tests__/auth-error-codes.test.ts b/src/lib/__tests__/auth-error-codes.test.ts index 76cf3ee13..d4e89be87 100644 --- a/src/lib/__tests__/auth-error-codes.test.ts +++ b/src/lib/__tests__/auth-error-codes.test.ts @@ -227,7 +227,7 @@ describe("auth failures carry a machine-readable errorCode", () => { expect(body.meta?.errorCode).toBe(AUTH_ERROR_CODES.missing); }); - it("leaves an HttpError thrown without a code exactly as it was", async () => { + it("gives an HttpError thrown without a code the status's generic one", async () => { const handler = apiHandler(async (_request: Request) => { throw new HttpError(404, "Not found"); }); @@ -239,7 +239,7 @@ describe("auth failures carry a machine-readable errorCode", () => { expect(await res.json()).toEqual({ data: null, error: "Not found", - meta: { requestId: expect.any(String) }, + meta: { requestId: expect.any(String), errorCode: "http.not_found" }, }); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index c827c33aa..32837983a 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -55,7 +55,7 @@ import { } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { acceptIncomingRequestId } from "./http/request-id"; -import { withErrorRequestId } from "./api-response"; +import { withErrorEnvelopeDefaults } from "./api-response"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -365,9 +365,10 @@ export function apiHandler Promise>( } } // Every error envelope names its request id in `meta.requestId`, so - // the id survives a proxy that strips the header (see - // `withErrorRequestId`). - const nr = (await withErrorRequestId( + // the id survives a proxy that strips the header, and carries a + // `meta.errorCode`, the status's default when the route named none + // (see `withErrorEnvelopeDefaults`). + const nr = (await withErrorEnvelopeDefaults( response as Response, evt.getRequestId(), )) as NextResponse; diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 82762766f..e10bb4cec 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -309,20 +309,69 @@ function isPlainObject(value: unknown): value is Record { } /** - * Put the request id into `meta.requestId` of an error envelope. + * 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)[ + 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. * * Every 4xx and 5xx a route answers with the `{ data: null, error, meta? }` * 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}. * * 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 * throwing cannot be the one that forgets it. Anything that is not a JSON * envelope (a redirect, a stream, a FHIR `OperationOutcome`, a body that does - * not parse) is returned untouched, and an id the handler already set wins. + * not parse) is returned untouched, and an id or code the handler already set + * wins. */ -export async function withErrorRequestId( +export async function withErrorEnvelopeDefaults( response: Response, requestId: string, ): Promise { @@ -354,12 +403,23 @@ export async function withErrorRequestId( } const meta = body.meta; if (meta !== undefined && !isPlainObject(meta)) return response; - if (meta && typeof meta.requestId === "string") return response; + const hasId = typeof meta?.requestId === "string"; + const hasCode = typeof meta?.errorCode === "string"; + if (hasId && hasCode) return response; const headers = new Headers(response.headers); headers.delete("content-length"); return new NextResponse( - JSON.stringify({ ...body, meta: { ...(meta ?? {}), requestId } }), + JSON.stringify({ + ...body, + meta: { + ...(meta ?? {}), + ...(hasCode + ? {} + : { errorCode: defaultErrorCodeForStatus(response.status) }), + ...(hasId ? {} : { requestId }), + }, + }), { status: response.status, statusText: response.statusText, headers }, ); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 0360f87aa..2d34d120b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -211,6 +211,25 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "health_score_config.invalid", "health_score_config.too_narrow", ], + http: [ + "http.bad_gateway", + "http.bad_request", + "http.client_error", + "http.conflict", + "http.forbidden", + "http.gateway_timeout", + "http.gone", + "http.internal", + "http.invalid", + "http.method_not_allowed", + "http.not_found", + "http.payload_too_large", + "http.rate_limited", + "http.server_error", + "http.unauthenticated", + "http.unavailable", + "http.unsupported_media_type", + ], illness: [ "illness.day-log.invalid", "illness.disabled", diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1f391bd0f..31e5c434e 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -85,7 +85,7 @@ export const errorEnvelope = z .string() .optional() .describe( - "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " + + "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Every error envelope carries a code: when the route has no reason of its own to name, the code is the status's generic one from the `http` family (`http.not_found` for a 404, `http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own carries the generic `http.unauthenticated`. " + renderErrorCodeCatalogue(), ), capability: z @@ -118,7 +118,7 @@ export const errorEnvelope = z .meta({ id: "ErrorEnvelope", description: - "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.", + "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable machine code — the route's own where it names one, otherwise the status's generic `http.*` code — `meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.", }); export function dataEnvelope(payload: T, id: string) { @@ -279,7 +279,7 @@ export const malformedJsonResponse = { export const stdResponses = { "401": { description: - "Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the route refused the credential it was handed, not that the response is malformed. A session that could not be checked because the session store was unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its credential and retries.", + "Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route refused the credential it was handed. A session that could not be checked because the session store was unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its credential and retries.", content: { "application/json": { schema: errorEnvelope } }, }, "422": {