mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
merge(modules): module enable/disable foundation — registry, gate, migration 0167, /api/auth/me projection
This commit is contained in:
commit
2629976096
12 files changed
+1410
-1
No files matched your search
@@ -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,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;
|
||||
@@ -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: { <key>: boolean }` map.
|
||||
modulePreferencesJson Json? @map("module_preferences_json")
|
||||
|
||||
// v1.5.5 — self-hosted avatar storage.
|
||||
//
|
||||
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, boolean>;
|
||||
/** `CycleProfile.cycleTrackingEnabled`; NULL ⇒ derive from gender. */
|
||||
cycleTrackingEnabled: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce the persisted blob into a plain `Record<string, boolean>`,
|
||||
* 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<string, boolean> {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
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<ModuleGateInputs> {
|
||||
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<boolean> {
|
||||
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 `{ <key>: 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<Record<ModuleKey, boolean>> {
|
||||
const inputs = await loadInputs(userId);
|
||||
const assistantCoach = (await getAssistantFlags()).coach;
|
||||
const out = {} as Record<ModuleKey, boolean>;
|
||||
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<ModuleGateResult> {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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<string> = 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<Record<ModuleKey, ModuleDefinition>> =
|
||||
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<string> = 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;
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user