diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts new file mode 100644 index 000000000..1d0b27b35 --- /dev/null +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +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: () => {}, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + }, + }, +})); + +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 }, +}; + +function req(): NextRequest { + return new NextRequest("http://localhost/api/feature-flags", { + method: "GET", + }); +} + +const FIND = prisma.appSettings.findUnique as ReturnType; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/feature-flags", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns the all-on default shape for a fresh install", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + FIND.mockResolvedValue(null); + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { + assistant: { + enabled: boolean; + coach: boolean; + briefing: boolean; + insightStatus: boolean; + correlations: boolean; + healthScoreExplainer: boolean; + }; + }; + error: null; + }; + + expect(body.error).toBeNull(); + expect(body.data.assistant).toEqual({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, + }); + }); + + it("forces every sub-flag false when the master is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + FIND.mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { assistant: Record }; + }; + + expect(body.data.assistant).toEqual({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }); + }); + + it("reads each sub-flag column", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: false, + }); + + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { assistant: Record }; + }; + + expect(body.data.assistant).toEqual({ + enabled: true, + coach: false, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: false, + }); + }); + + it("attaches a private 60-second Cache-Control header", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + FIND.mockResolvedValue(null); + const res = await GET(req()); + expect(res.headers.get("Cache-Control")).toBe("private, max-age=60"); + }); +}); diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts new file mode 100644 index 000000000..2e8d46d29 --- /dev/null +++ b/src/app/api/feature-flags/route.ts @@ -0,0 +1,54 @@ +/** + * `GET /api/feature-flags` โ€” operator-side assistant flag matrix. + * + * Projects `AppSettings.assistant*Enabled` over HTTP so every client + * (web React tree + iOS native) reads the same authoritative shape. + * + * Response envelope: + * + * { + * "data": { + * "assistant": { + * "enabled": true, + * "coach": true, + * "briefing": true, + * "insightStatus": true, + * "correlations": true, + * "healthScoreExplainer": true + * } + * }, + * "error": null + * } + * + * - `requireAuth()` โ€” any logged-in user. Per-request flag fetches + * from the iOS native client always arrive after auth, so the + * gate matches the rest of the read-only profile surface. + * - Master kills every sub-flag in the resolver before the shape + * leaves the handler, so callers never have to compose + * `master && sub`. + * - `Cache-Control: private, max-age=60` โ€” operator toggles flip + * rarely; a 60-second per-session cache keeps the cost off the + * hot /insights mount path while still propagating an admin + * change within a minute. + * + * Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` ยง3 R5 + * and `.planning/research/v15-assistant-optional.md` Part D. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { annotate } from "@/lib/logging/context"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const GET = apiHandler(async (_request: NextRequest) => { + await requireAuth(); + annotate({ action: { name: "feature-flags.read" } }); + + const assistant = await getAssistantFlags(); + + const response = apiSuccess({ assistant }); + response.headers.set("Cache-Control", "private, max-age=60"); + return response; +});