feat(insights): gate every AI-narrative insights route on the insights module

Require the `insights` module (in addition to the existing assistant-surface
gate and any per-metric data-module gate) on all 14 narrative insights
routes: blood-pressure / bmi / pulse / weight / medication-compliance /
mood / metric / derived / derived-batch status, cards, correlations,
narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope
now precedes any provider or cache work when the account has insights off.

The per-metric sleep/glucose/recovery/mood gates and the
requireAssistantSurface("insightStatus"|"correlations") checks are kept;
this adds the module layer on top of them.

Route tests that now resolve the gate mock @/lib/modules/gate
default-enabled so their existing data-shape and assistant-flag assertions
ride through unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:40:38 +02:00
1 parent 69fe67e2de
commit 4cf923647d
22 files changed
+133 -3

No files matched your search

@@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -42,6 +52,7 @@ vi.mock("@/lib/insights/blood-pressure-status", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -58,6 +69,7 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => {
@@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
+3
View File
@@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
@@ -14,6 +14,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// (data shape + assistant-flag gating) ride through; the dedicated
// off → 403 coverage lives in the route-gate inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -37,6 +47,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
@@ -46,6 +57,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+3
View File
@@ -8,6 +8,7 @@
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -57,6 +58,8 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.4.31 — the iOS cards adapter feeds the same per-metric
// insight surfaces the web `<InsightStatusCard>` mounts on each
// /insights/<metric> sub-page. Both share the operator gate.
@@ -11,6 +11,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate
// inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -41,6 +51,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -50,6 +61,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
@@ -20,6 +20,7 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -73,6 +74,9 @@ export const GET = apiHandler(async () => {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Operator can hide the correlation surface entirely.
await requireAssistantSurface("correlations");
@@ -13,6 +13,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()` (in addition to the existing per-metric sleep/recovery
// gate). Mock it default-enabled so the existing assertions ride through;
// the off → 403 coverage lives in the route-gate inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -66,11 +66,19 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
// every module enabled) and the profile-read count isn't perturbed.
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return { ...actual, resolveModuleMap: vi.fn() };
return {
...actual,
resolveModuleMap: vi.fn(),
// v1.18.0 — the route gates on the `insights` module after
// `requireAuth()`. Default-enabled so the existing per-metric
// resolveModuleMap assertions ride through; the off → 403 coverage
// lives in the route-gate inventory test.
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
};
});
import { GET } from "../route";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveModuleMap, requireModuleEnabled } from "@/lib/modules/gate";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
@@ -106,6 +114,7 @@ beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
+3 -1
View File
@@ -39,7 +39,7 @@ import {
} from "@/lib/insights/derived";
import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { resolveModuleMap } from "@/lib/modules/gate";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { DERIVED_MODULE } from "../route";
export const dynamic = "force-dynamic";
@@ -115,6 +115,8 @@ function parseBatchItems(csv: string): {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// Per-user limiter, same posture as the compliance routes: the cold
+3
View File
@@ -71,6 +71,9 @@ const derivedQuerySchema = z.object({
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
@@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
@@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()` (in addition to the existing per-metric sleep/glucose/
// recovery gate). Mock it default-enabled so the existing assertions ride
// through; the off → 403 coverage lives in the route-gate inventory test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -43,6 +53,7 @@ vi.mock("@/lib/insights/metric-status", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
@@ -60,6 +71,7 @@ function makeReq(metric?: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("GET /api/insights/metric-status", () => {
@@ -62,6 +62,8 @@ const metricQuerySchema = z.object({
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = metricQuerySchema.safeParse({
@@ -15,6 +15,9 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Per-domain gate: the mood module must be enabled for this account
// before the mood AI-status surface is served (mirrors the cycle gate).
const gate = await requireModuleEnabled(user.id, "mood");
+3
View File
@@ -18,6 +18,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
@@ -39,6 +40,8 @@ function narrowLocale(locale: string): "de" | "en" {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = narrativeQuerySchema.safeParse({
@@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -42,6 +52,7 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
const SESSION_OK = {
@@ -60,6 +71,7 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("POST /api/insights/pregenerate", () => {
@@ -26,6 +26,7 @@ import { NextRequest } from "next/server";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -38,6 +39,8 @@ const WARM_WINDOW_MS = 3 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Gate on the same surface as the read-only status routes: this warms
// the assessment cards (the `insightStatus` surface), not the Coach.
// A user with assessments enabled but Coach disabled can still warm.
@@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
@@ -18,6 +18,16 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -28,6 +28,7 @@ import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -42,6 +43,8 @@ const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[]
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const rows = await prisma.measurement.findMany({
@@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");