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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:36:05 +02:00
1 parent f435142db5
commit 3090b742c4
4 files changed
+50 -15

No files matched your search

+26 -13
View File
@@ -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:
+3 -1
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -619,6 +620,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
+5 -1
View File
@@ -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
+16
View File
@@ -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 } },
},
};