mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user