diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0a6360a77..5e957b8d5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3081,6 +3081,50 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/modules: + get: + tags: + - Auth + summary: Read the resolved per-user module enable/disable map + description: "Returns the fully-resolved `{ : boolean }` map for every toggleable module. `cycle` reflects + the cycle gate, `coach` reflects the resolved per-user opt-out + operator master flag, and the rest read the + per-user disabled-allowlist (`modulePreferencesJson`). Default-on: a fresh account reads every module enabled. + iOS/web read this to hide a whole module surface end-to-end." + responses: + "200": + description: Resolved module map. + content: + application/json: + schema: + $ref: "#/components/schemas/GetModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Auth + summary: Update per-user module enable/disable preferences + description: Merges the supplied keys into the persisted disabled-allowlist (`modulePreferencesJson`) field-by-field — a + PATCH touching only one module leaves the siblings intact. Refuses to disable a core module (`weight`, + `bloodPressure`, `pulse`, `medications`) or any unknown key with a 422. `userId` is never accepted from the + body. Always returns the fully-resolved next-state map so clients can hard-set their optimistic update. + Rate-limit 60/min per user. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModulePrefsPatchRequest" + responses: + "200": + description: Resolved next-state module map echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -6548,6 +6592,37 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + ModulePrefsPatchRequest: + type: object + properties: + cycle: + type: boolean + mood: + type: boolean + sleep: + type: boolean + glucose: + type: boolean + workouts: + type: boolean + recovery: + type: boolean + labs: + type: boolean + achievements: + type: boolean + coach: + type: boolean + insights: + type: boolean + doctorReport: + type: boolean + additionalProperties: false + description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, + `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a + core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the + measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but + their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag). ProfileUpdateRequest: type: object properties: @@ -12057,6 +12132,100 @@ components: - data - error additionalProperties: false + GetModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModulesResponse: + type: object + properties: + modules: + $ref: "#/components/schemas/ModuleMap" + required: + - modules + additionalProperties: false + ModuleMap: + type: object + properties: + cycle: + type: boolean + description: Whether the "cycle" module is enabled for this account. + mood: + type: boolean + description: Whether the "mood" module is enabled for this account. + sleep: + type: boolean + description: Whether the "sleep" module is enabled for this account. + glucose: + type: boolean + description: Whether the "glucose" module is enabled for this account. + workouts: + type: boolean + description: Whether the "workouts" module is enabled for this account. + recovery: + type: boolean + description: Whether the "recovery" module is enabled for this account. + labs: + type: boolean + description: Whether the "labs" module is enabled for this account. + achievements: + type: boolean + description: Whether the "achievements" module is enabled for this account. + coach: + type: boolean + description: Whether the "coach" module is enabled for this account. + insights: + type: boolean + description: Whether the "insights" module is enabled for this account. + doctorReport: + type: boolean + description: Whether the "doctorReport" module is enabled for this account. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - achievements + - coach + - insights + - doctorReport + additionalProperties: false + description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means + the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; + `coach` mirrors the resolved per-user opt-out + operator master flag. + PatchModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetSourcePriorityResponse: type: object properties: diff --git a/prisma/migrations/0167_module_enable_disable/migration.sql b/prisma/migrations/0167_module_enable_disable/migration.sql new file mode 100644 index 000000000..0a5a69144 --- /dev/null +++ b/prisma/migrations/0167_module_enable_disable/migration.sql @@ -0,0 +1,38 @@ +-- v1.18.0 — module enable/disable foundation. +-- +-- A single nullable JSONB blob on `users` carrying a DISABLED allowlist +-- for the optional ("secondary") domains (mood, sleep, glucose, workouts, +-- recovery, labs, achievements, insights, doctor-report, …). The contract +-- is default-on: +-- +-- * NULL column → every module enabled +-- * empty object `{}` → every module enabled +-- * key absent → that module enabled +-- * key present & `false` → that module DISABLED +-- * key present & `true` → enabled (redundant-but-allowed) +-- +-- No backfill — every existing user keeps every module on. The +-- application resolver (`src/lib/modules/gate.ts`) tolerates a missing +-- column / null row and reads all-on, so a partial deploy where the +-- schema lands ahead of (or behind) the app code keeps working. +-- +-- CORE domains (weight, blood pressure, pulse, medications) are never +-- written here and the write endpoint refuses to disable them, so the +-- measurement engine + meds can never be turned off via this column. +-- `cycle` and `coach` keep their existing source of truth +-- (`CycleProfile.cycle_tracking_enabled` / `users.disable_coach` + +-- the operator assistant master flag) — this column does not own them. +-- +-- No additional index — reads are per-user (`WHERE id = $1`), matching +-- the convention set by `coach_prefs_json`, `source_priority_json`, +-- `doctor_report_prefs_json`, and `notification_prefs` on the same table +-- (no GIN index on any of them). +-- +-- Idempotent guard (`IF NOT EXISTS`) so reruns are safe on prod. +-- +-- Reversibility: down migration is +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "module_preferences_json"; +-- A roll-back loses per-user module opt-outs (every user falls back to +-- all-on), which is the safe default — no data is destroyed. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "module_preferences_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf1b8bf7..354486506 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -401,6 +401,35 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + // v1.18.0 — module enable/disable foundation. + // + // A DISABLED allowlist for the optional ("secondary") domains. The + // contract is default-on: a NULL column, an empty object, or a key + // simply absent all mean "module enabled". A key present and set to + // `false` means that module is OFF; `true` is redundant-but-allowed. + // No backfill — every existing user reads as all-on. + // + // Shape (string keys ⇒ boolean): + // { "mood": false, "glucose": false, ... } + // + // The canonical key set + which keys are toggleable lives in + // `src/lib/modules/registry.ts`; the resolver + 403 guard live in + // `src/lib/modules/gate.ts`. CORE domains (weight, blood pressure, + // pulse, medications — the measurement engine + meds) are NEVER + // present here and the gate refuses to disable them, so a crafted + // `{ "weight": false }` blob cannot turn the core engine off. + // + // Two modules do NOT resolve from this column to avoid a double + // source of truth: `cycle` delegates to `CycleProfile`/`isCycleEnabled` + // and `coach` delegates to `User.disableCoach` + the operator-level + // assistant master flag. The column may still carry a `cycle`/`coach` + // key for forward-compat but the gate ignores it for those two. + // + // Persisted via `PATCH /api/auth/me/modules` with field-by-field + // merge semantics (no mass assignment); surfaced resolved on + // `GET /api/auth/me` as a `modules: { : boolean }` map. + modulePreferencesJson Json? @map("module_preferences_json") + // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl` diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts new file mode 100644 index 000000000..d7d80ce70 --- /dev/null +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -0,0 +1,307 @@ +/** + * v1.18.0 — PATCH/GET /api/auth/me/modules. + * + * Covers the read projection, the field-by-field merge, core-module + * refusal (strict schema), the cycle/coach delegation reflected in the + * resolved map, and the standard auth / rate-limit / 422 envelope. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + cycleProfile: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +// Operator master flag = on by default; coach delegation keys off it. +// Partial mock — api-handler imports `AssistantDisabledError` from here. +vi.mock("@/lib/feature-flags", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAssistantFlags: vi.fn().mockResolvedValue({ coach: true }), + }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getAssistantFlags } from "@/lib/feature-flags"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type ModuleMap = Record; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Default DB shape: fresh user, no module prefs, no cycle row. The + * `findUnique` mock reads from a mutable holder so a PATCH `update` + * (which writes `modulePreferencesJson`) is reflected by the post-update + * `resolveModuleMap` re-read — mirroring real DB round-trip semantics. + */ +function primeUser(over: { + gender?: string | null; + disableCoach?: boolean; + modulePreferencesJson?: unknown; + cycleTrackingEnabled?: boolean | null; +} = {}) { + const row: { + gender: string | null; + disableCoach: boolean; + modulePreferencesJson: unknown; + } = { + gender: over.gender ?? null, + disableCoach: over.disableCoach ?? false, + modulePreferencesJson: over.modulePreferencesJson ?? null, + }; + vi.mocked(prisma.user.findUnique).mockImplementation( + (() => Promise.resolve({ ...row })) as never, + ); + vi.mocked(prisma.user.update).mockImplementation(((args: { + data?: { modulePreferencesJson?: unknown }; + }) => { + const data = args.data; + if (data && "modulePreferencesJson" in data) { + row.modulePreferencesJson = data.modulePreferencesJson; + } + return Promise.resolve({}); + }) as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue( + over.cycleTrackingEnabled === undefined + ? null + : ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never); +}); + +describe("GET /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(401); + }); + + it("returns every toggleable module on for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(true); + expect(env.data.modules.sleep).toBe(true); + expect(env.data.modules.insights).toBe(true); + // cycle gender-derived (null gender ⇒ off); coach delegated (on). + expect(env.data.modules.cycle).toBe(false); + expect(env.data.modules.coach).toBe(true); + }); + + it("reflects a disabled module from the persisted allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.glucose).toBe(false); + expect(env.data.modules.mood).toBe(true); + }); + + it("reflects coach delegation (operator master flag off)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.coach).toBe(false); + }); +}); + +describe("PATCH /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("disables a module and persists the merged allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(false); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { mood: false } }, + }); + expect(auditLog).toHaveBeenCalledWith( + "user.modules.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ changed: ["mood"] }), + }), + ); + }); + + it("merges over the existing allowlist field-by-field (siblings intact)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // user previously disabled glucose; now disabling sleep too. + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ sleep: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: false, sleep: false } }, + }); + }); + + it("can re-enable a previously disabled module", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ glucose: true })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: true } }, + }); + }); + + it("REFUSES to disable a core module (strict schema 422)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + + for (const core of ["weight", "bloodPressure", "pulse", "medications"]) { + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ [core]: false }), + ); + expect(res.status).toBe(422); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an unknown module key with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ notAModule: false }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean value with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: "false" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("drops a previously-corrupted stored blob to clean booleans on merge", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // junk in the stored row must not leak into the persisted merge. + primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ labs: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { sleep: false, labs: false } }, + }); + }); +}); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts new file mode 100644 index 000000000..2be6c5b5d --- /dev/null +++ b/src/app/api/auth/me/modules/route.ts @@ -0,0 +1,123 @@ +/** + * v1.18.0 — per-user module enable/disable preferences. + * + * GET /api/auth/me/modules — resolved `{ : boolean }` map + * for every toggleable module (cycle/coach + * reflect their real delegated state). + * PATCH /api/auth/me/modules — body `{ ?: boolean }`. Merges + * the supplied keys into the persisted + * `modulePreferencesJson` DISABLED allowlist + * (field-by-field, no mass assignment) and + * returns the freshly-resolved module map. + * + * The body schema (`modulePrefsPatchSchema`) is `strict()` over the + * canonical toggleable key set, so a core-domain key (`weight`, + * `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422 — + * the core measurement engine + meds can never be disabled here. + * + * `userId` is always narrowed from `requireAuth()`; the body never + * carries it. The semantics are a DISABLED allowlist: a key set to + * `false` disables that module; `true` (or absence) leaves it enabled. + * cycle/coach keys are accepted for forward-compat but the gate ignores + * the blob for them (they delegate to their existing source of truth). + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.modules.get" } }); + + const modules = await resolveModuleMap(user.id); + return apiSuccess({ modules }); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `modules:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "modules.body.invalid_json"); + } + + const parsed = modulePrefsPatchSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.modules.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "modules.invalid", + }); + } + + // Merge the supplied keys into the persisted DISABLED allowlist, + // field-by-field. `normalisePrefs` strips any junk from the stored + // row so a previously-corrupted blob can't poison the merge, and the + // strict schema guarantees `parsed.data` carries only toggleable keys + // (core domains were rejected at the 422 above). + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { modulePreferencesJson: true }, + }); + const merged: Record = normalisePrefs( + existing?.modulePreferencesJson, + ); + const changed: string[] = []; + for (const [key, value] of Object.entries(parsed.data)) { + if (value === undefined) continue; + merged[key] = value; + changed.push(key); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: merged }, + }); + + const modules = await resolveModuleMap(user.id); + + await auditLog("user.modules.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { changed }, + }); + + annotate({ + action: { name: "auth.me.modules.patch" }, + meta: { changed }, + }); + + return apiSuccess({ modules }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 6538c5818..6da40db69 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -6,6 +6,7 @@ import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -21,12 +22,18 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, cycleProfile] = await Promise.all([ + const [, cycleProfile, modules] = await Promise.all([ setOnboardingPendingCookie(user.onboardingCompletedAt == null), prisma.cycleProfile.findUnique({ where: { userId: user.id }, select: { cycleTrackingEnabled: true }, }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(user.id), ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); @@ -85,5 +92,12 @@ export const GET = apiHandler(async () => { // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS // hides the whole cycle tab when this is false. cycleTrackingEnabled, + // v1.18.0 — module enable/disable map. `{ : boolean }` + // for every toggleable module; `false` means the module is OFF and + // the surface should disappear end-to-end (nav, dashboard, insights, + // …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the + // resolved `disableCoach` + operator master flag, so this map is the + // single thing a client needs to gate every secondary domain. + modules, }); }); diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index caedd7d9e..902aa2783 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -317,6 +317,12 @@ describe("Coach per-user disableCoach invariant", () => { // builder. "src/lib/dashboard/snapshot.ts", "src/app/api/dashboard/snapshot/route.ts", + // v1.18.0 — the module enable/disable gate delegates the `coach` + // module to the SAME two-layer source of truth (`user.disableCoach` + // AND the operator assistant master flag) rather than owning a + // second copy in `modulePreferencesJson`. Covered by the coach- + // delegation cases in `src/lib/modules/__tests__/gate.test.ts`. + "src/lib/modules/gate.ts", ]; /** diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts new file mode 100644 index 000000000..14ee98578 --- /dev/null +++ b/src/lib/modules/__tests__/gate.test.ts @@ -0,0 +1,189 @@ +/** + * v1.18.0 — module enable/disable gate resolver. + * + * Exercises the PURE resolver (`resolveModuleEnabled`) + the registry + * helpers, so the disabled-allowlist + delegation logic is asserted + * without a DB. The async `isModuleEnabled` / `requireModuleEnabled` + * thin wrappers are covered by the route test. + */ +import { describe, it, expect } from "vitest"; + +import { + resolveModuleEnabled, + normalisePrefs, + MODULE_DISABLED_ERROR_CODE, + type ModuleGateInputs, +} from "../gate"; +import { + isCoreDomain, + isModuleKey, + moduleDelegatesTo, + MODULE_KEYS, + CORE_DOMAIN_KEYS, +} from "../registry"; + +function inputs(over: Partial = {}): ModuleGateInputs { + return { + gender: null, + disableCoach: false, + modulePreferences: {}, + cycleTrackingEnabled: null, + ...over, + }; +} + +describe("resolveModuleEnabled — disabled allowlist (default-on)", () => { + it("enables a module when no preference is recorded", () => { + expect(resolveModuleEnabled("mood", inputs(), true)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true)).toBe(true); + expect(resolveModuleEnabled("labs", inputs(), true)).toBe(true); + }); + + it("enables a module when the key is present and true", () => { + expect( + resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true), + ).toBe(true); + }); + + it("disables a module ONLY on an explicit false", () => { + expect( + resolveModuleEnabled( + "glucose", + inputs({ modulePreferences: { glucose: false } }), + true, + ), + ).toBe(false); + }); + + it("isolates per-module: disabling one leaves siblings on", () => { + const i = inputs({ modulePreferences: { workouts: false } }); + expect(resolveModuleEnabled("workouts", i, true)).toBe(false); + expect(resolveModuleEnabled("recovery", i, true)).toBe(true); + expect(resolveModuleEnabled("labs", i, true)).toBe(true); + }); +}); + +describe("resolveModuleEnabled — cycle delegation", () => { + it("ignores the module blob and reads the cycle gate (gender-derived)", () => { + // Even a crafted `{ cycle: false }` blob cannot override the real + // FEMALE-derived enablement — the gate delegates to isCycleEnabled. + const i = inputs({ + gender: "FEMALE", + cycleTrackingEnabled: null, + modulePreferences: { cycle: false }, + }); + expect(resolveModuleEnabled("cycle", i, true)).toBe(true); + }); + + it("respects the cycle toggle when set", () => { + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "MALE", cycleTrackingEnabled: true }), + true, + ), + ).toBe(true); + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "FEMALE", cycleTrackingEnabled: false }), + true, + ), + ).toBe(false); + }); + + it("a MALE account with a null toggle is cycle-disabled", () => { + expect( + resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true), + ).toBe(false); + }); +}); + +describe("resolveModuleEnabled — coach delegation (two-layer)", () => { + it("is enabled only when the operator master flag AND !disableCoach", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), true), + ).toBe(true); + }); + + it("is disabled when the per-user opt-out is set", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: true }), true), + ).toBe(false); + }); + + it("is disabled when the operator master flag is off", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), false), + ).toBe(false); + }); + + it("ignores the module blob for coach", () => { + // A crafted `{ coach: true }` cannot re-enable against a disabled + // operator flag or a user opt-out. + const i = inputs({ disableCoach: true, modulePreferences: { coach: true } }); + expect(resolveModuleEnabled("coach", i, true)).toBe(false); + }); +}); + +describe("insights — narrative layer, not a delegated module", () => { + it("resolves from the disabled allowlist like the other secondary domains", () => { + expect(resolveModuleEnabled("insights", inputs(), true)).toBe(true); + expect( + resolveModuleEnabled( + "insights", + inputs({ modulePreferences: { insights: false } }), + true, + ), + ).toBe(false); + }); + + it("is not delegated (no double source of truth marker)", () => { + expect(moduleDelegatesTo("insights")).toBeUndefined(); + }); +}); + +describe("normalisePrefs — fail-open coercion", () => { + it("treats null / non-object / array as empty (all-on)", () => { + expect(normalisePrefs(null)).toEqual({}); + expect(normalisePrefs(undefined)).toEqual({}); + expect(normalisePrefs("nope")).toEqual({}); + expect(normalisePrefs(["mood"])).toEqual({}); + }); + + it("keeps only boolean values, dropping junk entries", () => { + expect( + normalisePrefs({ mood: false, sleep: "yes", glucose: 0, labs: true }), + ).toEqual({ mood: false, labs: true }); + }); +}); + +describe("registry — core domains can never be disabled", () => { + it("the four core domains are not toggleable module keys", () => { + for (const core of CORE_DOMAIN_KEYS) { + expect(isModuleKey(core)).toBe(false); + expect(isCoreDomain(core)).toBe(true); + } + }); + + it("a crafted core-domain key is inert (never read as a module)", () => { + // `weight: false` in the blob can't matter: weight is not a ModuleKey, + // so the gate never consults it. The resolver only ever runs for the + // 11 declared toggleable keys. + expect(isModuleKey("weight")).toBe(false); + expect(isModuleKey("medications")).toBe(false); + }); + + it("every toggleable key is a known module and not a core domain", () => { + for (const key of MODULE_KEYS) { + expect(isModuleKey(key)).toBe(true); + expect(isCoreDomain(key)).toBe(false); + } + }); +}); + +describe("wire contract", () => { + it("exports the iOS-classifier errorCode", () => { + expect(MODULE_DISABLED_ERROR_CODE).toBe("module.disabled"); + }); +}); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts new file mode 100644 index 000000000..58658e73b --- /dev/null +++ b/src/lib/modules/gate.ts @@ -0,0 +1,201 @@ +/** + * v1.18.0 — module enable/disable gate. + * + * The ONE enforcement point for the module foundation. Every leak-point + * (nav, dashboard tiles, insights sections, coach snapshot, status + * routes, reminder jobs, achievements, doctor-report / FHIR, search, + * quick-add) resolves its decision through `isModuleEnabled(userId, key)` + * and `requireModuleEnabled(userId, key)`. Mirrors the cycle gate + * (`src/lib/cycle/gate.ts`) and the assistant feature flags + * (`src/lib/feature-flags/index.ts`). + * + * Source-of-truth discipline (NO double source of truth): + * + * - `cycle` delegates to `isCycleEnabled(gender, CycleProfile)` — the + * existing cycle gate. `modulePreferencesJson.cycle` is + * ignored on purpose. + * - `coach` delegates to `User.disableCoach` AND the operator-level + * assistant master flag (`getAssistantFlags().coach`). Both + * must agree, matching the two-layer model the client gates + * already enforce. + * - every other module resolves purely from `modulePreferencesJson` + * as a DISABLED allowlist (absent / empty / `true` ⇒ on; an + * explicit `false` ⇒ off). Default-on. + * + * Fail-closed posture: the resolver only ever DISABLES a module when it + * sees an explicit, well-typed `false` (or, for delegated modules, the + * delegated source returning disabled). A malformed blob, a missing + * column, an unknown key — none of these can silently disable a module + * (default-on), and none can enable a delegated one against its real + * source. A crafted `{ "weight": false }` blob is inert because `weight` + * is a core domain, not a `ModuleKey`. + * + * Reads are memoised per-request (the assistant-flags pattern) so a + * single request that gates several modules hits the row once. + */ +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { + MODULE_KEYS, + moduleDelegatesTo, + type ModuleKey, +} from "./registry"; + +export { MODULE_KEYS } from "./registry"; +export type { ModuleKey } from "./registry"; + +/** Wire `errorCode` the iOS retry classifier branches on. */ +export const MODULE_DISABLED_ERROR_CODE = "module.disabled"; + +/** + * The per-user fields the gate needs. Bundled so the resolver does a + * single round-trip and the memo caches one row per request. + */ +export interface ModuleGateInputs { + gender: string | null; + disableCoach: boolean; + modulePreferences: Record; + /** `CycleProfile.cycleTrackingEnabled`; NULL ⇒ derive from gender. */ + cycleTrackingEnabled: boolean | null; +} + +/** + * Coerce the persisted blob into a plain `Record`, + * keeping only boolean values. A null column, a non-object, or junk + * entries all collapse to an empty / partial map — which the + * disabled-allowlist semantics read as "all on". Fail-open per key: + * only a literal `false` disables. + */ +export function normalisePrefs(raw: unknown): Record { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "boolean") { + out[k] = v; + } + } + return out; +} + +/** + * Pure resolver — exposed for unit tests so the disabled-allowlist + + * delegation logic can be asserted without a DB. `assistantCoach` is the + * resolved operator-level `getAssistantFlags().coach`; pass it in so this + * stays synchronous and side-effect-free. + */ +export function resolveModuleEnabled( + key: ModuleKey, + inputs: ModuleGateInputs, + assistantCoach: boolean, +): boolean { + const delegate = moduleDelegatesTo(key); + + if (delegate === "cycle") { + // Single source of truth: the cycle gate. The module blob is ignored. + return isCycleEnabled(inputs.gender, { + cycleTrackingEnabled: inputs.cycleTrackingEnabled, + }); + } + + if (delegate === "coach") { + // Two-layer model: operator master flag AND per-user opt-out. + return assistantCoach && !inputs.disableCoach; + } + + // Disabled allowlist: only an explicit `false` turns the module off. + return inputs.modulePreferences[key] !== false; +} + +/** Internal: load the gate inputs once per request. */ +function loadInputs(userId: string): Promise { + return memoizePerRequest(`module-gate-inputs:${userId}`, async () => { + const [user, cycleProfile] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + gender: true, + disableCoach: true, + modulePreferencesJson: true, + }, + }), + prisma.cycleProfile.findUnique({ + where: { userId }, + select: { cycleTrackingEnabled: true }, + }), + ]); + return { + gender: user?.gender ?? null, + disableCoach: user?.disableCoach ?? false, + modulePreferences: normalisePrefs(user?.modulePreferencesJson), + cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null, + }; + }); +} + +/** + * Resolve whether a module is enabled for an account. Default-on: + * an unknown / absent / non-`false` state reads as enabled. Delegated + * modules (`cycle`, `coach`) resolve through their real source. + */ +export async function isModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + const inputs = await loadInputs(userId); + const delegate = moduleDelegatesTo(moduleKey); + // Only pay the assistant-flags read when resolving the coach delegate. + const assistantCoach = + delegate === "coach" ? (await getAssistantFlags()).coach : false; + return resolveModuleEnabled(moduleKey, inputs, assistantCoach); +} + +/** + * Resolve the full `{ : boolean }` map for every toggleable module, + * suitable for the `GET /api/auth/me` projection. cycle/coach reflect + * their real delegated state. + */ +export async function resolveModuleMap( + userId: string, +): Promise> { + const inputs = await loadInputs(userId); + const assistantCoach = (await getAssistantFlags()).coach; + const out = {} as Record; + for (const key of MODULE_KEYS) { + out[key] = resolveModuleEnabled(key, inputs, assistantCoach); + } + return out; +} + +/** + * Route-guard outcome: either a clear pass or a ready 403 envelope to + * return verbatim — mirrors `CycleGateResult`. + */ +export type ModuleGateResult = + | { enabled: true } + | { enabled: false; response: Response }; + +/** + * Enforce the gate for a module-scoped route. Returns a 403 + * `module.disabled` envelope (carrying the module key in `meta`) when the + * account has the module turned off — even with a valid Bearer token. + */ +export async function requireModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + if (await isModuleEnabled(userId, moduleKey)) { + return { enabled: true }; + } + return { + enabled: false, + response: apiError(`Module "${moduleKey}" is not enabled`, 403, { + errorCode: MODULE_DISABLED_ERROR_CODE, + module: moduleKey, + }), + }; +} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts new file mode 100644 index 000000000..45f2781c1 --- /dev/null +++ b/src/lib/modules/registry.ts @@ -0,0 +1,215 @@ +/** + * v1.18.0 — canonical module registry. + * + * The single source of truth for "what is a module" in HealthLog's + * enable/disable foundation. Every leak-point (nav, dashboard tiles, + * insights sections, coach snapshot, status routes, reminder jobs, + * achievements, doctor-report / FHIR, search, quick-add) reads its gate + * decision through `src/lib/modules/gate.ts`, which keys off the entries + * declared here. + * + * Two classes of domain: + * + * - CORE (always-on): weight, blood pressure, pulse, medications — the + * measurement engine + meds. These are NOT in `MODULE_KEYS` and have + * NO registry entry. They can never be disabled; the gate has no key + * to flip and the write endpoint refuses them. This is structural, + * not a runtime check future code can soften. + * + * - TOGGLEABLE (the "secondary domains"): the maintainer-chosen scope + * below. Each carries a stable key, an i18n label + description key, + * a category for the Settings hub grouping, and an optional + * `delegatesTo` marker for the two modules whose enabled-state is + * owned elsewhere (no double source of truth). + * + * The persisted shape (`User.modulePreferencesJson`) is a DISABLED + * allowlist: absent / empty / `true` ⇒ enabled, an explicit `false` ⇒ + * disabled. Default-on, no backfill. + */ + +/** + * Where a module's enabled-state is resolved when it is NOT owned by + * `modulePreferencesJson` directly. Keeps a single source of truth: + * + * - "cycle" → `isCycleEnabled(gender, CycleProfile)` (the existing + * cycle gate; `modulePreferencesJson.cycle` is ignored). + * - "coach" → `User.disableCoach` AND the operator-level assistant + * master flag (`getAssistantFlags().coach`); again the + * module blob is ignored for this key. + * + * Every other toggleable module has no `delegatesTo` and resolves purely + * from `modulePreferencesJson`. + */ +export type ModuleDelegation = "cycle" | "coach"; + +/** Coarse grouping for the Settings "What you track" hub. */ +export type ModuleCategory = + | "tracking" // user-logged domains (mood, cycle, glucose, labs) + | "device" // device / passive-sync domains (sleep, workouts, recovery) + | "engagement" // gamification (achievements) + | "intelligence" // AI-driven surfaces (coach, insights) + | "export"; // outbound reporting (doctor report / FHIR) + +export interface ModuleDefinition { + /** Stable key — the literal used in `modulePreferencesJson` + the API. */ + key: ModuleKey; + /** i18n key for the human-readable module name (messages/*.json). */ + labelKey: string; + /** i18n key for the one-line "what turning this off does" explainer. */ + descriptionKey: string; + /** Settings-hub grouping. */ + category: ModuleCategory; + /** + * When set, the module's enabled-state is NOT resolved from + * `modulePreferencesJson` — the gate delegates to the named source so + * there is exactly one source of truth. See `ModuleDelegation`. + */ + delegatesTo?: ModuleDelegation; +} + +/** + * The toggleable modules — the maintainer's "secondary domains" scope. + * + * Declared as a const tuple so `ModuleKey` is the exact union and the + * registry stays exhaustively typed. + */ +export const MODULE_KEYS = [ + "cycle", + "mood", + "sleep", + "glucose", + "workouts", + "recovery", + "labs", + "achievements", + "coach", + "insights", + "doctorReport", +] as const; + +export type ModuleKey = (typeof MODULE_KEYS)[number]; + +/** + * CORE domains — the always-on measurement engine + medications. Listed + * here for documentation + the write-endpoint denylist; they have NO + * `ModuleDefinition` and can never appear as a toggle. A crafted + * `{ "weight": false }` blob is inert: `weight` is not a `ModuleKey`, so + * the gate never reads it and the PATCH validator rejects it. + */ +export const CORE_DOMAIN_KEYS = [ + "weight", + "bloodPressure", + "pulse", + "medications", +] as const; + +export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number]; + +const CORE_DOMAIN_SET: ReadonlySet = new Set(CORE_DOMAIN_KEYS); + +/** True for the four always-on core domains (never disableable). */ +export function isCoreDomain(key: string): key is CoreDomainKey { + return CORE_DOMAIN_SET.has(key); +} + +/** + * The registry. Order is the Settings-hub display order (grouped by + * category in the UI; this flat order is the canonical iteration order). + * + * `insights` decision (documented): disabling `insights` hides only the + * AI-ANALYSIS surfaces — the Daily Briefing, the per-metric AI status + * cards, the correlation narration, the Health-Score explainer. It does + * NOT hide the raw weight / BP / pulse charts or any measurement data: + * those are core and render from live measurements regardless. `insights` + * is the narrative layer, not the data layer. (The operator-level + * assistant master flag is a separate, server-wide kill-switch; this + * per-user toggle sits below it.) + * + * `coach` is the per-user opt-out half of the existing two-layer model + * (`User.disableCoach` AND the operator master flag). It is surfaced here + * so the Modules hub lists it alongside its siblings, but it delegates — + * the module blob never owns it. + */ +export const MODULE_REGISTRY: Readonly> = + Object.freeze({ + cycle: { + key: "cycle", + labelKey: "modules.cycle.label", + descriptionKey: "modules.cycle.description", + category: "tracking", + delegatesTo: "cycle", + }, + mood: { + key: "mood", + labelKey: "modules.mood.label", + descriptionKey: "modules.mood.description", + category: "tracking", + }, + glucose: { + key: "glucose", + labelKey: "modules.glucose.label", + descriptionKey: "modules.glucose.description", + category: "tracking", + }, + labs: { + key: "labs", + labelKey: "modules.labs.label", + descriptionKey: "modules.labs.description", + category: "tracking", + }, + sleep: { + key: "sleep", + labelKey: "modules.sleep.label", + descriptionKey: "modules.sleep.description", + category: "device", + }, + workouts: { + key: "workouts", + labelKey: "modules.workouts.label", + descriptionKey: "modules.workouts.description", + category: "device", + }, + recovery: { + key: "recovery", + labelKey: "modules.recovery.label", + descriptionKey: "modules.recovery.description", + category: "device", + }, + achievements: { + key: "achievements", + labelKey: "modules.achievements.label", + descriptionKey: "modules.achievements.description", + category: "engagement", + }, + coach: { + key: "coach", + labelKey: "modules.coach.label", + descriptionKey: "modules.coach.description", + category: "intelligence", + delegatesTo: "coach", + }, + insights: { + key: "insights", + labelKey: "modules.insights.label", + descriptionKey: "modules.insights.description", + category: "intelligence", + }, + doctorReport: { + key: "doctorReport", + labelKey: "modules.doctorReport.label", + descriptionKey: "modules.doctorReport.description", + category: "export", + }, + }); + +const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); + +/** True when `key` is a known toggleable module. */ +export function isModuleKey(key: string): key is ModuleKey { + return MODULE_KEY_SET.has(key); +} + +/** The two delegated keys, resolved by their existing source of truth. */ +export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined { + return MODULE_REGISTRY[key].delegatesTo; +} diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 3a6f4224c..1c200012e 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -10,8 +10,45 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; import { notificationPrefsSchema } from "@/lib/validations/notification-prefs"; import { sourcePrioritySchema } from "@/lib/validations/source-priority"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; +import { MODULE_KEYS } from "@/lib/modules/registry"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime +// validator (`modulePrefsPatchSchema` — strict over the toggleable key +// set; a core-domain or unknown key is a 422). The response is the +// fully-resolved `{ : boolean }` map for every toggleable +// module: `cycle` mirrors the cycle gate, `coach` mirrors the resolved +// `disableCoach` + operator master flag, the rest read the per-user +// disabled-allowlist. The map is the single thing a client needs to gate +// every secondary domain end-to-end. +const modulePrefsPatchRequest = modulePrefsPatchSchema.meta({ + id: "ModulePrefsPatchRequest", + description: + 'Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag).', +}); + +const moduleMapResolved = z + .object( + Object.fromEntries( + MODULE_KEYS.map((k) => [ + k, + z + .boolean() + .describe(`Whether the "${k}" module is enabled for this account.`), + ]), + ), + ) + .meta({ + id: "ModuleMap", + description: + "Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.", + }); + +const moduleMapEnvelopeInner = z + .object({ modules: moduleMapResolved }) + .meta({ id: "ModulesResponse" }); + // v1.4.49 — single schema for the per-user Coach opt-out flag. Previously // split into `disableCoachBody` (PATCH request) and `disableCoachData` // (response payload), both `z.object({ disableCoach: z.boolean() })`. The @@ -374,6 +411,54 @@ export const profilePaths: NonNullable = { }, }, }, + "/api/auth/me/modules": { + get: { + tags: ["Auth"], + summary: "Read the resolved per-user module enable/disable map", + description: + "Returns the fully-resolved `{ : boolean }` map for every toggleable module. `cycle` reflects the cycle gate, `coach` reflects the resolved per-user opt-out + operator master flag, and the rest read the per-user disabled-allowlist (`modulePreferencesJson`). Default-on: a fresh account reads every module enabled. iOS/web read this to hide a whole module surface end-to-end.", + responses: { + "200": { + description: "Resolved module map.", + content: { + "application/json": { + schema: dataEnvelope( + moduleMapEnvelopeInner, + "GetModulesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Update per-user module enable/disable preferences", + description: + "Merges the supplied keys into the persisted disabled-allowlist (`modulePreferencesJson`) field-by-field — a PATCH touching only one module leaves the siblings intact. Refuses to disable a core module (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key with a 422. `userId` is never accepted from the body. Always returns the fully-resolved next-state map so clients can hard-set their optimistic update. Rate-limit 60/min per user.", + requestBody: { + required: true, + content: { + "application/json": { schema: modulePrefsPatchRequest }, + }, + }, + responses: { + "200": { + description: "Resolved next-state module map echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + moduleMapEnvelopeInner, + "PatchModulesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/source-priority": { get: { tags: ["Auth"], diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts new file mode 100644 index 000000000..44c29ebf6 --- /dev/null +++ b/src/lib/validations/modules.ts @@ -0,0 +1,33 @@ +/** + * v1.18.0 — module enable/disable request validation. + * + * The PATCH body is a partial map of TOGGLEABLE module keys → boolean. + * Only keys in the canonical registry (`MODULE_KEYS`) are accepted; a + * core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or + * any unknown key is a 422 — so the core measurement engine + meds can + * never be disabled through this surface (defence-in-depth on top of the + * gate, which has no key to flip for core domains anyway). + * + * `strict()` rejects unknown keys outright rather than silently dropping + * them, so a client gets a clear error instead of a no-op. + * + * The shared shape is reused by the OpenAPI registry so the wire + * contract stays single-source. + */ +import { z } from "zod/v4"; + +import { MODULE_KEYS } from "@/lib/modules/registry"; + +/** Build `{ ?: boolean }` from the canonical key list. */ +const moduleShape = Object.fromEntries( + MODULE_KEYS.map((k) => [k, z.boolean().optional()]), +) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional>; + +/** + * Partial module-preferences PATCH body. Every key is an optional + * boolean; omitted keys are left untouched server-side. `strict()` + * rejects any key not in `MODULE_KEYS` (including core domains). + */ +export const modulePrefsPatchSchema = z.object(moduleShape).strict(); + +export type ModulePrefsPatch = z.infer;