From c145843aa3b82c9b93135490d9a26fd4106d9d14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:38:57 +0200
Subject: [PATCH 1/9] fix(insights): let a read's ApiError reach the failure
primitives
Five queryFns caught the ApiError and threw a plain Error with a
translated line, so the read-failure primitives always saw a generic
failure: an ended session offered Retry instead of Sign in, and a
refused share drew an error card. The queryFns now pass the error
through; the message lives on the primitive. The ECG list also says a
failed read instead of rendering nothing.
The guard now also reads every queryFn and refuses a catch that
re-throws a plain Error.
---
.../query-error-error-prop-guard.test.ts | 91 ++++++++++++++++++-
src/app/insights/page-client.tsx | 8 +-
.../insights/__tests__/ecg-section.test.tsx | 37 ++++++++
.../__tests__/rhythm-events-card.test.tsx | 22 +++++
src/components/insights/ecg-cross-link.tsx | 8 +-
src/components/insights/ecg-detail.tsx | 11 +--
src/components/insights/ecg-section.tsx | 37 ++++++--
.../insights/rhythm-events-card.tsx | 12 +--
8 files changed, 183 insertions(+), 43 deletions(-)
diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts
index 0a61c87f1..6544cba0c 100644
--- a/src/__tests__/query-error-error-prop-guard.test.ts
+++ b/src/__tests__/query-error-error-prop-guard.test.ts
@@ -14,6 +14,16 @@
* and refuses both. The matcher is whitespace-tolerant (tags span lines) and
* the match count is asserted, so a matcher that stops finding the tags fails
* rather than passing over nothing.
+ *
+ * Handing the error over is only half of it: the error has to still be the
+ * `ApiError` the read threw. A `queryFn` that catches it and throws
+ * `new Error(t("…loadError"))` instead strips the status, so every primitive
+ * downstream reads "failed" and an ended session offers a retry. The second
+ * block reads every `queryFn` value under `src/` (`.ts` and `.tsx`, since
+ * query hooks live in both and a cache key is shared across files) and
+ * refuses a catch that re-throws a plain `Error`, or a translated message
+ * thrown in its place. The message belongs on the primitive (`message` /
+ * `title`), not inside the error.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
@@ -21,12 +31,16 @@ import { describe, expect, it } from "vitest";
const SRC = join(__dirname, "..");
-function walk(dir: string, out: string[] = []): string[] {
+function walk(
+ dir: string,
+ out: string[] = [],
+ ext: RegExp = /\.tsx$/,
+): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "__tests__" || entry.name === "generated") continue;
const path = join(dir, entry.name);
- if (entry.isDirectory()) walk(path, out);
- else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test."))
+ if (entry.isDirectory()) walk(path, out, ext);
+ else if (ext.test(entry.name) && !entry.name.includes(".test."))
out.push(path);
}
return out;
@@ -80,3 +94,74 @@ describe("read-failure primitives carry their error", () => {
expect(offenders).toEqual([]);
});
});
+
+/**
+ * The text of every `queryFn` value: from the key to the comma, or the
+ * closing bracket, that ends the property at its own depth.
+ */
+function queryFnBodies(source: string): string[] {
+ const bodies: string[] = [];
+ const key = /\bqueryFn\s*:/g;
+ for (let m = key.exec(source); m; m = key.exec(source)) {
+ let depth = 0;
+ let i = m.index + m[0].length;
+ for (; i < source.length; i++) {
+ const ch = source[i];
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
+ else if (ch === ")" || ch === "}" || ch === "]") {
+ if (depth === 0) break;
+ depth--;
+ } else if (ch === "," && depth === 0) break;
+ }
+ bodies.push(source.slice(m.index, i));
+ }
+ return bodies;
+}
+
+/** A catch that throws a plain Error, or a translated line thrown as one. */
+function masksTheReadError(body: string): boolean {
+ return (
+ /\bcatch\b\s*(\([^)]*\))?\s*\{[^}]*\bthrow\s+new\s+Error\(/.test(body) ||
+ /\bthrow\s+new\s+Error\(\s*t\(/.test(body)
+ );
+}
+
+const queryFns = walk(SRC, [], /\.tsx?$/).flatMap((file) =>
+ queryFnBodies(stripComments(readFileSync(file, "utf8"))).map((body) => ({
+ file: relative(SRC, file),
+ body,
+ })),
+);
+
+describe("a read's ApiError reaches the primitive intact", () => {
+ it("finds the queryFns it guards", () => {
+ // About 310 at the time of writing.
+ expect(queryFns.length).toBeGreaterThan(250);
+ });
+
+ it("recognises the masking shape it refuses", () => {
+ expect(
+ masksTheReadError(
+ 'queryFn: async () => { try { return await apiGet(u); } catch { throw new Error(t("x.loadError")); } }',
+ ),
+ ).toBe(true);
+ expect(
+ masksTheReadError(
+ 'queryFn: async () => { try { return await apiGet(u); } catch (e) { throw new Error("failed"); } }',
+ ),
+ ).toBe(true);
+ expect(masksTheReadError("queryFn: () => apiGet(u)")).toBe(false);
+ expect(
+ masksTheReadError(
+ 'queryFn: async () => { if (!id) throw new Error("missing id"); return apiGet(u); }',
+ ),
+ ).toBe(false);
+ });
+
+ it("never swaps the read's error for a plain Error", () => {
+ const offenders = queryFns
+ .filter(({ body }) => masksTheReadError(body))
+ .map(({ file, body }) => `${file}: ${body.split("\n")[0]}`);
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx
index 55c1e4812..3904bf638 100644
--- a/src/app/insights/page-client.tsx
+++ b/src/app/insights/page-client.tsx
@@ -327,13 +327,7 @@ export default function InsightsPageClient() {
// the regular shell and the tiles fill in as their data lands.
const { data, isLoading, isFetched, isError } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
- queryFn: async () => {
- try {
- return await apiGet("/api/insights/comprehensive");
- } catch {
- throw new Error(t("insights.loadError"));
- }
- },
+ queryFn: () => apiGet("/api/insights/comprehensive"),
enabled: isAuthenticated,
});
diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx
index 8dff451da..cd19e8293 100644
--- a/src/components/insights/__tests__/ecg-section.test.tsx
+++ b/src/components/insights/__tests__/ecg-section.test.tsx
@@ -28,7 +28,14 @@ vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
+const apiGetMock = vi.fn();
+vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
+ ...(await importOriginal()),
+ apiGet: (...args: unknown[]) => apiGetMock(...args),
+}));
+
const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section");
+const { ApiError } = await import("@/lib/api/api-fetch");
type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem;
const IRREGULAR_REC: EcgRecordingListItem = {
@@ -68,6 +75,36 @@ function countRows(html: string): number {
}
describe("", () => {
+ it("hands the read's ApiError through untouched", async () => {
+ // The error row decides sign-in vs retry from the status; a queryFn that
+ // re-threw a plain Error would turn an ended session into a retry.
+ useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
+ renderSection(undefined);
+ const { queryFn } = useQueryMock.mock.calls.at(-1)![0] as {
+ queryFn: () => Promise;
+ };
+ const refusal = new ApiError("Unauthorized", 401);
+ apiGetMock.mockRejectedValueOnce(refusal);
+ await expect(queryFn()).rejects.toBe(refusal);
+ });
+
+ it("says an ended session instead of falling through to nothing", () => {
+ useQueryMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new ApiError("Unauthorized", 401),
+ refetch: vi.fn(),
+ });
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain('data-slot="ecg-section-error"');
+ expect(html).toContain('data-slot="query-error-sign-in"');
+ });
+
it("renders nothing before the payload resolves", () => {
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
const html = renderToStaticMarkup(
diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx
index 7de61bc59..f7665e785 100644
--- a/src/components/insights/__tests__/rhythm-events-card.test.tsx
+++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx
@@ -28,7 +28,14 @@ vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
+const apiGetMock = vi.fn();
+vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
+ ...(await importOriginal()),
+ apiGet: (...args: unknown[]) => apiGetMock(...args),
+}));
+
const { RhythmEventsCard } = await import("../rhythm-events-card");
+const { ApiError } = await import("@/lib/api/api-fetch");
interface RhythmEvent {
id: string;
@@ -69,6 +76,21 @@ const STEADINESS_EVENT: RhythmEvent = {
};
describe("", () => {
+ it("hands the read's ApiError through, so a 401 offers sign-in", async () => {
+ useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
+ renderToStaticMarkup(
+
+
+ ,
+ );
+ const { queryFn } = useQueryMock.mock.calls.at(-1)![0] as {
+ queryFn: () => Promise;
+ };
+ const refusal = new ApiError("Unauthorized", 401);
+ apiGetMock.mockRejectedValueOnce(refusal);
+ await expect(queryFn()).rejects.toBe(refusal);
+ });
+
it("renders nothing before the payload resolves", () => {
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
const html = renderToStaticMarkup(
diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx
index 15174c2a7..86596545c 100644
--- a/src/components/insights/ecg-cross-link.tsx
+++ b/src/components/insights/ecg-cross-link.tsx
@@ -59,13 +59,7 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
const { data } = useQuery({
queryKey: queryKeys.insightsEcgList(),
- queryFn: async () => {
- try {
- return await apiGet("/api/insights/ecg");
- } catch {
- throw new Error(t("insights.ecg.loadError"));
- }
- },
+ queryFn: () => apiGet("/api/insights/ecg"),
enabled: enabled && isAuthenticated,
});
diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx
index 0624ba083..ff840a099 100644
--- a/src/components/insights/ecg-detail.tsx
+++ b/src/components/insights/ecg-detail.tsx
@@ -47,15 +47,8 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
- queryFn: async () => {
- try {
- return await apiGet(
- `/api/insights/ecg/${recordingId}`,
- );
- } catch {
- throw new Error(t("insights.ecg.loadError"));
- }
- },
+ queryFn: () =>
+ apiGet(`/api/insights/ecg/${recordingId}`),
});
if (isError) {
diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx
index 2741a7d8c..2d6016503 100644
--- a/src/components/insights/ecg-section.tsx
+++ b/src/components/insights/ecg-section.tsx
@@ -10,6 +10,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import {
ecgResultLabel,
type EcgClassification,
@@ -93,18 +94,38 @@ export function EcgSection({
const { t } = useTranslations();
const fmt = useFormatters();
- const { data, isLoading } = useQuery({
+ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgList(),
- queryFn: async () => {
- try {
- return await apiGet("/api/insights/ecg");
- } catch {
- throw new Error(t("insights.ecg.loadError"));
- }
- },
+ queryFn: () => apiGet("/api/insights/ecg"),
enabled: enabled && isAuthenticated,
});
+ // A failed read is said, never swallowed into the empty gate below: on the
+ // dedicated page this list is the whole content.
+ if (isError && !data) {
+ return (
+
+ {!hideHeading && (
+
+ )}
+ void refetch()}
+ />
+
+ );
+ }
+
// Data-availability gate — never paint an empty card.
if (isLoading || !data || !data.hasRecordings) return null;
diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx
index 6c953a550..279d00d13 100644
--- a/src/components/insights/rhythm-events-card.tsx
+++ b/src/components/insights/rhythm-events-card.tsx
@@ -93,15 +93,9 @@ export function RhythmEventsCard({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
- queryFn: async () => {
- try {
- return await apiGet(
- "/api/insights/rhythm-events",
- );
- } catch {
- throw new Error(t("insights.rhythmEvents.loadError"));
- }
- },
+ // The ApiError passes through untouched: the error row reads its status
+ // to tell an ended session from a real failure.
+ queryFn: () => apiGet("/api/insights/rhythm-events"),
enabled: enabled && isAuthenticated,
});
From 0057b03699a3fb19f8b99757882c43f80e942cf9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:39:50 +0200
Subject: [PATCH 2/9] refactor(openapi): derive the http code family from the
status table
The catalogue listed the status defaults a second time by hand. It now
reads them from DEFAULT_ERROR_CODES, the table the handler applies.
---
.../insights/targets/__tests__/route.test.ts | 4 ++--
src/lib/openapi/error-codes.ts | 24 ++++---------------
2 files changed, 7 insertions(+), 21 deletions(-)
diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts
index 9c46f189c..b948897de 100644
--- a/src/app/api/insights/targets/__tests__/route.test.ts
+++ b/src/app/api/insights/targets/__tests__/route.test.ts
@@ -322,8 +322,8 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
});
it("reads latest-ever-per-type one index probe per type, floored at one year", async () => {
- // Audit High finding 3 established the 365-day floor. The read is the
- // shared `readLatestEver`: driven from the type list, one LIMIT 1 probe
+ // The read is floored at 365 days. It is the shared `readLatestEver`:
+ // driven from the type list, one LIMIT 1 probe
// per type, so a dense type never sorts a year of raw rows to keep one.
await callGet(makeReq());
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 2d34d120b..8352923ab 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_ERROR_CODES } from "@/lib/api-response";
+
/**
* Every `meta.errorCode` the API emits, grouped by the surface that emits it.
*
@@ -211,25 +213,9 @@ 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",
- ],
+ // The status defaults, derived from the table `apiHandler` applies so the
+ // two cannot drift apart.
+ http: [...new Set(Object.values(DEFAULT_ERROR_CODES))].sort(),
illness: [
"illness.day-log.invalid",
"illness.disabled",
From c300574a8ee5cb92d7924344f273fdde86607ffa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:48:15 +0200
Subject: [PATCH 3/9] 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.
---
docs/api/openapi.yaml | 48 +++++++++---------
.../sharing-recoverable-errors.test.tsx | 5 +-
.../zod-refusal-issue-list-guard.test.ts | 2 +-
src/app/api/account/grants/route.ts | 10 +++-
.../managed-profiles/[id]/guardians/route.ts | 25 ++++++----
.../managed-profile-affordances.test.tsx | 30 ++++++++---
.../settings/access/grant-invite-card.tsx | 5 +-
.../access/managed-profile-guardians.tsx | 23 +++------
.../__tests__/query-error-primitives.test.tsx | 15 ++++++
.../api-handler-error-request-id.test.ts | 2 +-
src/lib/api-response.ts | 50 ++-----------------
src/lib/api/default-error-codes.ts | 45 +++++++++++++++++
src/lib/api/read-failure.ts | 36 +++++++++++++
src/lib/openapi/error-codes.ts | 3 +-
src/lib/openapi/routes/account-sharing.ts | 13 ++---
src/lib/openapi/routes/auth.ts | 2 +-
src/lib/openapi/routes/profile.ts | 2 +-
src/lib/sharing/grant-view.ts | 7 +++
.../managed-profile-surface.test.ts | 15 +++---
tests/integration/sharing-lifecycle.test.ts | 2 +
20 files changed, 212 insertions(+), 128 deletions(-)
create mode 100644 src/lib/api/default-error-codes.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 3230be4c9..a6ba6f244 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -3516,9 +3516,10 @@ paths:
$ref: "#/components/schemas/ApiTokenRevokeEnvelope"
"401": *a1
"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.
+ description: 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:
@@ -7706,7 +7707,7 @@ paths:
`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."
+ `health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`."
content:
application/json:
schema:
@@ -21519,7 +21520,7 @@ paths:
schema:
$ref: "#/components/schemas/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:
@@ -21710,8 +21711,8 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"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.
+ description: "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:
@@ -21794,8 +21795,9 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"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.
+ description: "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:
@@ -21880,11 +21882,10 @@ paths:
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."
+ `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."
parameters:
- in: path
name: id
@@ -21911,11 +21912,11 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"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."
+ description: "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:
@@ -21938,7 +21939,8 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
- description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status.
+ description: "More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status
+ default)."
content:
application/json:
schema:
@@ -36227,9 +36229,9 @@ components:
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
- sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
- sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
- sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
+ sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
+ sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
+ sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx
index 67f84b3ff..26dafe0fb 100644
--- a/src/__tests__/sharing-recoverable-errors.test.tsx
+++ b/src/__tests__/sharing-recoverable-errors.test.tsx
@@ -335,9 +335,12 @@ describe("a refused invitation keeps what was typed", () => {
expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe(
"recordSharing.invite.errorOffline",
);
- expect(inviteErrorKey(apiError(404))).toBe(
+ expect(inviteErrorKey(apiError(404, "sharing.invite.no_account"))).toBe(
"recordSharing.invite.errorNoAccount",
);
+ expect(inviteErrorKey(apiError(404, "http.not_found"))).toBe(
+ "recordSharing.invite.errorFailed",
+ );
expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe(
"recordSharing.invite.errorDuplicate",
);
diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts
index 57426b41b..242353000 100644
--- a/src/__tests__/zod-refusal-issue-list-guard.test.ts
+++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts
@@ -188,7 +188,7 @@ const TOKEN_IN_ERROR_422: Record = {
"src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json":
"The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.",
"src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence":
- "Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.",
+ "Predates this sweep and carries only the status default code; the Coach reminder surfaces move as one piece or not at all.",
"src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen":
"Predates this sweep, same surface.",
"src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen":
diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts
index 5d093073b..2851c662e 100644
--- a/src/app/api/account/grants/route.ts
+++ b/src/app/api/account/grants/route.ts
@@ -66,7 +66,11 @@ import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
-import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
+import {
+ GRANT_PARTY_SELECT,
+ INVITE_NO_ACCOUNT_CODE,
+ toGrantView,
+} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
@@ -221,7 +225,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: GRANT_PARTY_SELECT,
});
if (!invitee) {
- return apiError("No account with that name or e-mail", 404);
+ return apiError("No account with that name or e-mail", 404, {
+ errorCode: INVITE_NO_ACCOUNT_CODE,
+ });
}
await auth.commitElevation();
diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts
index 8afbb7e28..01e51b55b 100644
--- a/src/app/api/managed-profiles/[id]/guardians/route.ts
+++ b/src/app/api/managed-profiles/[id]/guardians/route.ts
@@ -20,7 +20,10 @@ import {
} from "@/lib/managed-profiles/guardian-list";
import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle";
import { checkRateLimit } from "@/lib/rate-limit";
-import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
+import {
+ GRANT_PARTY_SELECT,
+ INVITE_NO_ACCOUNT_CODE,
+} from "@/lib/sharing/grant-view";
import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants";
import { inviteManagedProfileGuardianSchema } from "@/lib/validations/managed-profiles";
@@ -106,16 +109,16 @@ export const POST = apiHandler(
},
select: GRANT_PARTY_SELECT,
});
- // No `errorCode`, deliberately, and the same for the 429 above. Rate-limit
- // refusals carry none anywhere in this application, and the sibling
- // invitation route (`POST /api/account/grants`) answers an unknown
- // identifier with a bare 404 too. A code here would make one route in the
- // family unlike both. The published contract says so, and says that a
- // client resolves on `meta.errorCode` FIRST: this route's other 404 —
- // "you are not a Guardian of that profile" — does carry one, and branching
- // on the status would tell somebody to check a spelling when the real
- // answer is that the profile is no longer theirs to administer.
- if (!invitee) return apiError("No account with that name or e-mail", 404);
+ // The same code the sibling invitation route (`POST /api/account/grants`)
+ // answers an unknown identifier with. This route has a second 404 — "you
+ // are not a Guardian of that profile", `managed_profile.not_found` — and
+ // the two need different next steps, so each names itself and a client
+ // never has to tell them apart by status.
+ if (!invitee) {
+ return apiError("No account with that name or e-mail", 404, {
+ errorCode: INVITE_NO_ACCOUNT_CODE,
+ });
+ }
try {
const grant = await inviteManagedProfileGuardian({
diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx
index 1ccb24492..4601462d1 100644
--- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx
+++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx
@@ -837,14 +837,16 @@ describe("what a refused guardian invitation says", () => {
why: "a 404 WITH a code — not the same as an unknown identifier",
},
{
- err: new ApiError("refused", 404),
+ err: new ApiError("refused", 404, {
+ errorCode: "sharing.invite.no_account",
+ }),
key: "recordSharing.managed.guardianErrorNoAccount",
- why: "the codeless 404: no account carries that name or e-mail",
+ why: "the other 404: no account carries that name or e-mail",
},
{
- err: new ApiError("refused", 429),
+ err: new ApiError("refused", 429, { errorCode: "http.rate_limited" }),
key: "recordSharing.managed.guardianErrorRateLimit",
- why: "the other codeless arm, where waiting is the whole recovery",
+ why: "a 429 has one meaning, where waiting is the whole recovery",
},
{
err: new ApiError("refused", 401, {
@@ -872,16 +874,28 @@ describe("what a refused guardian invitation says", () => {
}
it("tells the two 404s apart, which is the whole reason for the order", () => {
- // The route publishes a code on one 404 and not on the other. Branching on
- // status first would tell somebody to check a spelling when the real
- // answer is that they are no longer a Guardian of that profile.
+ // Each 404 names itself. Branching on the status would tell somebody to
+ // check a spelling when the real answer is that they are no longer a
+ // Guardian of that profile, and a 404 that names neither is not an
+ // unknown name either.
expect(
guardianInviteErrorKey(
new ApiError("refused", 404, {
errorCode: "managed_profile.not_found",
}),
),
- ).not.toBe(guardianInviteErrorKey(new ApiError("refused", 404)));
+ ).not.toBe(
+ guardianInviteErrorKey(
+ new ApiError("refused", 404, {
+ errorCode: "sharing.invite.no_account",
+ }),
+ ),
+ );
+ expect(
+ guardianInviteErrorKey(
+ new ApiError("refused", 404, { errorCode: "http.not_found" }),
+ ),
+ ).toBe("recordSharing.managed.guardianErrorFailed");
// And all nine cases above resolve to distinct sentences rather than one:
// the failure this table is written against is one message for everything.
expect(
diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx
index a4828182a..eb3544759 100644
--- a/src/components/settings/access/grant-invite-card.tsx
+++ b/src/components/settings/access/grant-invite-card.tsx
@@ -747,12 +747,11 @@ export function inviteErrorKey(err: unknown): string {
// sent" is not.
case "sharing.invite.manage_browser_only":
return "recordSharing.invite.errorManageBrowserOnly";
+ case "sharing.invite.no_account":
+ return "recordSharing.invite.errorNoAccount";
default:
break;
}
- if (err instanceof ApiError && err.status === 404) {
- return "recordSharing.invite.errorNoAccount";
- }
if (err instanceof ApiError && err.status === 429) {
return "recordSharing.invite.errorRateLimited";
}
diff --git a/src/components/settings/access/managed-profile-guardians.tsx b/src/components/settings/access/managed-profile-guardians.tsx
index b6f831e2f..0526868ba 100644
--- a/src/components/settings/access/managed-profile-guardians.tsx
+++ b/src/components/settings/access/managed-profile-guardians.tsx
@@ -308,18 +308,12 @@ export function ManagedProfileGuardians({
/**
* The message for a refused Guardian invitation.
*
- * Six refusals, and **two of them carry no `errorCode`** — the rate limit
- * (`guardians/route.ts`, before the body is even read) and the unknown-account
- * 404. That asymmetry is the route's actual shape, not laziness here: the
- * other four arms publish a stable code and are matched on it, and these two
- * have to be told apart by status because there is nothing else to branch on.
- * Recorded in `02-13.1-CONTRACT-FINDINGS.md` so whoever freezes the contract
- * decides deliberately whether to close it.
- *
- * The code switch runs FIRST, and the order matters: `managed_profile.not_found`
- * is a 404 WITH a code, and an unknown account is a 404 without one. Branching
- * on status first would tell somebody to check the spelling of a name when the
- * real answer is that they are not a Guardian of that profile any more.
+ * Six refusals, matched on their code. The route answers 404 twice, and the
+ * two need different next steps: `sharing.invite.no_account` asks somebody to
+ * check the spelling of a name, `managed_profile.not_found` says they are not
+ * a Guardian of that profile any more. Branching on the status would collapse
+ * them, so only the statuses with a single meaning fall back to the status: a
+ * 429 (wait, whatever the code) and a 401 (the step-up gate).
*/
export function guardianInviteErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
@@ -340,14 +334,13 @@ export function guardianInviteErrorKey(err: unknown): string {
return "recordSharing.managed.guardianErrorManagedInvitee";
case "managed_profile.not_found":
return "recordSharing.actionError.profileNotFound";
+ case "sharing.invite.no_account":
+ return "recordSharing.managed.guardianErrorNoAccount";
default:
break;
}
- // The two codeless arms. Waiting is the recovery for one and correcting the
- // identifier is the recovery for the other, so they must not collapse.
if (err.status === 429) return "recordSharing.managed.guardianErrorRateLimit";
- if (err.status === 404) return "recordSharing.managed.guardianErrorNoAccount";
// The step-up gate this whole family carries. Not a failure — and it comes
// in two shapes, because an account with no factor enrolled cannot clear it.
if (err.status === 401) return stepUpErrorKey(err);
diff --git a/src/components/ui/__tests__/query-error-primitives.test.tsx b/src/components/ui/__tests__/query-error-primitives.test.tsx
index 3edb66fa3..bd70c4a46 100644
--- a/src/components/ui/__tests__/query-error-primitives.test.tsx
+++ b/src/components/ui/__tests__/query-error-primitives.test.tsx
@@ -58,6 +58,21 @@ describe("readFailureKind", () => {
expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed");
expect(readFailureKind(null)).toBe("failed");
});
+
+ it("reads the code before the status", () => {
+ const withCode = (status: number, errorCode: string) =>
+ readFailureKind(new ApiError("x", status, { errorCode }));
+ // A step-up or re-proof gate answers 401 on a session that is still good:
+ // sending that reader to sign-in would end it.
+ expect(withCode(401, "auth.stepup.required")).toBe("failed");
+ expect(withCode(401, "auth.reproof.required")).toBe("failed");
+ expect(withCode(401, "auth.mfa.code_invalid")).toBe("failed");
+ expect(withCode(401, "auth.token.expired")).toBe("unauthenticated");
+ expect(withCode(401, "http.unauthenticated")).toBe("unauthenticated");
+ // An unknown code falls back to the status.
+ expect(withCode(401, "auth.something.new")).toBe("unauthenticated");
+ expect(withCode(403, "http.forbidden")).toBe("refused");
+ });
});
describe.each([
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 de8e74f76..334f86c7d 100644
--- a/src/lib/__tests__/api-handler-error-request-id.test.ts
+++ b/src/lib/__tests__/api-handler-error-request-id.test.ts
@@ -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";
diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts
index 03b3e60c0..81edb9819 100644
--- a/src/lib/api-response.ts
+++ b/src/lib/api-response.ts
@@ -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(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
@@ -308,52 +310,6 @@ function isPlainObject(value: unknown): value is Record {
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)[
- 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
diff --git a/src/lib/api/default-error-codes.ts b/src/lib/api/default-error-codes.ts
new file mode 100644
index 000000000..84c039c28
--- /dev/null
+++ b/src/lib/api/default-error-codes.ts
@@ -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)[
+ status
+ ];
+ if (listed) return listed;
+ return status >= 500
+ ? DEFAULT_ERROR_CODES.server
+ : DEFAULT_ERROR_CODES.client;
+}
diff --git a/src/lib/api/read-failure.ts b/src/lib/api/read-failure.ts
index 0a6c8b388..9a87a0476 100644
--- a/src/lib/api/read-failure.ts
+++ b/src/lib/api/read-failure.ts
@@ -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 = 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 = 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";
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 8352923ab..460074912 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -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> =
"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",
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index c12a73a2d..589147f18 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -945,7 +945,8 @@ export const accountSharingPaths: NonNullable = {
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 = {
},
"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 = {
},
"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 = {
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 = {
},
"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 = {
},
"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 } },
},
},
diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts
index e99193678..71e0a5424 100644
--- a/src/lib/openapi/routes/auth.ts
+++ b/src/lib/openapi/routes/auth.ts
@@ -2158,7 +2158,7 @@ export const authPaths: NonNullable = {
},
"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": {
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 665b8a23e..8c55acee5 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -1426,7 +1426,7 @@ export const profilePaths: NonNullable = {
// 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 } },
},
},
diff --git a/src/lib/sharing/grant-view.ts b/src/lib/sharing/grant-view.ts
index 7eb36b4d1..0f3c97337 100644
--- a/src/lib/sharing/grant-view.ts
+++ b/src/lib/sharing/grant-view.ts
@@ -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";
diff --git a/tests/integration/managed-profile-surface.test.ts b/tests/integration/managed-profile-surface.test.ts
index 1758a5a52..b135ce059 100644
--- a/tests/integration/managed-profile-surface.test.ts
+++ b/tests/integration/managed-profile-surface.test.ts
@@ -718,16 +718,17 @@ describe("every call the managed-profile card can make", () => {
);
});
- it("answers an unknown identifier with a 404 carrying NO error code", async () => {
- // The asymmetry the client has to branch on status for. Four arms of this
- // route publish a code; this one and the rate limit do not.
+ it("answers an unknown identifier with a 404 that names itself", async () => {
+ // This route has a second 404 (`managed_profile.not_found`) with a
+ // different next step, so the unknown name carries its own code and a
+ // client never has to tell the two apart by status.
const { creator, profile } = await profileWithPendingSecondGuardian();
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: "nobody-here",
});
expect(response.status).toBe(404);
- expect(await errorCodeOf(response)).toBeUndefined();
+ expect(await errorCodeOf(response)).toBe("sharing.invite.no_account");
});
it("refuses an invitation from somebody who is not a Guardian", async () => {
@@ -947,9 +948,9 @@ describe("how many profiles one account may create in an hour", () => {
timezone: "UTC",
});
expect(refused.status).toBe(429);
- // No error code, like every other rate-limit refusal in this family — the
- // client branches on status, which is recorded in the findings.
- expect(await errorCodeOf(refused)).toBeUndefined();
+ // The status default. A 429 has one meaning in this family (wait), so the
+ // client branches on the status and needs no code of its own.
+ expect(await errorCodeOf(refused)).toBe("http.rate_limited");
// And nothing was created by the refused call.
expect(
await getPrismaClient().user.count({
diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts
index 1aa9574a0..c9a1cd724 100644
--- a/tests/integration/sharing-lifecycle.test.ts
+++ b/tests/integration/sharing-lifecycle.test.ts
@@ -491,6 +491,8 @@ describe("who may do what to a grant", () => {
const response = await invite("nobody-here@example.test");
expect(response.status).toBe(404);
+ const body = (await response.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("sharing.invite.no_account");
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
});
From 2f7f04ecab31d93cb71962885f43dc1da5588e94 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:48:23 +0200
Subject: [PATCH 4/9] fix(auth): answer a malformed session cookie as no
session
The session lookup turned every query error into a 503, so a cookie
holding a NUL byte reached the legacy id path, Postgres refused the
value, and an anonymous caller could produce server errors at will.
A cookie that cannot be a secret or a legacy id is now answered as no
session before the database is asked, and only an unreachable store
(connection errors, timeouts, an exhausted pool) is a 503. Any other
query error is answered like a value that matches nothing.
---
.../session-locale-reconcile.test.ts | 7 +-
src/lib/auth/__tests__/session.test.ts | 79 ++++++++++++++-----
src/lib/auth/session.ts | 36 ++++++++-
src/lib/prisma-errors.ts | 24 ++++++
4 files changed, 124 insertions(+), 22 deletions(-)
diff --git a/src/lib/auth/__tests__/session-locale-reconcile.test.ts b/src/lib/auth/__tests__/session-locale-reconcile.test.ts
index 4766eaf1f..01b356880 100644
--- a/src/lib/auth/__tests__/session-locale-reconcile.test.ts
+++ b/src/lib/auth/__tests__/session-locale-reconcile.test.ts
@@ -66,14 +66,17 @@ vi.mock("next/headers", () => ({
import { prisma } from "@/lib/db";
import { getSession } from "../session";
+/** A cookie value in the shape `mintSessionSecret` produces. */
+const SECRET = `hls_${"a".repeat(64)}`;
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function seedSession(userLocale: string | null) {
- cookieState.sessionId = "hls_raw-secret";
+ cookieState.sessionId = SECRET;
store.row = {
id: "sess-1",
userId: "user-1",
- tokenHash: "hash:hls_raw-secret",
+ tokenHash: `hash:${SECRET}`,
// Far enough out that the sliding-expiry refresh stays quiet.
expiresAt: new Date(Date.now() + THIRTY_DAYS_MS),
lastActiveAt: new Date(),
diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts
index 91ec94f5f..3646fbda9 100644
--- a/src/lib/auth/__tests__/session.test.ts
+++ b/src/lib/auth/__tests__/session.test.ts
@@ -91,6 +91,11 @@ import {
} from "../session";
import { SessionStoreUnavailableError } from "@/lib/api-errors";
+/** A cookie value in the shape `mintSessionSecret` produces. */
+const SECRET = `hls_${"a".repeat(64)}`;
+const FORGED_SECRET = `hls_${"b".repeat(64)}`;
+const UNKNOWN_SECRET = `hls_${"c".repeat(64)}`;
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function seed(row: Partial & { id: string }): SessionRow {
@@ -155,21 +160,21 @@ describe("createSession", () => {
describe("getSession — cookie resolution", () => {
it("resolves a modern cookie by its hash, not by primary key", async () => {
- const row = seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
- cookieState.sessionId = "hls_abc";
+ const row = seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
+ cookieState.sessionId = SECRET;
const result = await getSession();
expect(result?.session.id).toBe(row.id);
// The lookup went through the hash column.
expect(prisma.session.findUnique).toHaveBeenCalledWith(
- expect.objectContaining({ where: { tokenHash: "hash:hls_abc" } }),
+ expect.objectContaining({ where: { tokenHash: `hash:${SECRET}` } }),
);
});
it("refuses a modern-shaped cookie whose hash matches nothing", async () => {
seed({ id: "sess_cuid_1", tokenHash: "hash:hls_real" });
- cookieState.sessionId = "hls_forged";
+ cookieState.sessionId = FORGED_SECRET;
await expect(getSession()).resolves.toBeNull();
});
@@ -202,10 +207,10 @@ describe("getSession — sliding expiry", () => {
it("extends a modern session and re-emits the same secret", async () => {
seed({
id: "sess_cuid_1",
- tokenHash: "hash:hls_abc",
+ tokenHash: `hash:${SECRET}`,
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
});
- cookieState.sessionId = "hls_abc";
+ cookieState.sessionId = SECRET;
await getSession();
@@ -215,7 +220,7 @@ describe("getSession — sliding expiry", () => {
const cookieValue = cookieState.set.mock.calls.find(
(c) => c[0] === "healthlog_session",
)![1];
- expect(cookieValue).toBe("hls_abc");
+ expect(cookieValue).toBe(SECRET);
});
it("withholds the extension from a legacy session so the id path drains", async () => {
@@ -270,7 +275,7 @@ describe("a session lookup that fails is not a missing session", () => {
}
it("getSession raises a 503 with a retryable code", async () => {
- cookieState.sessionId = "hls_abc";
+ cookieState.sessionId = SECRET;
failLookup();
const err = await getSession().catch((e: unknown) => e);
expect(err).toBeInstanceOf(SessionStoreUnavailableError);
@@ -284,26 +289,64 @@ describe("a session lookup that fails is not a missing session", () => {
it("validateSessionFromCookieValue raises instead of answering null", async () => {
failLookup();
- await expect(
- validateSessionFromCookieValue("hls_abc"),
- ).rejects.toBeInstanceOf(SessionStoreUnavailableError);
+ await expect(validateSessionFromCookieValue(SECRET)).rejects.toBeInstanceOf(
+ SessionStoreUnavailableError,
+ );
});
it("validateSessionWithCreatedAt raises instead of answering null", async () => {
failLookup();
- await expect(
- validateSessionWithCreatedAt("hls_abc"),
- ).rejects.toBeInstanceOf(SessionStoreUnavailableError);
+ await expect(validateSessionWithCreatedAt(SECRET)).rejects.toBeInstanceOf(
+ SessionStoreUnavailableError,
+ );
+ });
+
+ it("raises for a pool that had no connection free (P2024)", async () => {
+ vi.mocked(prisma.session.findUnique).mockRejectedValueOnce(
+ Object.assign(new Error("connection pool exhausted"), {
+ code: "P2024",
+ }) as never,
+ );
+ await expect(validateSessionFromCookieValue(SECRET)).rejects.toBeInstanceOf(
+ SessionStoreUnavailableError,
+ );
+ });
+
+ it("answers null, not 503, when the query refused the value itself", async () => {
+ // Postgres 22021: a byte sequence it will not accept in text. Nothing is
+ // wrong with the store; a 503 here would let any anonymous caller produce
+ // server errors on demand.
+ vi.mocked(prisma.session.findUnique).mockRejectedValueOnce(
+ Object.assign(new Error("invalid byte sequence for encoding"), {
+ code: "P2010",
+ meta: { driverAdapterError: { cause: { originalCode: "22021" } } },
+ }) as never,
+ );
+ await expect(validateSessionFromCookieValue("sess_legacy")).resolves.toBe(
+ null,
+ );
+ });
+
+ it.each([
+ ["a NUL byte", "\u0000"],
+ ["a percent-encoded NUL", "%00"],
+ ["a short secret", "hls_abc"],
+ ["a secret with non-hex", `hls_${"z".repeat(64)}`],
+ ["an overlong legacy id", "c".repeat(65)],
+ ])("never asks the database about %s", async (_name, value) => {
+ vi.mocked(prisma.session.findUnique).mockClear();
+ await expect(validateSessionFromCookieValue(value)).resolves.toBe(null);
+ expect(prisma.session.findUnique).not.toHaveBeenCalled();
});
it("still answers null for a cookie that matches nothing", async () => {
- await expect(validateSessionFromCookieValue("hls_nope")).resolves.toBe(
+ await expect(validateSessionFromCookieValue(UNKNOWN_SECRET)).resolves.toBe(
null,
);
});
it("logout still clears the cookie when the lookup fails", async () => {
- cookieState.sessionId = "hls_abc";
+ cookieState.sessionId = SECRET;
failLookup();
await expect(destroySession()).resolves.toBeUndefined();
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
@@ -314,8 +357,8 @@ describe("destroySession", () => {
it("deletes the row the secret resolves to, not the cookie value", async () => {
// The cookie is no longer the primary key, so a logout that deletes by
// cookie value would no-op and leave the session alive.
- seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
- cookieState.sessionId = "hls_abc";
+ seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
+ cookieState.sessionId = SECRET;
await destroySession();
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index 5d2d53640..d779fa9c5 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -6,7 +6,7 @@ import { hashToken } from "@/lib/auth/hmac";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
-import { isP2025 } from "@/lib/prisma-errors";
+import { isDatabaseUnavailable, isP2025 } from "@/lib/prisma-errors";
import { SessionStoreUnavailableError } from "@/lib/api-errors";
import { locales, type Locale } from "@/lib/i18n/config";
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
@@ -41,6 +41,19 @@ function mintSessionSecret(): string {
return `${SESSION_TOKEN_PREFIX}${randomBytes(SESSION_SECRET_BYTES).toString("hex")}`;
}
+/** Exactly what `mintSessionSecret` produces. */
+const SESSION_SECRET_SHAPE = new RegExp(
+ `^${SESSION_TOKEN_PREFIX}[0-9a-f]{${SESSION_SECRET_BYTES * 2}}$`,
+);
+
+/**
+ * What a legacy cookie (a `Session.id`, a cuid) can look like. Deliberately a
+ * character class rather than the cuid grammar: it exists to keep a value that
+ * cannot be an id away from the query, where a NUL byte or invalid UTF-8 makes
+ * Postgres raise instead of finding nothing.
+ */
+const LEGACY_SESSION_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
+
/**
* Resolve the session row a cookie value refers to, or null.
*
@@ -74,6 +87,14 @@ function mintSessionSecret(): string {
* login from the deploy onward is on a secret immediately.
*/
async function findSessionByCookie(cookieValue: string) {
+ // A malformed value matches no session: it is answered as one, before the
+ // database is asked, so an anonymous caller cannot turn a crafted cookie into
+ // a query error.
+ if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) {
+ if (!SESSION_SECRET_SHAPE.test(cookieValue)) return null;
+ } else if (!LEGACY_SESSION_ID_SHAPE.test(cookieValue)) {
+ return null;
+ }
try {
if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) {
return await prisma.session.findUnique({
@@ -94,7 +115,18 @@ async function findSessionByCookie(cookieValue: string) {
// the callers, so a database restart answered 401 and every client
// signed its person out. It is a 503 now, with a code a client can retry
// on (`auth.session.unavailable`).
- throw new SessionStoreUnavailableError(error);
+ //
+ // Only for a store that could not be reached. Any other query error says
+ // something about the value asked for, not about the store, and is
+ // answered like a value that matches nothing: a 503 there would let any
+ // anonymous caller produce server errors at will.
+ if (isDatabaseUnavailable(error)) {
+ throw new SessionStoreUnavailableError(error);
+ }
+ getEvent()?.addWarning(
+ `session lookup refused the value: ${error instanceof Error ? error.name : "unknown"}`,
+ );
+ return null;
}
}
diff --git a/src/lib/prisma-errors.ts b/src/lib/prisma-errors.ts
index 67745ad7a..d5612ea8e 100644
--- a/src/lib/prisma-errors.ts
+++ b/src/lib/prisma-errors.ts
@@ -13,6 +13,8 @@
* unique-constraint code.
*/
+import { classifyRestoreFailure } from "@/lib/export/restore-failure-cause";
+
/** Narrow a thrown Prisma error to a specific known-request code. */
function hasPrismaCode(err: unknown, code: string): boolean {
return (
@@ -37,3 +39,25 @@ export function isP2002(err: unknown): boolean {
export function isP2025(err: unknown): boolean {
return hasPrismaCode(err, "P2025");
}
+
+/**
+ * Whether a query failed because the database could not be reached, as opposed
+ * to refusing what it was asked. The first is worth a retry and is a 503; the
+ * second is an answer about the input.
+ *
+ * Reads the same signals the restore classifier does (`connection` and
+ * `timeout`: a connection SQLSTATE, a server shutting down, a statement or
+ * idle timeout, Prisma's P1001 / P1002 / P1017 / P2028, a terminated
+ * connection), plus the two that only a pooled request meets: P2024, no
+ * connection free in the pool within its timeout, and P1008, an operation that
+ * timed out.
+ */
+export function isDatabaseUnavailable(err: unknown): boolean {
+ if (hasPrismaCode(err, "P2024") || hasPrismaCode(err, "P1008")) return true;
+ const { cause } = classifyRestoreFailure(err);
+ if (cause === "connection" || cause === "timeout") return true;
+ const message = err instanceof Error ? err.message : "";
+ return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|Can't reach database server|timed out/i.test(
+ message,
+ );
+}
From 6e54f3390171afee5aaa699b637a5c2cffefffcc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:53:31 +0200
Subject: [PATCH 5/9] refactor(i18n): ask the catalogue for a sentence in one
helper
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
---
src/__tests__/translate-probe-guard.test.ts | 96 +++++++++++++++++++
src/app/insights/workouts/page-client.tsx | 5 +-
.../admin/notification-test-failures.ts | 11 +--
src/components/charts/health-chart.tsx | 4 +-
.../dashboard/recent-workouts-tile.tsx | 4 +-
.../insights/coach-panel/assumption-line.tsx | 4 +-
.../insights/coach-panel/chat-bubble.tsx | 21 ++--
.../coach-panel/coach-conversation.tsx | 6 +-
.../insights/coach-panel/message-thread.tsx | 4 +-
.../insights/coach-panel/turn-activity.tsx | 9 +-
.../insights/workout-detail/header.tsx | 5 +-
src/components/insights/workout-list.tsx | 4 +-
src/components/links/related-records.tsx | 6 +-
.../vorsorge-dashboard-card.tsx | 4 +-
.../vorsorge-section.tsx | 8 +-
.../vorsorge-settings.tsx | 4 +-
.../onboarding/baseline-form-utils.ts | 8 +-
.../settings/account-section/index.tsx | 6 +-
.../integrations/integration-actions.tsx | 12 ++-
src/components/settings/ntfy-card.tsx | 13 +--
.../settings/test-connection-button.tsx | 14 +--
src/components/settings/webhook-card.tsx | 13 +--
.../__tests__/no-template-key-guard.test.ts | 8 +-
.../timeline/readiness-inventory.tsx | 6 +-
src/lib/ai/coach/results/projections.ts | 4 +-
src/lib/api/localized-error.ts | 16 ++--
src/lib/daily/load-digest.ts | 4 +-
src/lib/daily/today-overview.ts | 4 +-
src/lib/dashboard/snapshot.ts | 6 +-
src/lib/i18n/__tests__/translate-code.test.ts | 45 +++++++++
src/lib/i18n/translate-code.ts | 47 +++++++++
src/lib/insights/correlation-discovery.ts | 9 +-
32 files changed, 291 insertions(+), 119 deletions(-)
create mode 100644 src/__tests__/translate-probe-guard.test.ts
create mode 100644 src/lib/i18n/__tests__/translate-code.test.ts
create mode 100644 src/lib/i18n/translate-code.ts
diff --git a/src/__tests__/translate-probe-guard.test.ts b/src/__tests__/translate-probe-guard.test.ts
new file mode 100644
index 000000000..19896de8a
--- /dev/null
+++ b/src/__tests__/translate-probe-guard.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Whether the catalogue has a sentence is asked in one place.
+ *
+ * `t()` answers a missing key with the key itself, so "does this code have a
+ * sentence" is a comparison of the answer with the question. Written inline it
+ * was copied once per surface that wanted a code's sentence or else a
+ * fallback. It lives in `src/lib/i18n/translate-code.ts` now
+ * (`translateOrNull`, `translateCode`), and this guard refuses the comparison
+ * anywhere else under `src/`.
+ *
+ * Two shapes are read: a translation held in a variable and compared with the
+ * key it was asked for (`const v = t(key); v === key`), and the inline form
+ * (`t(k) !== k`). The helper itself must match, so a matcher that stops
+ * finding the shape fails instead of passing over nothing.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const SRC = join(__dirname, "..");
+const HELPER = "lib/i18n/translate-code.ts";
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === "__tests__" || entry.name === "generated") continue;
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) walk(path, out);
+ else if (/\.tsx?$/.test(entry.name) && !entry.name.includes(".test."))
+ out.push(path);
+ }
+ return out;
+}
+
+function escape(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/** The probes in one source text, one line each. */
+function probes(source: string): string[] {
+ const found: string[] = [];
+ // Inline: `t(K) !== K` / `t(K, p) === K`, K an identifier or a template.
+ const inline =
+ /\bt\(\s*(`[^`]*`|[A-Za-z_$][\w$]*)\s*(?:,[^()]*)?\)\s*[!=]==\s*(`[^`]*`|[A-Za-z_$][\w$]*)/g;
+ for (const m of source.matchAll(inline)) {
+ if (m[1] === m[2]) found.push(m[0]);
+ }
+ // Held: `const v = t(K…)` (or `K ? t(K) : null`) and later `v === K` or
+ // `K !== v`.
+ const held =
+ /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$.]+\s*\?\s*)?t\(\s*([A-Za-z_$][\w$]*)\b/g;
+ for (const m of source.matchAll(held)) {
+ const [, value, key] = m;
+ const compare = new RegExp(
+ `\\b${escape(value)}\\s*[!=]==\\s*${escape(key)}\\b|\\b${escape(key)}\\s*[!=]==\\s*${escape(value)}\\b`,
+ );
+ if (compare.test(source.slice(m.index))) found.push(m[0]);
+ }
+ return found;
+}
+
+const files = walk(SRC).map((file) => ({
+ file: relative(SRC, file),
+ source: readFileSync(file, "utf8"),
+}));
+
+describe("the catalogue probe lives in one helper", () => {
+ it("recognises both shapes", () => {
+ expect(probes("const s = t(key);\nreturn s === key ? a : s;")).toHaveLength(
+ 1,
+ );
+ expect(probes("if (t(`x.${c}`) !== `x.${c}`) go();")).toHaveLength(1);
+ expect(
+ probes("const label = t(key, params); if (key !== label) {}"),
+ ).toHaveLength(1);
+ expect(
+ probes("const v = key ? t(key) : null; return v && v !== key ? v : f;"),
+ ).toHaveLength(1);
+ expect(probes("const s = t(key); return s;")).toEqual([]);
+ expect(probes("if (row.key === key) {}")).toEqual([]);
+ });
+
+ it("finds the probe in the helper", () => {
+ const helper = files.find(({ file }) => file === HELPER);
+ expect(helper).toBeDefined();
+ expect(probes(helper!.source).length).toBeGreaterThan(0);
+ });
+
+ it("finds it nowhere else", () => {
+ const offenders = files
+ .filter(({ file }) => file !== HELPER)
+ .flatMap(({ file, source }) =>
+ probes(source).map((p) => `${file}: ${p}`),
+ );
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx
index d1592e047..0cf9cdd60 100644
--- a/src/app/insights/workouts/page-client.tsx
+++ b/src/app/insights/workouts/page-client.tsx
@@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.4.32 — `/insights/workouts`.
@@ -82,9 +83,7 @@ export default function InsightsWorkoutsPageClient() {
const sportLabel = (sport: string) => {
// A sport enum the bundle has no copy for renders as its canonical
// string rather than a raw key — same fallback the list rows use.
- const key = `insights.workouts.sport.${sport}`;
- const label = t(key);
- return label === key ? sport : label;
+ return translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport;
};
return (
diff --git a/src/components/admin/notification-test-failures.ts b/src/components/admin/notification-test-failures.ts
index caf578b50..9cf7b6726 100644
--- a/src/components/admin/notification-test-failures.ts
+++ b/src/components/admin/notification-test-failures.ts
@@ -2,6 +2,7 @@ import {
CHANNEL_TYPE_LABELS,
type ChannelType,
} from "@/lib/notifications/types";
+import { translateCodeOrNull } from "@/lib/i18n/translate-code";
/** One channel's row in the admin notification test response. */
export interface ChannelTestResult {
@@ -27,14 +28,10 @@ export function describeChannelTestFailures(
.filter((r) => !r.success)
.map((r) => {
const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel;
- const key = r.errorCode
- ? `settings.testConnection.errors.${r.errorCode}`
- : null;
- const translated = key ? t(key) : null;
const cause =
- translated && translated !== key
- ? translated
- : (r.error ?? t("admin.notificationTestFailed"));
+ translateCodeOrNull(t, "settings.testConnection.errors", r.errorCode) ??
+ r.error ??
+ t("admin.notificationTestFailed");
const code =
typeof r.upstreamStatus === "number"
? ` (HTTP ${r.upstreamStatus})`
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 640c8db11..fdbe42f25 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -89,6 +89,7 @@ import {
DEFAULT_CHART_RANGE,
rangeWindowDays,
} from "./chart-range-tabs";
+import { translateOrNull } from "@/lib/i18n/translate-code";
// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and
// the "All" tab's ten-year fetch span live with the shared
@@ -399,8 +400,7 @@ function getTypeLabel(
const key = BASE_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const measurementKey = measurementLabelKey(type);
- const label = t(measurementKey);
- return label === measurementKey ? type : label;
+ return translateOrNull(t, measurementKey) ?? type;
}
/**
diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx
index 0b75c76f6..a9770c5b4 100644
--- a/src/components/dashboard/recent-workouts-tile.tsx
+++ b/src/components/dashboard/recent-workouts-tile.tsx
@@ -12,6 +12,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { iconForSport } from "@/lib/workouts/sport-icons";
+import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.4.32 — ``.
@@ -122,9 +123,8 @@ export function RecentWorkoutsTile() {
From 923249dbf64801f1ed5e157dfe0ecfc8a95eeb92 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:10:49 +0200
Subject: [PATCH 8/9] fix(ui): draw every failed read through a read-failure
primitive
Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.
The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.
A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/ko.json | 6 +-
messages/nl.json | 6 +-
messages/pl.json | 6 +-
.../read-failure-markup-guard.test.ts | 182 ++++++++++++++++++
src/app/insights/catalog/page.tsx | 1 +
src/app/insights/page-client.tsx | 1 +
src/components/admin/ai-quality-section.tsx | 1 +
src/components/admin/backups-section.tsx | 1 +
.../admin/coach-feedback-section.tsx | 1 +
src/components/admin/encryption-section.tsx | 13 +-
src/components/admin/host-metrics-chart.tsx | 1 +
.../admin/invite-tokens-section.tsx | 18 +-
src/components/admin/key-backup-card.tsx | 1 +
.../admin/offhost-backups-section.tsx | 1 +
.../admin/provider-health-section.tsx | 13 +-
src/components/admin/recent-audit-preview.tsx | 1 +
.../admin/system-status-section.tsx | 1 +
.../admin/system-status-summary.tsx | 1 +
.../cycle-insight-summary-card.test.tsx | 16 +-
.../cycle/cycle-insight-summary-card.tsx | 27 ++-
src/components/cycle/cycle-view.tsx | 2 +-
.../__tests__/recent-workouts-tile.test.tsx | 6 +-
.../dashboard/recent-workouts-tile.tsx | 9 +-
.../documents/document-detail-sheet.tsx | 11 +-
.../illness/illness-correlation-card.tsx | 14 +-
.../illness/illness-day-timeline.tsx | 16 +-
.../illness/illness-insights-card.tsx | 21 +-
.../__tests__/recovery-empty-state.test.tsx | 21 +-
.../coach-panel/attachment-picker.tsx | 11 +-
.../coach-panel/coach-model-picker.tsx | 25 +--
.../insights/coach-panel/history-rail.tsx | 20 +-
.../insights/derived/coach-read-strip.tsx | 3 +-
.../insights/derived/wellness-scores.tsx | 39 ++--
.../insights/intraday-pulse-chart.tsx | 11 +-
.../insights/mood/mood-what-stands-out.tsx | 1 +
.../insights/nutrients/caffeine-card.tsx | 22 ++-
.../insights/nutrients/hydration-card.tsx | 23 ++-
.../insights/period-narrative-card.tsx | 33 +++-
.../insights/recovery/recovery-section.tsx | 29 ++-
.../insights/workout-detail/day-context.tsx | 2 +
.../vorsorge-dashboard-card.tsx | 2 +-
.../medications/dose-history-ledger.tsx | 14 +-
.../scheduling/schedule-history-timeline.tsx | 15 +-
.../sections/phase-config-sheet.tsx | 16 +-
.../assessment-item-breakdown.tsx | 17 +-
.../coach-conversations-memory-card.tsx | 1 +
.../settings/coach-memory-section.tsx | 1 +
.../settings/coach-reminders-section.tsx | 1 +
.../integrations/apple-health-card.tsx | 12 +-
.../settings/integrations/dawarich-card.tsx | 11 +-
src/components/settings/privacy-section.tsx | 13 +-
src/components/settings/score-section.tsx | 1 +
.../settings/security-activity-card.tsx | 1 +
.../settings/security-sessions-card.tsx | 1 +
.../settings/trusted-devices-card.tsx | 1 +
60 files changed, 579 insertions(+), 175 deletions(-)
create mode 100644 src/__tests__/read-failure-markup-guard.test.ts
diff --git a/messages/de.json b/messages/de.json
index b2667a7de..72b690df6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
- }
+ },
+ "loadError": "Deine Erholungswerte konnten nicht geladen werden."
},
"bodyTempDeviation": {
"title": "Körpertemperatur-Abweichung",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)"
+ "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)",
+ "loadError": "Wasser konnte nicht geladen werden."
},
"caffeine": {
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)",
diff --git a/messages/en.json b/messages/en.json
index 3e30108aa..da940b4e7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
- }
+ },
+ "loadError": "Your recovery readings could not be loaded."
},
"bodyTempDeviation": {
"title": "Body temperature deviation",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Reference intake {value} {unit}/day (EFSA)"
+ "referenceMeta": "Reference intake {value} {unit}/day (EFSA)",
+ "loadError": "Could not load water."
},
"caffeine": {
"ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)",
diff --git a/messages/es.json b/messages/es.json
index a7e92f85e..aeab27a15 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
- }
+ },
+ "loadError": "No se pudieron cargar tus valores de recuperación."
},
"bodyTempDeviation": {
"title": "Desviación de la temperatura corporal",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)"
+ "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)",
+ "loadError": "No se pudo cargar el agua."
},
"caffeine": {
"ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)",
diff --git a/messages/fr.json b/messages/fr.json
index 2626bfb40..92a8ae59b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
- }
+ },
+ "loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
},
"bodyTempDeviation": {
"title": "Écart de température corporelle",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)"
+ "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)",
+ "loadError": "Impossible de charger l'eau."
},
"caffeine": {
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)",
diff --git a/messages/it.json b/messages/it.json
index 9f6082888..4ca892e15 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
- }
+ },
+ "loadError": "Non è stato possibile caricare i tuoi valori di recupero."
},
"bodyTempDeviation": {
"title": "Deviazione della temperatura corporea",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)"
+ "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)",
+ "loadError": "Impossibile caricare l'acqua."
},
"caffeine": {
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)",
diff --git a/messages/ko.json b/messages/ko.json
index 291d55291..7d8c1316b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
- }
+ },
+ "loadError": "회복 값을 불러오지 못했어요."
},
"bodyTempDeviation": {
"title": "체온 편차",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)"
+ "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)",
+ "loadError": "수분을 불러오지 못했어요."
},
"caffeine": {
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)",
diff --git a/messages/nl.json b/messages/nl.json
index 21d713639..8028f2dc4 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -4678,7 +4678,8 @@
},
"emptyAction": "Integraties openen",
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
- "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe."
+ "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.",
+ "loadError": "Je herstelwaarden konden niet worden geladen."
},
"bodyTempDeviation": {
"title": "Afwijking lichaamstemperatuur",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)"
+ "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)",
+ "loadError": "Water kon niet worden geladen."
},
"caffeine": {
"ceilingMeta": "Veilige bovengrens {value} {unit}/dag (EFSA)",
diff --git a/messages/pl.json b/messages/pl.json
index 4735fd572..3aa3bd00e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
- }
+ },
+ "loadError": "Nie udało się załadować Twoich wartości regeneracji."
},
"bodyTempDeviation": {
"title": "Odchylenie temperatury ciała",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
- "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)"
+ "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)",
+ "loadError": "Nie udało się załadować wody."
},
"caffeine": {
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)",
diff --git a/src/__tests__/read-failure-markup-guard.test.ts b/src/__tests__/read-failure-markup-guard.test.ts
new file mode 100644
index 000000000..b8c46a4c6
--- /dev/null
+++ b/src/__tests__/read-failure-markup-guard.test.ts
@@ -0,0 +1,182 @@
+/**
+ * A failed read draws a read-failure primitive, and draws it the right way.
+ *
+ * Two shapes kept coming back beside the primitives:
+ *
+ * - A read's `isError` branch rendering its own markup: a bare
+ * `text-destructive` paragraph with no retry, a muted line that reads as
+ * "still learning", a hand-rolled row. None of them knows an ended session
+ * from a refusal, which is the decision `QueryErrorRow`, `QueryErrorCard`
+ * and `ChartErrorState` make from the error.
+ * - A `QueryErrorRow` with its own frame inside a card that already draws
+ * one, a card inside a card. Inside a card body the row is `inset`.
+ *
+ * Both readers are lexical. A read is a hook call (`const x = useFoo(` or a
+ * destructure with `isError`) that is not `useMutation` and whose result is
+ * never `.mutate`d; a mutation's error is inline `role="alert"` text by the
+ * standards, so it is left alone. The branch is the text after `isError … ?`
+ * or `&&` up to the next `) : `. Nesting is the count of card tags opened and
+ * not closed before the row. Each reader is checked against a fixture of the
+ * shape it refuses, and the tree must yield a floor of matches, so a matcher
+ * that stops finding anything fails.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const SRC = join(__dirname, "..");
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === "__tests__" || entry.name === "generated") continue;
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) walk(path, out);
+ else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test."))
+ out.push(path);
+ }
+ return out;
+}
+
+function escape(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+const PRIMITIVE = /QueryErrorRow|QueryErrorCard|ChartErrorState/;
+
+/** Every read `isError` branch: its line and whether it hand-rolls markup. */
+function readErrorBranches(
+ source: string,
+): Array<{ line: number; handRolled: boolean }> {
+ const reads = new Set();
+ for (const m of source.matchAll(
+ /const\s+(\w+)\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g,
+ )) {
+ if (m[2] === "useMutation") continue;
+ if (new RegExp(`${escape(m[1])}\\.mutate(?:Async)?\\(`).test(source))
+ continue;
+ reads.add(m[1]);
+ }
+ const alternatives = [...reads].map((v) => `${escape(v)}\\.isError`);
+ const destructured = [
+ ...source.matchAll(
+ /const\s*\{[^}]*\bisError\b[^}]*\}\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g,
+ ),
+ ].some((m) => m[1] !== "useMutation");
+ if (destructured) alternatives.push("(? = [];
+ for (const m of source.matchAll(branch)) {
+ if (source[m.index - 1] === "!") continue;
+ const body = source.slice(
+ m.index + m[0].length,
+ m.index + m[0].length + 500,
+ );
+ const arm = body.split(") : ")[0];
+ found.push({
+ line: source.slice(0, m.index).split("\n").length,
+ handRolled:
+ !PRIMITIVE.test(arm) && /<(p|div|span)\b/.test(arm.slice(0, 250)),
+ });
+ }
+ return found;
+}
+
+const CARD_TAGS = ["SettingsCard", "Card", "CardContent", "InsightSectionCard"];
+
+/** Lines of `QueryErrorRow`s without `inset` inside an open card tag. */
+function framedRowsInCards(source: string): number[] {
+ const lines: number[] = [];
+ for (const m of source.matchAll(//g)) {
+ if (/\binset\b/.test(m[1])) continue;
+ const before = source.slice(0, m.index);
+ let depth = 0;
+ for (const tag of CARD_TAGS) {
+ depth += (before.match(new RegExp(`<${tag}(?=[\\s>])`, "g")) ?? [])
+ .length;
+ depth -= (before.match(new RegExp(`${tag}>`, "g")) ?? []).length;
+ depth -= (before.match(new RegExp(`<${tag}(?=\\s)[^>]*?/>`, "g")) ?? [])
+ .length;
+ }
+ if (depth > 0) lines.push(before.split("\n").length);
+ }
+ return lines;
+}
+
+const files = walk(SRC).map((file) => ({
+ file: relative(SRC, file),
+ source: readFileSync(file, "utf8"),
+}));
+
+describe("read failures render through a primitive", () => {
+ it("recognises a hand-rolled read failure", () => {
+ const rolled = `
+ const { data, isError } = useQuery({ queryKey: k, queryFn: f });
+ return isError ? (
+
+ refetch?.()}
+ />
);
}
diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx
index deff1ae99..da0cabb36 100644
--- a/src/components/insights/intraday-pulse-chart.tsx
+++ b/src/components/insights/intraday-pulse-chart.tsx
@@ -23,7 +23,7 @@ import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { DayLink } from "@/components/day/day-link";
import { Button } from "@/components/ui/button";
-import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
@@ -77,7 +77,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* coarser hourly grain instead of an empty chart — `resolution` on the DTO
* tells the caption which, and the tension read is always absent on an
* hourly day (it needs per-sample resolution to stay honest). A failed fetch
- * renders `` with a retry action instead of falling through
+ * renders `` with a retry action instead of falling through
* to the empty-day copy.
*
* `initialDateKey` pins the first day shown, for callers that already know
@@ -254,10 +254,11 @@ export function IntradayPulseChart({
{isLoading ? (
) : isError ? (
- refetch()}
- className="border-0 bg-transparent shadow-none"
+ slot="intraday-pulse-error"
+ onRetry={() => void refetch()}
/>
) : (data?.series?.length ?? 0) < MIN_BUCKETS_FOR_COURSE ? (
// A lone reading or two on a 24-hour axis drew a dot in an empty
diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx
index 46148152d..c16adb3a7 100644
--- a/src/components/insights/mood/mood-what-stands-out.tsx
+++ b/src/components/insights/mood/mood-what-stands-out.tsx
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
void refetch()}
- />
+
+
+
+
+
+ void refetch()}
+ />
+
+
);
}
diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx
index 2f722cba9..5d61aa219 100644
--- a/src/components/insights/nutrients/hydration-card.tsx
+++ b/src/components/insights/nutrients/hydration-card.tsx
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { GlassWater } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
@@ -38,8 +38,25 @@ export function HydrationCard() {
),
});
- if (isError) {
- return ;
+ // The card keeps its frame and title on a failed read, like the caffeine
+ // card beside it, so the page does not change shape under the reader.
+ if (isError && !data) {
+ return (
+
+
+
+
+
+ void refetch()}
+ />
+
+
+ );
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 2495101b1..94249a5a1 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -18,6 +18,7 @@ import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
import { InsightSectionCard } from "./insight-section-card";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.11.0 — period-narrative card (Pillar P1).
@@ -121,13 +122,33 @@ export function PeriodNarrativeCard({
);
}
- // The route never errors the page; on a fetch error or whenever there is no
- // real generated narrative yet (empty account, or a first warm still in
- // flight) the card un-mounts itself rather than showing an error, a
- // "preparing" husk, or the methodology framing. The query stays mounted and
- // polls in the background, so the card appears the moment a narrative lands.
+ // A failed read is said, with a retry: hiding it would look exactly like
+ // "no narrative yet". Whenever there is no real generated narrative yet
+ // (empty account, the capability off, or a first warm still in flight) the
+ // card un-mounts itself rather than showing a "preparing" husk or the
+ // methodology framing. The query stays mounted and polls in the background,
+ // so the card appears the moment a narrative lands.
+ if (query.isError && !query.data) {
+ return (
+
+
+ void query.refetch()}
+ />
+
+ );
+ }
const narrative = query.data?.narrative ?? null;
- if (query.isError || !narrative) {
+ if (!narrative) {
return null;
}
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx
index 060b067ab..f21fa05de 100644
--- a/src/components/insights/recovery/recovery-section.tsx
+++ b/src/components/insights/recovery/recovery-section.tsx
@@ -21,6 +21,7 @@ import type {
RecoveryEmptyReason,
} from "@/lib/insights/recovery-empty";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
@@ -79,7 +80,8 @@ const EMPTY_COPY = {
*/
export function RecoverySection() {
const { t } = useTranslations();
- const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
+ const { data, isLoading, error, refetch } =
+ useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
@@ -190,6 +192,17 @@ export function RecoverySection() {
// answer (`recoveryEmpty`), so web and iOS read one decision.
const emptyReason = data?.recoveryEmpty ?? null;
+ // A failed read is said, never left as a skeleton that loads forever.
+ if (error && summaries == null) {
+ return (
+
+ );
+ }
+
if (isLoading || summaries == null) {
return (
@@ -207,11 +220,15 @@ export function RecoverySection() {
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyReason])}
action={
-
-
- {t("insights.recovery.emptyAction")}
-
-
+ // Waiting for a first resilience level has nothing to open: the
+ // connection is fine, the level simply has not arrived.
+ emptyReason === "oura_no_resilience" ? undefined : (
+
+
+ {t("insights.recovery.emptyAction")}
+
+
+ )
}
/>
);
diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx
index 5286b8ee4..d262f13d3 100644
--- a/src/components/insights/workout-detail/day-context.tsx
+++ b/src/components/insights/workout-detail/day-context.tsx
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
{sleep.isError ? (
// A failed read is not an absent night.
{mood.isError ? (
refetch()} />
+ refetch()} />
) : upcoming.length === 0 ? (
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index e52462fd2..0ff9aa79a 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -114,6 +114,7 @@ import {
} from "@/components/medications/dose-history-ledger-compute";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -210,7 +211,7 @@ export function DoseHistoryLedger({
// `from` travels: the server's `to` defaults to its own now.
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
@@ -465,9 +466,14 @@ export function DoseHistoryLedger({
)}
{isError && (
-
diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx
index 985e771ff..cab9665b6 100644
--- a/src/components/settings/score-section.tsx
+++ b/src/components/settings/score-section.tsx
@@ -445,6 +445,7 @@ export function ScoreSection() {
{analyticsQuery.isError ? (
refetch()}
diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index d2059b6db..69373a1f5 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -165,6 +165,7 @@ export function SecuritySessionsCard({
{isError && (
refetch()}
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx
index 093f0de21..20fa7b45f 100644
--- a/src/components/settings/trusted-devices-card.tsx
+++ b/src/components/settings/trusted-devices-card.tsx
@@ -105,6 +105,7 @@ export function TrustedDevicesCard({
{isError && (
refetch()}
From 3e6a33df35605be53ecbaa9868efa6136a23e450 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:10:57 +0200
Subject: [PATCH 9/9] fix(i18n): one sentence each for the read-failure lines
The ended-session and spent-budget lines each ran to two sentences; the
action sits on the button, so each is one sentence now. The sign-in
label's key is common.signIn, matching what it says. The French strings
added in this release use vous like the rest of the bundle, and the
Korean sleep-score gap labels take the polite form the bundle uses.
---
messages/de.json | 6 +++---
messages/en.json | 6 +++---
messages/es.json | 6 +++---
messages/fr.json | 8 ++++----
messages/it.json | 6 +++---
messages/ko.json | 10 +++++-----
messages/nl.json | 6 +++---
messages/pl.json | 6 +++---
.../ui/__tests__/query-error-primitives.test.tsx | 4 ++--
src/hooks/use-read-failure.ts | 2 +-
10 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 72b690df6..187a3672f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -55,9 +55,9 @@
"errorIdLabel": "Fehler-ID",
"errorIdCopy": "Fehler-ID kopieren",
"errorIdCopied": "Fehler-ID kopiert",
- "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal.",
- "sessionEnded": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um weiterzumachen.",
- "signInAgain": "Anmelden"
+ "readThrottled": "Gerade gibt es zu viele Anfragen, versuch es später noch einmal.",
+ "sessionEnded": "Deine Sitzung ist abgelaufen, melde dich erneut an, um weiterzumachen.",
+ "signIn": "Anmelden"
},
"apiErrors": {
"measurement": {
diff --git a/messages/en.json b/messages/en.json
index da940b4e7..7600036d3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -55,9 +55,9 @@
"errorIdLabel": "Error ID",
"errorIdCopy": "Copy error ID",
"errorIdCopied": "Error ID copied",
- "readThrottled": "Too many requests just now. Try again later.",
- "sessionEnded": "Your session has ended. Sign in again to continue.",
- "signInAgain": "Sign in"
+ "readThrottled": "There are too many requests just now, so try again later.",
+ "sessionEnded": "Your session has ended, so sign in again to continue.",
+ "signIn": "Sign in"
},
"apiErrors": {
"measurement": {
diff --git a/messages/es.json b/messages/es.json
index aeab27a15..d07564803 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -55,9 +55,9 @@
"errorIdLabel": "ID de error",
"errorIdCopy": "Copiar ID de error",
"errorIdCopied": "ID de error copiado",
- "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde.",
- "sessionEnded": "Tu sesión ha caducado. Inicia sesión de nuevo para continuar.",
- "signInAgain": "Iniciar sesión"
+ "readThrottled": "Hay demasiadas solicitudes ahora mismo, así que vuelve a intentarlo más tarde.",
+ "sessionEnded": "Tu sesión ha caducado, así que inicia sesión de nuevo para continuar.",
+ "signIn": "Iniciar sesión"
},
"apiErrors": {
"measurement": {
diff --git a/messages/fr.json b/messages/fr.json
index 92a8ae59b..67c935a0a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -55,9 +55,9 @@
"errorIdLabel": "ID d'erreur",
"errorIdCopy": "Copier l'ID d'erreur",
"errorIdCopied": "ID d'erreur copié",
- "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard.",
- "sessionEnded": "Ta session a expiré. Reconnecte-toi pour continuer.",
- "signInAgain": "Se connecter"
+ "readThrottled": "Il y a trop de requêtes pour le moment, réessayez plus tard.",
+ "sessionEnded": "Votre session a expiré, reconnectez-vous pour continuer.",
+ "signIn": "Se connecter"
},
"apiErrors": {
"measurement": {
@@ -4667,7 +4667,7 @@
"scoreCrossLink": "Voir le score de récupération composite",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.",
- "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnecte Oura une fois dans Intégrations et autorise-le.",
+ "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnectez Oura une fois dans Intégrations et autorisez-le.",
"emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.",
"emptyAction": "Ouvrir les intégrations",
"block": {
diff --git a/messages/it.json b/messages/it.json
index 4ca892e15..6e43c3354 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -55,9 +55,9 @@
"errorIdLabel": "ID errore",
"errorIdCopy": "Copia ID errore",
"errorIdCopied": "ID errore copiato",
- "readThrottled": "Troppe richieste in questo momento. Riprova più tardi.",
- "sessionEnded": "La tua sessione è scaduta. Accedi di nuovo per continuare.",
- "signInAgain": "Accedi"
+ "readThrottled": "Ci sono troppe richieste in questo momento, quindi riprova più tardi.",
+ "sessionEnded": "La tua sessione è scaduta, quindi accedi di nuovo per continuare.",
+ "signIn": "Accedi"
},
"apiErrors": {
"measurement": {
diff --git a/messages/ko.json b/messages/ko.json
index 7d8c1316b..7f23511b6 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -55,9 +55,9 @@
"errorIdLabel": "오류 ID",
"errorIdCopy": "오류 ID 복사",
"errorIdCopied": "오류 ID 복사됨",
- "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요.",
- "sessionEnded": "세션이 만료되었어요. 계속하려면 다시 로그인해 주세요.",
- "signInAgain": "로그인"
+ "readThrottled": "지금은 요청이 너무 많으니 나중에 다시 시도해 주세요.",
+ "sessionEnded": "세션이 만료되었으니 계속하려면 다시 로그인해 주세요.",
+ "signIn": "로그인"
},
"apiErrors": {
"measurement": {
@@ -4397,8 +4397,8 @@
"composition": "구성"
},
"gap": {
- "fewNights": "아직 밤이 부족함 ({count}/{needed})",
- "noMainSleep": "최근 하루는 낮잠만 있음"
+ "fewNights": "아직 밤이 부족해요 ({needed}일 중 {count}일)",
+ "noMainSleep": "최근 하루는 낮잠만 있었어요"
}
},
"READINESS": {
diff --git a/messages/nl.json b/messages/nl.json
index 8028f2dc4..ae766ad06 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -55,9 +55,9 @@
"errorIdLabel": "Fout-ID",
"errorIdCopy": "Fout-ID kopiëren",
"errorIdCopied": "Fout-ID gekopieerd",
- "readThrottled": "Er zijn net te veel verzoeken. Probeer het later opnieuw.",
- "sessionEnded": "Je sessie is verlopen. Meld je opnieuw aan om verder te gaan.",
- "signInAgain": "Aanmelden"
+ "readThrottled": "Er zijn net te veel verzoeken, dus probeer het later opnieuw.",
+ "sessionEnded": "Je sessie is verlopen, dus meld je opnieuw aan om verder te gaan.",
+ "signIn": "Aanmelden"
},
"apiErrors": {
"measurement": {
diff --git a/messages/pl.json b/messages/pl.json
index 3aa3bd00e..265fabc95 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -55,9 +55,9 @@
"errorIdLabel": "ID błędu",
"errorIdCopy": "Kopiuj ID błędu",
"errorIdCopied": "Skopiowano ID błędu",
- "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później.",
- "sessionEnded": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować.",
- "signInAgain": "Zaloguj się"
+ "readThrottled": "W tej chwili jest zbyt wiele zapytań, więc spróbuj ponownie później.",
+ "sessionEnded": "Twoja sesja wygasła, więc zaloguj się ponownie, aby kontynuować.",
+ "signIn": "Zaloguj się"
},
"apiErrors": {
"measurement": {
diff --git a/src/components/ui/__tests__/query-error-primitives.test.tsx b/src/components/ui/__tests__/query-error-primitives.test.tsx
index bd70c4a46..4a63fe044 100644
--- a/src/components/ui/__tests__/query-error-primitives.test.tsx
+++ b/src/components/ui/__tests__/query-error-primitives.test.tsx
@@ -91,7 +91,7 @@ describe.each([
it("says a spent budget without offering a retry", () => {
const html = render(new ApiError("Too many", 429));
- expect(html).toContain("Too many requests just now.");
+ expect(html).toContain("There are too many requests just now");
expect(html).not.toContain("retry");
expect(html).not.toContain("Could not load");
expect(html).not.toContain("Something on our side.");
@@ -100,7 +100,7 @@ describe.each([
it("offers sign-in for an ended session, never a blank", () => {
const html = render(new ApiError("Unauthorized", 401));
expect(html).not.toBe("");
- expect(html).toContain("Your session has ended.");
+ expect(html).toContain("Your session has ended");
expect(html).toContain('data-slot="query-error-sign-in"');
expect(html).not.toContain("retry");
});
diff --git a/src/hooks/use-read-failure.ts b/src/hooks/use-read-failure.ts
index 57800b410..e07a73dd4 100644
--- a/src/hooks/use-read-failure.ts
+++ b/src/hooks/use-read-failure.ts
@@ -45,7 +45,7 @@ export function useReadFailure(
kind: "unauthenticated",
message: t("common.sessionEnded"),
action: {
- label: t("common.signInAgain"),
+ label: t("common.signIn"),
onClick: () => window.location.assign(signInHref()),
kind: "signIn",
},