From 3090b742c49bee6972f0057709de1276d9ca63b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:36:05 +0200 Subject: [PATCH] docs(openapi): document the profile module map and the 403 module.disabled envelope Add the resolved `modules` map to ProfileResponse (shared ModuleMap schema) so the spec matches the alias payload, and add a reusable module-disabled 403 response documenting `meta.errorCode = module.disabled` plus `meta.module` (the disabled key). Wire that 403 onto the module-gated sleep night/rhythm routes, which emit it via requireModuleEnabled. Regenerate docs/api/openapi.yaml; openapi:check is in sync. --- docs/api/openapi.yaml | 39 +++++++++++++++++--------- src/lib/openapi/routes/measurements.ts | 4 ++- src/lib/openapi/routes/profile.ts | 6 +++- src/lib/openapi/routes/shared.ts | 16 +++++++++++ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6dc5a0f2a..7f44a1495 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1918,6 +1918,14 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 + "403": &a5 + description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/sleep/rhythm: @@ -1939,6 +1947,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 + "403": *a5 "422": *a3 "429": *a4 /api/workouts: @@ -5154,7 +5163,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a5 + "404": &a6 description: Not found / not owned by the caller. content: application/json: @@ -5189,7 +5198,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/{key}/hidden: @@ -5230,7 +5239,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/groups: @@ -5305,7 +5314,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 delete: @@ -5336,7 +5345,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/layout: @@ -5589,7 +5598,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "404": &a6 + "404": &a7 description: Measurement reminder not found / not owned. content: application/json: @@ -5623,7 +5632,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 delete: @@ -5645,7 +5654,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -5669,7 +5678,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 /api/labs: @@ -5816,7 +5825,7 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "404": &a7 + "404": &a8 description: Lab result not found (or owned by another user). content: application/json: @@ -5890,7 +5899,7 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "404": *a7 + "404": *a8 "422": *a3 "429": *a4 delete: @@ -5912,7 +5921,7 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "404": *a7 + "404": *a8 "422": *a3 "429": *a4 components: @@ -13751,6 +13760,8 @@ components: - type: string - type: "null" description: German KVNR, decrypted for the form prefill. + modules: + $ref: "#/components/schemas/ModuleMap" required: - username - email @@ -13765,9 +13776,11 @@ components: - insurerName - insurerIkNumber - insuranceNumber + - modules additionalProperties: false description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) - is plaintext at rest. + is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module + flags. PatchProfileResponse: type: object properties: diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9dbb92663..7a3b082ea 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -14,7 +14,7 @@ import { measurementSourceEnum, } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; const batchEntrySchema = z .object({ @@ -600,6 +600,7 @@ export const measurementPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, @@ -619,6 +620,7 @@ export const measurementPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 1c200012e..2a5654939 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -219,11 +219,15 @@ const profileResponse = z .string() .nullable() .describe("German KVNR, decrypted for the form prefill."), + // v1.18.0 — resolved module enable/disable map, identical to the + // /api/auth/me projection. The native client reads this alias, so it + // must carry the same server-authoritative module flags. + modules: moduleMapResolved, }) .meta({ id: "ProfileResponse", description: - "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", + "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module flags.", }); // The PATCH echo mirrors GET but reports KVNR presence as a boolean diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43c60cb71..599b305fd 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -124,3 +124,19 @@ export const consentRequiredResponse = { content: { "application/json": { schema: errorEnvelope } }, }, }; + +// ── Module-disabled gate (v1.18.0) ─────────────────────────────────── +// Every module-scoped route runs `requireModuleEnabled(userId, key)`, +// which returns a 403 when the account has the module turned off — even +// with a valid Bearer token. The envelope carries +// `meta.errorCode = "module.disabled"` and `meta.module` (the disabled +// module key) so the iOS retry classifier branches on it and the client +// can drop the whole surface. The errorEnvelope shape already declares +// `meta.errorCode`; `meta.module` is documented here in prose. +export const moduleDisabledResponse = { + "403": { + description: + 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.', + content: { "application/json": { schema: errorEnvelope } }, + }, +};