feat(api): gate assistant endpoints on the feature-flag matrix

Every LLM-driven endpoint now reads the operator flag set near the
top of the handler and throws `AssistantDisabledError` when the
relevant surface is off. The api-handler catches the typed error
and returns the 403 envelope:

  { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }

Gates per the v1.4.31 brief:

  /api/insights/chat            → coach            (POST + GET rail)
  /api/insights/generate        → coach
  /api/insights/comprehensive   → coach
  /api/insights/cards           → insightStatus
  /api/insights/correlations    → correlations
  /api/insights/{weight,bmi,blood-pressure,pulse,mood,
                 medication-compliance}-status → insightStatus

Master `assistantEnabled = false` forces every sub-flag false in
the resolver, so flipping the master 403s every endpoint at once.

Older iOS clients see a generic 403 (their existing 403-handler
covers the case); v1.4.31+ clients read the `errorCode` to render
the `<AssistantDisabledNotice>` empty state. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 13:46:17 +02:00
1 parent 61c9a1e792
commit aeff47afde
16 files changed
+239 -2

No files matched your search

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the heavy generator so the gate test stays isolated from the
// real status pipeline.
vi.mock("@/lib/insights/blood-pressure-status", () => ({
generateBloodPressureStatusForUser: vi.fn(async () => ({
available: true,
locale: "en",
text: "ok",
})),
resolveBloodPressureStatusLocale: () => "en",
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
"http://localhost/api/insights/blood-pressure-status",
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => {
it("returns 200 when insightStatus is enabled (default)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
});
it("returns 403 + errorCode when insightStatus is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
});
});
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
+2
View File
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -7,6 +7,10 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// v1.4.31 — gated on `assistant.insightStatus`; null row falls
// back to the all-on default so existing assertions ride
// through unchanged.
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
@@ -52,6 +56,7 @@ beforeEach(() => {
dateOfBirth: null,
aiProvider: null,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -59,6 +64,38 @@ function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/cards");
}
describe("GET /api/insights/cards — assistant-flag gate", () => {
it("returns 403 + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
it("returns 403 when the master flag is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
});
});
describe("GET /api/insights/cards", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
+5
View File
@@ -7,6 +7,7 @@
* provider).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -56,6 +57,10 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// 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.
await requireAssistantSurface("insightStatus");
annotate({ action: { name: "insights.cards" } });
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
+9
View File
@@ -32,6 +32,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import {
@@ -127,6 +128,10 @@ function buildHistoryWindow(turns: CoachTurn[]): CoachTurn[] {
async function handleChatRequest(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// v1.4.31 — operator can disable the Coach surface app-wide.
// Throws AssistantDisabledError → apiHandler returns 403 +
// `errorCode: "assistant.disabled.coach"` per the iOS contract.
await requireAssistantSurface("coach");
const userId = auth.user.id;
let body: unknown;
@@ -501,6 +506,10 @@ export const POST = apiHandler(handleChatRequest);
*/
export const GET = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
// v1.4.31 — same gate as the SSE POST. Hiding the rail when
// the operator has disabled Coach matches the FAB suppression
// on the client.
await requireAssistantSurface("coach");
const url = new URL(request.url);
const cursor = url.searchParams.get("cursor");
const limitRaw = url.searchParams.get("limit");
@@ -20,11 +20,15 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.31 — comprehensive feeds the hero strip narration and the
// recommendations grid that share the Coach gate.
await requireAssistantSurface("coach");
const userId = user.id;
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { auditLog: { create: vi.fn() } },
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -29,6 +32,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -37,6 +41,12 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.31 — default to an all-on flag set so existing assertions
// ride through unchanged. Tests that need the gate-off path set
// their own findUnique resolution.
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -67,4 +77,38 @@ describe("GET /api/insights/correlations", () => {
expect.objectContaining({ userId: "user-1" }),
);
});
it("returns 403 + errorCode when the correlations flag is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: false,
assistantHealthScoreExplainerEnabled: true,
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.correlations");
});
it("returns 403 when the master flag is off (sub-flag forced)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
});
});
@@ -10,11 +10,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.31 — operator can hide the correlation narration tile.
await requireAssistantSurface("correlations");
annotate({ action: { name: "insights.correlations" } });
await auditLog("insights.correlations.empty", {
@@ -23,6 +23,14 @@ vi.mock("@/lib/api-handler", () => ({
})),
}));
// v1.4.31 — `requireAssistantSurface()` is gated near the top of
// the handler. The test mocks the apiHandler wrapper itself out, so
// flag reads need a deterministic mock at the module boundary.
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
+6
View File
@@ -27,6 +27,7 @@ import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -174,6 +175,11 @@ async function buildComparisonSnapshotForUser(
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.4.31 — the advisor + daily briefing surfaces ride this
// endpoint. Operator disables Coach, every advisor consumer
// (briefing card, recommendations grid, hero strip narration)
// empties out.
await requireAssistantSurface("coach");
const userId = user.id;
const dbUser = await prisma.user.findUnique({
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -7,11 +7,13 @@ import {
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
+19 -1
View File
@@ -9,6 +9,7 @@ import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
import { auditLog } from "./auth/audit";
import { AssistantDisabledError } from "./feature-flags";
/**
* Custom error class for HTTP errors with status codes.
@@ -162,7 +163,24 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
try {
response = await handler(...args);
} catch (error) {
if (error instanceof HttpError) {
if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients render the
// `<AssistantDisabledNotice>` empty state.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof HttpError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },