From f435142db5af49bbdb0a860cedc72829b579be91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:35:59 +0200 Subject: [PATCH 1/2] feat(profile): carry the resolved module map on the /api/user/profile alias The native client reads /api/user/profile, not /api/auth/me, so the v1.18.0 module enable/disable map never reached it. Resolve the same `resolveModuleMap` projection /api/auth/me returns and include it on the alias response, overlapped with the profile-row read. Extend the route test to assert the map is present and server-authoritative. --- .../api/user/profile/__tests__/route.test.ts | 51 ++++++++++++++++++ src/app/api/user/profile/route.ts | 53 ++++++++++++------- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 67b6568f7..8a8654e13 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -19,6 +19,18 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// v1.18.0 — the GET alias resolves the module map. Stub it so these +// tests assert the projection wiring without standing up the gate's +// own DB round-trips (operator availability + assistant flags + cycle). +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn().mockResolvedValue({ + sleep: true, + glucose: true, + cycle: false, + coach: true, + }), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -31,6 +43,14 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { resolveModuleMap } from "@/lib/modules/gate"; + +const MODULE_MAP = { + sleep: true, + glucose: true, + cycle: false, + coach: true, +} as const; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -39,6 +59,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -108,6 +129,36 @@ describe("GET /api/user/profile", () => { expect(body.data.insurerIkNumber).toBe("101234567"); expect(body.data.insurerName).toBe("Example Insurer"); }); + + // v1.18.0 — the alias the iOS app reads must carry the resolved module + // map, matching the /api/auth/me projection so the client gets module + // flags from the endpoint it actually fetches. + it("includes the resolved module map", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "testuser", + displayName: null, + email: null, + dateOfBirth: null, + gender: null, + heightCm: null, + locale: null, + timezone: "Europe/Berlin", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + expect(resolveModuleMap).toHaveBeenCalledWith("user-1"); + const body = (await res.json()) as { + data: { modules: Record }; + }; + expect(body.data.modules).toEqual(MODULE_MAP); + }); }); describe("PATCH /api/user/profile", () => { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index cf799b456..095ade7e3 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context"; import { applyProfileUpdate } from "@/lib/auth/profile-update"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; +import { resolveModuleMap } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -25,25 +26,35 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "user.profile.get" } }); - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - username: true, - displayName: true, - email: true, - dateOfBirth: true, - gender: true, - heightCm: true, - locale: true, - timezone: true, - timeFormat: true, - moodReminderEnabled: true, - fullName: true, - insurerName: true, - insurerIkNumber: true, - insuranceNumberEncrypted: true, - }, - }); + // Overlap the profile-row read with the module-map resolve (a separate + // memoised Postgres round-trip) — both feed the same response and have + // no ordering dependency. + const [dbUser, modules] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + username: true, + displayName: true, + email: true, + dateOfBirth: true, + gender: true, + heightCm: true, + locale: true, + timezone: true, + timeFormat: true, + moodReminderEnabled: true, + fullName: true, + insurerName: true, + insurerIkNumber: true, + insuranceNumberEncrypted: true, + }, + }), + // v1.18.0 — resolved module enable/disable map, identical to the + // projection `GET /api/auth/me` returns. The iOS app reads this alias + // endpoint, so it must carry the same server-authoritative flags; a + // `false` key means the module surface disappears end-to-end. + resolveModuleMap(user.id), + ]); // v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s // the profile fetch. @@ -71,6 +82,10 @@ export const GET = apiHandler(async () => { insurerName: dbUser?.insurerName ?? null, insurerIkNumber: dbUser?.insurerIkNumber ?? null, insuranceNumber, + // v1.18.0 — module enable/disable map, matching the /api/auth/me + // projection exactly so the native client gets module flags from the + // endpoint it actually reads. + modules, }); }); 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 2/2] 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 } }, + }, +};