diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 4a4bbf87f..d312df72f 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -107,11 +107,13 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/mood/tags/groups/[key]/route.ts", "src/app/api/mood/tags/[key]/hidden/route.ts", // ── DATA LAYER (workouts / labs) ────────────────────────────────── - // Raw Workout + LabResult CRUD + the iOS batch ingest. Same data-layer - // reasoning: the workouts / labs modules gate the surfaces (Insights - // pill, dashboard tile, doctor-report section), not the row store. - "src/app/api/workouts/route.ts", - "src/app/api/workouts/[id]/route.ts", + // The workout READ surfaces (`GET /api/workouts`, `GET /api/workouts/{id}`) + // are now module-gated — they back the hidden Insights workouts surface, so + // a disabled account must not read them even over a Bearer token. Only the + // iOS batch INGEST stays exempt: synced workouts must keep landing in the + // row store while the surface is hidden, so re-enabling reveals a complete + // history rather than a gap. LabResult CRUD follows the same data-layer + // reasoning (the labs module gates the surfaces, not the row store). "src/app/api/workouts/batch/route.ts", "src/app/api/labs/route.ts", "src/app/api/labs/[id]/route.ts", diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 9b5a24b08..7d51e212b 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -363,6 +363,66 @@ describe("GET /api/analytics", () => { expect(body.data.lastSeenByType.PULSE).toBeNull(); }); + // v1.18.0 B1 — the default slice serves the glucose-only surfaces + // (per-context summaries + the TIR/GMI/eA1C clinical panel) only when + // the `glucose` module is enabled. A disabled-glucose account nulls + // both fields rather than 403-ing the whole core-metrics payload. + function seedGlucoseRows() { + const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + vi.mocked(prisma.measurement.findMany).mockImplementation( + (async (args: { where?: { type?: string } }) => { + if (args?.where?.type === "BLOOD_GLUCOSE") { + return [ + { value: 95, measuredAt, glucoseContext: "FASTING" }, + { value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" }, + ]; + } + return []; + }) as never, + ); + } + + it("serves glucose clinical + per-context when glucose enabled", async () => { + seedGlucoseRows(); + vi.mocked(isModuleEnabled).mockResolvedValue(true as never); + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseByContext: Record | null; + glucoseClinical: unknown | null; + }; + }; + expect(body.data.glucoseClinical).not.toBeNull(); + expect(body.data.glucoseByContext).not.toBeNull(); + expect(body.data.glucoseByContext?.FASTING).toBeDefined(); + }); + + it("nulls glucose clinical + per-context when glucose disabled", async () => { + seedGlucoseRows(); + vi.mocked(isModuleEnabled).mockImplementation( + (async (_userId: string, key: string) => key !== "glucose") as never, + ); + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseByContext: Record | null; + glucoseClinical: unknown | null; + summaries: Record; + }; + }; + // Core metrics still served — the route did NOT 403. + expect(body.data.summaries.WEIGHT.count).toBe(0); + // Glucose-only surfaces are nulled. + expect(body.data.glucoseClinical).toBeNull(); + expect(body.data.glucoseByContext).toBeNull(); + }); + // v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked` // fan-out was removed entirely; the default slice now delegates to // `computeSummariesSlice` which runs three rollup-tier `$queryRaw` diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 7d37fde48..749a7beed 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -427,6 +427,14 @@ async function buildAnalyticsResponse(user: AuthedUser) { // already blanks the mood tile + the coach snapshot unions mood out; // this closes the matching gap on the Health Score. const moodEnabled = await isModuleEnabled(user.id, "mood"); + // v1.18.0 B1 — resolve the `glucose` module server-side. The analytics + // route serves core metrics too, so a disabled-glucose account must NOT + // 403 the whole payload; instead the glucose-only surfaces (per-context + // summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they + // never reach a hidden surface. Mirrors the mood-pillar gate above. + const glucoseEnabled = await isModuleEnabled(user.id, "glucose"); + const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null; + const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null; const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, ...bpInputs, @@ -447,8 +455,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctPriorYear, bpInTargetCount90, bpInTargetSpanDays90, - glucoseByContext, - glucoseClinical, + glucoseByContext: glucoseByContextOut, + glucoseClinical: glucoseClinicalOut, correlations, healthScore, sleepStages, diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index d2b7141a9..d4f55e537 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -29,6 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { requireModuleEnabled } from "@/lib/modules/gate"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,6 +40,10 @@ export const GET = apiHandler( annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); + // v1.18.0 B1 — gate the detail surface behind the workouts module. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const row = await prisma.workout.findUnique({ where: { id }, include: { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index de21ca911..4d360b71c 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -113,6 +123,10 @@ describe("GET /api/workouts — canonical dedup", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { @@ -139,6 +153,20 @@ describe("GET /api/workouts — canonical dedup", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest()); + expect(res.status).toBe(403); + // The list query never runs once the gate refuses. + expect(prisma.workout.findMany).not.toHaveBeenCalled(); + }); + it("filters by ownership — only the caller's rows enter the picker", async () => { // The mock returns whatever the route asked Prisma for; the route's // `where: { userId: user.id }` filter is what enforces ownership. diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 683a53c5d..f3de4f6d1 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -94,6 +104,10 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("returns the workout with iOS-contract field names", async () => { @@ -123,6 +137,20 @@ describe("GET /api/workouts/{id}", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(403); + // The detail query never runs once the gate refuses. + expect(prisma.workout.findUnique).not.toHaveBeenCalled(); + }); + it("returns 404 when the row belongs to another user", async () => { vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ ...BASE_ROW, diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 4fe739e6a..098a99990 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -55,6 +55,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; @@ -171,6 +172,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "workouts.list" } }); + // v1.18.0 B1 — the workouts surface is a toggleable module. A disabled + // account must not be able to read the list even with a valid Bearer + // token. The batch INGEST route stays ungated so synced data still + // lands while the surface is hidden. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const { searchParams } = new URL(request.url); const limit = Math.min( Math.max( diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index e753ad589..22b2c3a9b 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { Droplet } from "lucide-react"; +import { Droplet, Loader2 } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel"; import { useAuth } from "@/hooks/use-auth"; +import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; import { resolveGlucoseUnit } from "@/lib/glucose"; @@ -29,9 +30,19 @@ const MGDL_PER_MMOL = 18.0182; export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); + const { ready } = useModulePageGuard("glucose"); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const isMmol = glucoseUnit === "mmol/L"; + // v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account. + if (!ready) { + return ( +
+ +
+ ); + } + return ( + + + ); + } return ( + + + ); + } + if (isEmpty) { return ( + + + ); + } + return ( + + + ); + } + return ( ({ + useRouter: () => ({ push: pushMock }), +})); + +const useAuthMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => useAuthMock(), +})); + +import { useModulePageGuard } from "../use-module-page-guard"; + +function Probe({ moduleKey }: { moduleKey: "glucose" }) { + const { ready } = useModulePageGuard(moduleKey); + return {String(ready)}; +} + +function readyOf(): string { + const html = renderToStaticMarkup(); + return html.includes(">true<") ? "true" : "false"; +} + +describe("useModulePageGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is not ready while auth is loading", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when unauthenticated", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when the module is disabled (explicit false)", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: false } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("false"); + }); + + it("is ready when authenticated and the module is enabled", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: true } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); + + it("is ready (default-on) when the module key is absent", () => { + useAuthMock.mockReturnValue({ + user: { modules: {} }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); +}); diff --git a/src/hooks/use-module-page-guard.ts b/src/hooks/use-module-page-guard.ts new file mode 100644 index 000000000..758d908f4 --- /dev/null +++ b/src/hooks/use-module-page-guard.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/hooks/use-auth"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 B1 — client-side page guard for a toggleable module. + * + * Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated + * visitor bounces to login; an authenticated account without the module + * enabled bounces to `/insights`. The nav entry is already hidden for a + * disabled account, so this only catches a direct URL hit. Every backing + * `/api/*` route enforces the gate server-side, so this is a UX redirect, + * not the security boundary. + * + * Returns `ready` — `true` only once auth has resolved, the visitor is + * authenticated, AND the module is enabled. While `false` the caller + * should render a calm loader / nothing rather than the (about-to- + * redirect) module surface, so a disabled-module page never half-renders. + * + * Default-on (disabled allowlist): an absent map, a missing key, or any + * non-`false` value reads as enabled — matching the server gate. + */ +export function useModulePageGuard(moduleKey: ModuleKey): { + ready: boolean; +} { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.[moduleKey] !== false; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/insights"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + return { ready: !isLoading && isAuthenticated && enabled }; +} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 4590f0082..c1728d649 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -87,9 +87,12 @@ function moduleMap( import { buildDashboardSnapshot, + WIDGET_MODULE_BY_ID, + SUMMARY_TYPE_MODULE, type SnapshotUserInput, } from "../snapshot"; import type { ModuleKey } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; const emptySummary = { count: 0, @@ -796,6 +799,67 @@ describe("buildDashboardSnapshot — additive proof", () => { }); }); +describe("module gating maps — every toggleable-domain widget is mapped (v1.18.0)", () => { + // The dashboard widget ids that belong to a toggleable module. A new + // toggleable widget MUST be added here AND to `WIDGET_MODULE_BY_ID`, or + // it would leak onto a disabled-module account's dashboard. + const TOGGLEABLE_WIDGET_IDS = [ + "mood", + "sleep", + "glucose", + "achievements", + "recentWorkouts", + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + ] as const; + + it("every toggleable-domain widget id has a module mapping", () => { + for (const id of TOGGLEABLE_WIDGET_IDS) { + expect( + WIDGET_MODULE_BY_ID[id], + `widget "${id}" is missing a WIDGET_MODULE_BY_ID entry`, + ).toBeDefined(); + } + }); + + it("every mapped widget value is a real ModuleKey", () => { + for (const [id, key] of Object.entries(WIDGET_MODULE_BY_ID)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `widget "${id}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("every summary-type mapping value is a real ModuleKey", () => { + for (const [type, key] of Object.entries(SUMMARY_TYPE_MODULE)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `summary type "${type}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("recovery-domain widgets resolve to the recovery module", () => { + expect(WIDGET_MODULE_BY_ID.cardioRecovery).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.sixMinuteWalk).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairAscentSpeed).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairDescentSpeed).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.CARDIO_RECOVERY).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.SIX_MINUTE_WALK_DISTANCE).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_ASCENT_SPEED).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_DESCENT_SPEED).toBe("recovery"); + }); + + it("breathing-disturbance widget + type resolve to the sleep module", () => { + expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep"); + expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep"); + }); +}); + describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { const widget = (snap: { layout: { widgets: { id: string; visible: boolean; tileVisible?: boolean }[] } }, id: string) => snap.layout.widgets.find((w) => w.id === id); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 944177e84..30835ef12 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -91,12 +91,19 @@ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; * (weight / bp / pulse / bodyFat / medications / bpInTarget and the * vital-derived HealthKit metrics) carry NO entry and are never hidden. */ -const WIDGET_MODULE_BY_ID: Partial> = { +export const WIDGET_MODULE_BY_ID: Partial> = { mood: "mood", sleep: "sleep", glucose: "glucose", achievements: "achievements", recentWorkouts: "workouts", + // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery + // module; the per-night breathing-disturbance widget belongs to sleep. + cardioRecovery: "recovery", + sixMinuteWalk: "recovery", + stairAscentSpeed: "recovery", + stairDescentSpeed: "recovery", + breathingDisturbances: "sleep", }; /** @@ -106,9 +113,18 @@ const WIDGET_MODULE_BY_ID: Partial> = { * client data-floor gates also drop it) before the snapshot leaves the * server. Core vital types are absent here and always pass through. */ -const SUMMARY_TYPE_MODULE: Partial> = { +export const SUMMARY_TYPE_MODULE: Partial> = { SLEEP_DURATION: "sleep", BLOOD_GLUCOSE: "glucose", + // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + + // its tiles are the recovery module's surface; when it is off these + // device-native signals must drop from the dashboard snapshot too. + CARDIO_RECOVERY: "recovery", + SIX_MINUTE_WALK_DISTANCE: "recovery", + STAIR_ASCENT_SPEED: "recovery", + STAIR_DESCENT_SPEED: "recovery", + // Per-night breathing-disturbance index is a sleep-page signal. + BREATHING_DISTURBANCES: "sleep", }; const GLUCOSE_CONTEXTS = [