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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 20:54:02 +02:00
1 parent d1089ba232
commit fa53631c55
7 files changed
+738 -1

No files matched your search

+169
View File
@@ -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 `{ <moduleKey>: 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:
@@ -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<typeof import("@/lib/feature-flags")>();
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<string, boolean>;
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(
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<Response>)(mkPatch({ labs: false }));
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* v1.18.0 — per-user module enable/disable preferences.
*
* GET /api/auth/me/modules — resolved `{ <moduleKey>: boolean }` map
* for every toggleable module (cycle/coach
* reflect their real delegated state).
* PATCH /api/auth/me/modules — body `{ <moduleKey>?: 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<string, boolean> = 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 });
});
+15 -1
View File
@@ -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. `{ <moduleKey>: 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,
});
});
@@ -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",
];
/**
+85
View File
@@ -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 `{ <moduleKey>: 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<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/modules": {
get: {
tags: ["Auth"],
summary: "Read the resolved per-user module enable/disable map",
description:
"Returns the fully-resolved `{ <moduleKey>: 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"],
+33
View File
@@ -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 `{ <toggleableKey>?: 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<z.ZodBoolean>>;
/**
* 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<typeof modulePrefsPatchSchema>;