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(