diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 840c64368..dcef82ea6 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +vi.mock("@/lib/feature-flags", () => ({ + loadAssistantSwitches: vi.fn(async () => ({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + })), +})); + import { POST } from "../route"; import { resolveProviderForTest } from "@/lib/ai/provider"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; beforeEach(() => { vi.resetAllMocks(); @@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => { expect(response.status).toBe(429); expect(generateCompletion).not.toHaveBeenCalled(); + const body = (await response.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("ai.budget.exceeded"); }); it("records a successful probe on the ledger", async () => { @@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => { ); }); }); + +describe("POST /api/ai/test — the operator's master switch and typed refusals", () => { + it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => { + vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + documentAi: false, + }); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(403); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.enabled", + reason: "operator_disabled", + }); + expect(resolveProviderForTest).not.toHaveBeenCalled(); + }); + + it("fails closed, without a 5xx, when the switches cannot be read", async () => { + vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null); + const response = await POST(emptyRequest() as never); + expect(response.status).toBeLessThan(500); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "ai.unavailable", + reason: "check_failed", + }); + expect(resolveProviderForTest).not.toHaveBeenCalled(); + }); + + it("names a missing provider", async () => { + vi.mocked(resolveProviderForTest).mockResolvedValue({ + type: "none", + } as never); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(422); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "ai.provider.none", + reason: "no_provider", + }); + }); +}); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 27265df91..1c6c53cca 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -23,6 +23,23 @@ import { resolveDailyCap, } from "@/lib/ai/coach/budget"; import { aiTestOverrideSchema } from "@/lib/validations/ai-provider"; +import { + AI_PROVIDER_NONE_ERROR_CODE, + AI_UNAVAILABLE_ERROR_CODE, +} from "@/lib/ai/capabilities/types"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; + +/** + * The master switch's refusal code. The connection test sends a fixed prompt + * and no health data, so it belongs to no capability; it answers to the + * operator's master switch alone, under the `assistant.disabled.` + * template with the master's own name (`enabled`) as the last segment. + */ +const MASTER_SWITCH = "enabled"; +const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`; + +/** The daily AI budget refusal's code. */ +const AI_TEST_BUDGET_CODE = "ai.budget.exceeded"; /** * Output ceiling of the probe below. Mirrors the `maxTokens` on the completion @@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "ai.test" } }); + // With the operator's master switch off no AI call leaves this server, and + // the connection test is an AI call. A switch set that could not be read + // fails closed, like every capability does. + // (403, not the usual 503 for `ai.unavailable`: this route never answers + // 5xx, because a proxy rewrites a 5xx into an HTML page the settings form + // cannot parse. See the catch below.) + const switches = await loadAssistantSwitches(); + if (switches === null) { + return apiError("AI is unavailable right now", 403, { + errorCode: AI_UNAVAILABLE_ERROR_CODE, + reason: "check_failed", + }); + } + if (!switches.enabled) { + annotate({ action: { name: "ai.test.operator_disabled" } }); + return apiError("AI is turned off on this server", 403, { + errorCode: AI_TEST_MASTER_DISABLED_CODE, + reason: "operator_disabled", + }); + } + const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000); if (!rl.allowed) return apiError("Too many test requests", 429); @@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => { } if (provider.type === "none") { - return apiError("No AI provider configured", 422); + return apiError("No AI provider configured", 422, { + errorCode: AI_PROVIDER_NONE_ERROR_CODE, + reason: "no_provider", + }); } // Meter the probe on the same daily ledger every other AI surface writes to, @@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ); if (!reservation.allowed) { annotate({ action: { name: "ai.test.budget_exceeded" } }); - return apiError("Daily AI budget exhausted", 429); + return apiError("Daily AI budget exhausted", 429, { + errorCode: AI_TEST_BUDGET_CODE, + }); } try { diff --git a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts index 0fa555dd3..656fb617f 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts @@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; */ vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + consentReceipt: { findFirst: vi.fn(), create: vi.fn() }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/consent/web-grant", () => ({ - ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: vi.fn(), })); vi.mock("@/lib/jobs/document-summary-catchup", () => ({ enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }), @@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({ import { PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; const SESSION_OK = { @@ -74,6 +77,13 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never); + vi.mocked(getAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => { @@ -103,23 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () await PATCH(mkPatch(false)); expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); - expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); }); - it("still mints the consent receipt on the flip that schedules the pass", async () => { - // The catch-up rides the same act of consent, never around it. The mint - // is marked `affirmative` because switching the toggle on IS the consent - // act: unlike the settings-mount heal, it may lift an earlier - // withdrawal, which is the user deciding again rather than the page - // deciding for them. + it("mints the extraction receipt on the flip that schedules the pass", async () => { + // The catch-up rides the same act of consent, never around it. The toggle + // grants document reading only: `ai_extraction`, not the master `ai_full` + // that would also open the Coach and the AI analysis. withPrevious(false); await PATCH(mkPatch(true)); - expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith( - "user-1", - "affirmative", + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0] + .data as { userId: string; kind: string }; + expect(data.userId).toBe("user-1"); + expect(data.kind).toBe("ai_extraction"); + // The capability is read after the mint, so the fresh receipt counts. + const mintOrder = vi.mocked(prisma.consentReceipt.create).mock + .invocationCallOrder[0]!; + const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!; + expect(mintOrder).toBeLessThan(readOrder); + }); + + it("does not mint a second receipt when one that covers document reads is active", async () => { + withPrevious(false); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ + id: "r1", + } as never); + + await PATCH(mkPatch(true)); + + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + revokedAt: null, + kind: { in: ["ai_extraction", "ai_full"] }, + }), + }), ); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + + it("treats a concurrent mint as success", async () => { + withPrevious(false); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue({ + code: "P2002", + }); + + const res = await PATCH(mkPatch(true)); + + expect(res.status).toBe(200); + }); + + it("saves the preference but schedules no catch-up while document reading is closed", async () => { + // The operator turned document reading off: every summary the pass would + // enqueue could only be refused, so it is not enqueued at all. + withPrevious(false); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + + const res = await PATCH(mkPatch(true)); + + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalled(); + expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); }); it("persists the flag field-by-field", async () => { diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 8fef3da24..c5e175fea 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -10,12 +10,17 @@ * through the user's configured external provider with no per-document tap. * * Flipping it ON is itself the standing consent act, so the write also mints an - * append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the - * durable audit record that sits alongside the runtime short-circuit the - * document consent gate reads. An OFF→ON flip additionally schedules a bounded - * catch-up over the documents already stored (`enqueueSummaryCatchUp`), because - * the summary job is enqueued at upload time and would otherwise never revisit - * a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate + * append-only `ai_extraction` consent receipt — the durable record the document + * capability reads. It covers reading documents, lab reports and medication + * text and nothing else: the Coach and the AI analysis keep asking for their + * own consent. (Before this release the toggle minted `ai_full`, which also + * opened those; receipts minted that way stay valid, because nothing tells + * them apart from a deliberate master grant.) An OFF→ON flip additionally + * schedules a bounded catch-up over the documents already stored + * (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time + * and would otherwise never revisit a vault filled before the opt-in; it is + * skipped when the `documentAi` capability is closed for the record, since + * every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate * limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). Idempotent — always returns the * resolved next state so the client can hard-set the optimistic update. @@ -30,8 +35,10 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types"; import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs"; @@ -45,6 +52,55 @@ type DocumentsAutoAiReadResponse = { documentsAutoAiRead: boolean; }; +/** + * The receipt kind the toggle mints: the extraction grant, narrower than the + * master `ai_full`. The capability table lists it (with `ai_full`) as what + * satisfies the document consent rule. + */ +const AUTO_READ_CONSENT_KIND = "ai_extraction"; + +/** + * Mint the extraction receipt unless one that satisfies document reads is + * already active. The toggle is an affirmative act, so an earlier revocation + * does not block it, exactly as a first grant would not be blocked. The + * partial unique index on active receipts is the backstop for two concurrent + * flips; losing that race means the other flip already minted, which is the + * outcome wanted. Returns whether a receipt was written. + */ +async function ensureExtractionReceipt( + userId: string, + now: Date = new Date(), +): Promise { + const active = await prisma.consentReceipt.findFirst({ + where: { + userId, + revokedAt: null, + kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] }, + }, + select: { id: true }, + }); + if (active) return false; + try { + await prisma.consentReceipt.create({ + data: { + userId, + kind: AUTO_READ_CONSENT_KIND, + artefact: JSON.stringify({ + source: "web", + kind: AUTO_READ_CONSENT_KIND, + grantedAt: now.toISOString(), + note: "Documents read automatically with AI, turned on in the app.", + }), + signedAt: now, + }, + }); + return true; + } catch (err) { + if (isP2002(err)) return false; + throw err; + } +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } }); @@ -103,11 +159,9 @@ export const PATCH = apiHandler(async (req: Request) => { }); // Turning the toggle ON is the standing consent act — mint an append-only - // `ai_full` receipt (idempotent) so the durable audit trail records it, in - // addition to the runtime short-circuit the document consent gate reads. - if (next) { - await ensureWebAiConsentReceipt(user.id, "affirmative"); - } + // extraction receipt (idempotent) so the durable audit trail records it and + // the document capability can read it. + const consentMinted = next ? await ensureExtractionReceipt(user.id) : false; // A genuine OFF→ON flip schedules a catch-up over the documents already in // the vault. Without it the switch only ever applied to FUTURE uploads: the @@ -117,7 +171,16 @@ export const PATCH = apiHandler(async (req: Request) => { // budget gate still runs per document inside the summary job itself. const wasEnabled = previous?.documentsAutoAiRead ?? false; if (next && !wasEnabled) { - void enqueueSummaryCatchUp(user.id); + // Read after the mint, so a receipt this request just wrote counts. + const documentAi = await getAiCapability("documentAi"); + if (documentAi.available) { + void enqueueSummaryCatchUp(user.id); + } else { + annotate({ + action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" }, + meta: { reason: documentAi.reason }, + }); + } } await auditLog("user.documentsAutoAiRead.update", { @@ -126,6 +189,7 @@ export const PATCH = apiHandler(async (req: Request) => { details: { previous: previous?.documentsAutoAiRead ?? false, next, + consentMinted, }, }); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 3e4462c4d..daef007e4 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -38,7 +38,12 @@ */ export const ERROR_CODE_CATALOGUE: Readonly> = { - ai: ["ai.provider.none", "ai.record.notPermitted", "ai.unavailable"], + ai: [ + "ai.budget.exceeded", + "ai.provider.none", + "ai.record.notPermitted", + "ai.unavailable", + ], ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], allergy: ["allergy.invalid"], analytics: ["analytics.invalid_query"], @@ -62,6 +67,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], backup: ["backup.payload.undecryptable", "backup.section.missing"], coach: [ + "coach.budget.exceeded", "coach.conversation.invalidTitle", "coach.conversation.notFound", "coach.fenced.attachmentConflict",