From 8cc879f6dc6811f97c90fd3c15267300d6519929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:55:03 +0200 Subject: [PATCH 1/5] fix(analytics): gate glucose clinical panel behind the glucose module The default /api/analytics slice served the per-context glucose summaries and the TIR / GMI / eA1C clinical panel unconditionally, leaking a disabled-glucose account's readings to a surface the account has turned off. The route also serves core weight / BP / pulse metrics, so a 403 on the whole payload is wrong. Resolve the glucose module once server-side and null out glucoseByContext + glucoseClinical when it is off; the core metrics keep flowing. Mirrors the mood-pillar gate already in this route. --- src/app/api/analytics/__tests__/route.test.ts | 60 +++++++++++++++++++ src/app/api/analytics/route.ts | 12 +++- 2 files changed, 70 insertions(+), 2 deletions(-) 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, From 6eef3ab21c00a33dd926ec916bbc5ec4efac9a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:57:54 +0200 Subject: [PATCH 2/5] fix(workouts): gate the workout read APIs behind the workouts module GET /api/workouts and GET /api/workouts/{id} returned a disabled- module account's workout list and detail to any valid Bearer or cookie session. Both now require the workouts module and return the 403 module.disabled envelope when it is off. The batch INGEST route (POST /api/workouts/batch) stays ungated by design: synced workouts must keep landing in storage even while the surface is hidden, so re-enabling the module reveals a complete history rather than a gap. --- src/app/api/workouts/[id]/route.ts | 5 ++++ .../__tests__/canonical-dedup.test.ts | 28 +++++++++++++++++++ src/app/api/workouts/__tests__/detail.test.ts | 28 +++++++++++++++++++ src/app/api/workouts/route.ts | 8 ++++++ 4 files changed, 69 insertions(+) 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( From a524cad4c40137dc3fbedf7d016a5ff6a5c81af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:01:47 +0200 Subject: [PATCH 3/5] fix(insights): guard the module sub-pages against direct-URL hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep, blood-glucose, workouts, and recovery insights pages fetched their series and half-rendered when the backing module was disabled — a disabled account that navigated straight to the URL saw the surface flash before any guard. They now resolve a shared useModulePageGuard hook (mirroring the cycle page): an unauthenticated visitor bounces to login, a disabled-module account bounces to /insights, and the page renders a calm loader until the gate clears. The backing /api routes enforce the gate server-side, so this is the UX layer, not the security boundary. --- src/app/insights/blood-glucose/page.tsx | 13 ++- src/app/insights/recovery/page.tsx | 13 +++ src/app/insights/sleep/page.tsx | 14 ++- src/app/insights/workouts/[id]/page.tsx | 13 +++ src/app/insights/workouts/page.tsx | 13 ++- .../__tests__/use-module-page-guard.test.tsx | 86 +++++++++++++++++++ src/hooks/use-module-page-guard.ts | 45 ++++++++++ 7 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 src/hooks/__tests__/use-module-page-guard.test.tsx create mode 100644 src/hooks/use-module-page-guard.ts 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 }; +} From f966ae884a333f7fb488706dc110f7bfbb98f8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:05:37 +0200 Subject: [PATCH 4/5] fix(dashboard): gate the workouts + recovery surfaces by module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's recent-workouts and steps tiles painted regardless of the workouts module state; AND the resolved per-account flag into both gates (fail-open — only an explicit false hides the tile). Extend the snapshot module maps to cover the recovery domain: the cardioRecovery / sixMinuteWalk / stairAscentSpeed / stairDescentSpeed widgets and their HealthKit measurement types now resolve to the recovery module, and the per-night breathing-disturbance widget + type resolve to sleep. A new guard test asserts every toggleable-domain widget id carries a mapping and every mapped value is a real module key, so a future device widget cannot leak onto a disabled account. --- src/app/page.tsx | 10 ++- src/lib/dashboard/__tests__/snapshot.test.ts | 64 ++++++++++++++++++++ src/lib/dashboard/snapshot.ts | 20 +++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 836045263..5808a63a6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -523,7 +523,11 @@ export default function DashboardPage() { const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; const showMoodTile = isTileVisible("mood") && hasMood; const showSleepTile = isTileVisible("sleep") && hasSleep; - const showStepsTile = isTileVisible("steps") && hasSteps; + // v1.18.0 B1 — steps are activity data tied to the workouts module + // surface; AND the resolved module flag (fail-open: only an explicit + // `false` hides it) so a disabled-workouts account drops the tile. + const showStepsTile = + isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; const showVo2Tile = isTileVisible("vo2Max") && hasVo2; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; @@ -559,7 +563,9 @@ export default function DashboardPage() { // no data floor, so it would otherwise flash in then retract for a user // who hid it. const showRecentWorkoutsTile = - layoutResolved && isChartVisible("recentWorkouts"); + layoutResolved && + isChartVisible("recentWorkouts") && + user?.modules?.workouts !== false; // v1.16.0 — shared reveal gate for the chart row. Every data-backed // chart mounts as soon as its visibility gate flips (so the per-chart 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 = [ From 629e5396c6f837948f39d8aa135be4dd8d3a459b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:07:34 +0200 Subject: [PATCH 5/5] test(modules): move gated workout read routes off the exempt inventory The workout list + detail reads now carry requireModuleEnabled, so the route-gate inventory's exempt allowlist must stop claiming them as ungated data-layer surfaces. Only the iOS batch ingest stays exempt. --- .../__tests__/module-route-gate-inventory.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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",