From 45fe97e07b7f879f7e4ef09c9e0097c76cafdac8 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:57:46 +0000 Subject: [PATCH] Declare unavailableWidgetIds in the wire contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widgets GET grew a response field and the spec was never told. Nothing would have caught it: `openapi:check` compares the generated yaml against the Zod registry, and an undeclared field changes neither — so CI stays green while the published contract quietly stops describing what the route returns. iOS decodes this payload. `dashboardLayoutResult` is shared by the GET and the PUT echo, but the field is resolved per request from the caller's modules and their data, so it exists only on the read. A separate `dashboardLayoutReadResult` extends the shared schema for the GET rather than widening both verbs and describing a field the PUT never sends. docs/api/openapi.yaml is NOT regenerated in this commit: `pnpm openapi:generate` cannot run here (the Prisma engine download is refused, so `@/generated/prisma/client` is missing and the registry will not load). The tree is therefore in drift on purpose and `pnpm openapi:check` will fail until `pnpm openapi:generate` is run and the yaml committed. --- src/lib/openapi/routes/dashboard.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index e9dc74148..e71ded325 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -108,6 +108,27 @@ const dashboardLayoutResult = dashboardLayoutSchema "Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token.", }); +/** + * The GET-only shape. `unavailableWidgetIds` is resolved per request from + * the caller's module state and their data, so it belongs to the read and + * never to the PUT echo — hence a separate schema rather than widening + * `dashboardLayoutResult`, which both verbs share. + */ +const dashboardLayoutReadResult = dashboardLayoutResult + .extend({ + unavailableWidgetIds: z + .array(z.string()) + .optional() + .describe( + "Widget ids whose toggle cannot do anything for this account, and which a settings UI should therefore not offer — a switch over a tile that can never paint. Resolved per request from the caller's enabled modules and the data they actually have: `hrv` appears here when the Recovery module is off and the account has no SDNN series, because its only remaining HRV source (nightly RMSSD) is recovery-owned and withheld. Absent when nothing is unavailable. Additive: a client that ignores it behaves exactly as before.", + ), + }) + .meta({ + id: "DashboardLayoutReadResult", + description: + "Resolved dashboard layout, its `updatedAt` token, and any widget toggles that are unavailable for this account.", + }); + // ── The native-client dashboard aggregator ─────────────────────────── // // `GET /api/dashboard/summary` predates the registry and is the shape the iOS @@ -372,7 +393,7 @@ export const dashboardWidgetPaths: NonNullable = { tags: ["Dashboard"], summary: "Read the calling user's dashboard widget layout", description: - "Returns the resolved effective layout (defaults merged in if the user has not customised it) plus the optimistic-concurrency `updatedAt` token.", + "Returns the resolved effective layout (defaults merged in if the user has not customised it), the optimistic-concurrency `updatedAt` token, and `unavailableWidgetIds` — any widget whose toggle cannot do anything for this account, which a settings UI should not offer.", responses: { ...recordRefusal(), "200": { @@ -380,7 +401,10 @@ export const dashboardWidgetPaths: NonNullable = { "The resolved layout (custom or default) plus its token.", content: { "application/json": { - schema: dataEnvelope(dashboardLayoutResult, "DashboardLayout"), + schema: dataEnvelope( + dashboardLayoutReadResult, + "DashboardLayout", + ), }, }, },