Merge release/v1.39.0 into feat/ai-routes-insights-coach

Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
commit c509afeb2e
256 files changed
+13048 -4642

No files matched your search

@@ -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,11 @@ 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 { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts";
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 +53,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_CONSENT_KIND;
/**
* 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 +160,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 +172,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 +190,7 @@ export const PATCH = apiHandler(async (req: Request) => {
details: {
previous: previous?.documentsAutoAiRead ?? false,
next,
consentMinted,
},
});
@@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock — api-handler imports `AssistantDisabledError` from here.
// Partial mock: the rest of the module stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return {
+10 -1
View File
@@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -272,7 +276,12 @@ export const GET = apiHandler(async () => {
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
timezone: user.timezone,
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively: