mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(ai): narrow the auto-read consent and refuse the connection test with AI off
Turning document auto-read on now mints an ai_extraction receipt instead of ai_full, so it covers reading documents, lab reports and medication text and no longer opens the Coach or the AI analysis. Receipts minted as ai_full by earlier releases stay valid. The catch-up over stored documents is only scheduled while documentAi is open for the record. The AI connection test is an AI call, so it is refused with assistant.disabled.enabled while the operator's master switch is off, and fails closed with ai.unavailable when the switches cannot be read (403, since the route never answers 5xx). A missing provider carries ai.provider.none and a spent daily budget ai.budget.exceeded.
This commit is contained in:
1 parent
bd8ea9d86b
commit
d239d31311
5 files changed
+263
-30
No files matched your search
@@ -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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.<switch>`
|
||||
* 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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
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<Record<string, readonly string[]>> =
|
||||
],
|
||||
backup: ["backup.payload.undecryptable", "backup.section.missing"],
|
||||
coach: [
|
||||
"coach.budget.exceeded",
|
||||
"coach.conversation.invalidTitle",
|
||||
"coach.conversation.notFound",
|
||||
"coach.fenced.attachmentConflict",
|
||||
|
||||
Reference in new issue
Block a user