From 6116d92ff594f6d05a7d9c35aed5cd215b052661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 20:53:46 +0200 Subject: [PATCH 1/3] feat(modules): add module_preferences_json disabled-allowlist column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive, nullable JSONB on users carrying a default-on disabled allowlist for the optional (secondary) domains. NULL / empty / a key set to true all read as enabled; a key set to false disables that module. No backfill — every existing account reads all-on. Core domains (weight, blood pressure, pulse, medications) are never written here and have no toggle. cycle and coach keep their existing source of truth and the column does not own them. --- .../0167_module_enable_disable/migration.sql | 38 +++++++++++++++++++ prisma/schema.prisma | 29 ++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 prisma/migrations/0167_module_enable_disable/migration.sql 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` From d1089ba232858c79049cf05d9f92e31fc3a1c261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 20:53:54 +0200 Subject: [PATCH 2/3] feat(modules): add the canonical module registry and enforcement gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry.ts is the single source of truth for the toggleable secondary domains (cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, coach, insights, doctorReport) plus the always-on core domain denylist. Each entry carries a stable key, label/description i18n keys, a category, and an optional delegatesTo marker. gate.ts is the one enforcement point: isModuleEnabled / requireModuleEnabled (403 module.disabled envelope) plus resolveModuleMap for the /api/auth/me projection. No double source of truth — cycle delegates to isCycleEnabled and coach delegates to disableCoach AND the operator assistant master flag; every other module resolves from the disabled allowlist. Default-on, fail-open per key, per-request memoised reads. insights gates only the AI-analysis surfaces (briefing, status cards, correlation narration, score explainer) — never the raw measurement charts, which are core and render regardless. --- src/lib/modules/__tests__/gate.test.ts | 189 ++++++++++++++++++++++ src/lib/modules/gate.ts | 201 +++++++++++++++++++++++ src/lib/modules/registry.ts | 215 +++++++++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 src/lib/modules/__tests__/gate.test.ts create mode 100644 src/lib/modules/gate.ts create mode 100644 src/lib/modules/registry.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; +} From fa53631c553085ad65eb467d67cd8bd33d666d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 20:54:02 +0200 Subject: [PATCH 3/3] feat(api): surface and update per-user module preferences GET /api/auth/me now returns a resolved modules map for every toggleable module (cycle/coach reflect their delegated state). New PATCH/GET /api/auth/me/modules read and update the preferences: the body is strict over the toggleable key set so a core-domain or unknown key is a 422, the merge is field-by-field (no mass assignment), and userId is narrowed from the session. Documented in the OpenAPI registry and yaml. The disable-coach gate-inventory allowlist records the module gate's intentional coach delegation read. --- docs/api/openapi.yaml | 169 ++++++++++ .../auth/me/modules/__tests__/route.test.ts | 307 ++++++++++++++++++ src/app/api/auth/me/modules/route.ts | 123 +++++++ src/app/api/auth/me/route.ts | 16 +- .../__tests__/coach-user-disable.test.tsx | 6 + src/lib/openapi/routes/profile.ts | 85 +++++ src/lib/validations/modules.ts | 33 ++ 7 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/modules/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/modules/route.ts create mode 100644 src/lib/validations/modules.ts 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/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/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;