From 813dc2547b3abd30be897bb04ad32bc38720d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 10:32:59 +0200 Subject: [PATCH] feat(documents): add per-user automatic AI reading opt-in Add the documentsAutoAiRead per-user Boolean (migration 0231, default false) that authorises ambient AI reading of uploaded vault documents. OFF by default keeps the vault local-first for every self-hoster. - schema column + migration 0231_documents_auto_ai_read - documentAutoReadEnabled(userId) read helper - GET/PATCH /api/auth/me/documents-auto-ai-read toggle route; the ON write mints an ai_full consent receipt for the audit trail - the document consent gate short-circuits an external pick when the toggle is ON; a local pick stays ungated and OFF still requires a receipt for any external egress --- .../0231_documents_auto_ai_read/migration.sql | 17 +++ prisma/schema.prisma | 12 ++ .../auth/me/documents-auto-ai-read/route.ts | 129 ++++++++++++++++++ src/lib/ai/__tests__/consent-guard.test.ts | 56 +++++++- src/lib/ai/consent-guard.ts | 12 ++ src/lib/documents/document-settings.ts | 28 ++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0231_documents_auto_ai_read/migration.sql create mode 100644 src/app/api/auth/me/documents-auto-ai-read/route.ts create mode 100644 src/lib/documents/document-settings.ts diff --git a/prisma/migrations/0231_documents_auto_ai_read/migration.sql b/prisma/migrations/0231_documents_auto_ai_read/migration.sql new file mode 100644 index 000000000..5524b0512 --- /dev/null +++ b/prisma/migrations/0231_documents_auto_ai_read/migration.sql @@ -0,0 +1,17 @@ +-- Automatic AI reading of uploaded vault documents (per-user opt-in). +-- +-- One additive, defaulted column on `users`. OFF by default: the document vault +-- stays local-first — a freshly uploaded document is only read by an external AI +-- provider when the user explicitly taps a per-document action (which still +-- requires an active consent receipt). When `true`, the auto-index-on-upload job +-- may read a new document through the user's configured external provider with +-- no per-document tap; flipping the toggle on is itself the standing consent +-- (the write that sets it also mints an `ai_full` consent receipt for the audit +-- trail). +-- +-- The default `false` backfills every existing row onto the current behaviour +-- (local-first, per-document-explicit, consent-required), so this is a no-op for +-- anyone who never opts in. + +ALTER TABLE "users" + ADD COLUMN "documents_auto_ai_read" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdd222bd4..dcbe9c1a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -302,6 +302,18 @@ model User { aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted") + // Automatic AI reading of uploaded vault documents. OFF by default: the vault + // stays local-first — a newly stored document is only read externally when the + // user explicitly taps a per-document AI action (which still needs an active + // consent receipt). When `true`, the auto-index job may read a freshly + // uploaded document through the user's configured external provider with no + // per-document tap: the toggle IS the standing consent (the write that flips + // it on also mints an `ai_full` receipt for the audit trail). Mirrors + // `labsLocalOcrEnabled`; all AI provider config is per-user, so the operator + // flips it for their own account. Default `false` is the entire privacy + // contract for every other self-hoster. + documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read") + // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") // v1.4.25 W14b — last persisted onboarding wizard step. 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 new file mode 100644 index 000000000..4bd8f1a62 --- /dev/null +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -0,0 +1,129 @@ +/** + * Per-user "read documents automatically with AI" opt-in endpoint. + * + * GET /api/auth/me/documents-auto-ai-read — current flag. + * PATCH /api/auth/me/documents-auto-ai-read — body `{ documentsAutoAiRead: boolean }`. + * + * OFF by default: the document vault stays local-first and every external AI + * egress needs an explicit per-document action + an active consent receipt. When + * `true`, the auto-index-on-upload job may read a freshly uploaded document + * 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. 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. + */ +import { z } from "zod"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +const patchBodySchema = z.object({ + documentsAutoAiRead: z.boolean(), +}); + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type DocumentsAutoAiReadResponse = { + documentsAutoAiRead: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { documentsAutoAiRead: true }, + }); + const payload: DocumentsAutoAiReadResponse = { + documentsAutoAiRead: row?.documentsAutoAiRead ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `documents-auto-ai-read:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + // The body is a single boolean — bound the parse so a malformed or oversized + // payload is rejected before it is materialised. + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = patchBodySchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.documentsAutoAiRead; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { documentsAutoAiRead: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { documentsAutoAiRead: next }, + }); + + // 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); + } + + await auditLog("user.documentsAutoAiRead.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.documentsAutoAiRead ?? false, + next, + }, + }); + + annotate({ + action: { name: "auth.me.documentsAutoAiRead.patch" }, + meta: { documentsAutoAiRead: next }, + }); + + const payload: DocumentsAutoAiReadResponse = { documentsAutoAiRead: next }; + return apiSuccess(payload); +}); diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index db055bbfb..cabf7f6f1 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt: vi.fn(), })); +vi.mock("@/lib/documents/document-settings", () => ({ + documentAutoReadEnabled: vi.fn().mockResolvedValue(false), +})); import { ConsentRequiredError, @@ -13,10 +16,12 @@ import { isExternalDocumentEgress, } from "../consent-guard"; import { latestActiveReceipt } from "@/lib/consent/receipts"; +import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import type { ProviderChainResolved } from "../provider-runner"; import type { ConsentKind } from "@/lib/validations/consent"; const mockedLatest = vi.mocked(latestActiveReceipt); +const mockedAutoRead = vi.mocked(documentAutoReadEnabled); /** Minimal chain entry — the gate only inspects `providerType`. */ function entry(providerType: string): ProviderChainResolved { @@ -184,7 +189,12 @@ describe("isExternalDocumentEgress", () => { }); describe("assertDocumentEgressConsent", () => { - beforeEach(() => mockedLatest.mockReset()); + beforeEach(() => { + mockedLatest.mockReset(); + mockedAutoRead.mockReset(); + // Default: the auto-read toggle is OFF (the shipped privacy posture). + mockedAutoRead.mockResolvedValue(false); + }); it("never reads consent and never throws for a LOCAL document pick", async () => { grant([]); @@ -196,6 +206,8 @@ describe("assertDocumentEgressConsent", () => { }), ).resolves.toBeUndefined(); expect(mockedLatest).not.toHaveBeenCalled(); + // A local pick short-circuits before the toggle is even consulted. + expect(mockedAutoRead).not.toHaveBeenCalled(); }); // The live gap the governance fix closes: codex was ungated for documents. @@ -233,4 +245,46 @@ describe("assertDocumentEgressConsent", () => { }), ).resolves.toBeUndefined(); }); + + // Gate A: the documentsAutoAiRead opt-in short-circuits an external pick. + it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => { + grant([]); + mockedAutoRead.mockResolvedValue(true); + for (const p of ["codex", "openai", "anthropic", "admin-openai"]) { + await expect( + assertDocumentEgressConsent({ + userId: "u1", + providerType: p, + surface: "insights", + }), + ).resolves.toBeUndefined(); + } + // The toggle short-circuits BEFORE the receipt read. + expect(mockedLatest).not.toHaveBeenCalled(); + }); + + it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => { + grant([]); + mockedAutoRead.mockResolvedValue(false); + await expect( + assertDocumentEgressConsent({ + userId: "u1", + providerType: "codex", + surface: "insights", + }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + }); + + it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => { + grant([]); + mockedAutoRead.mockResolvedValue(true); + await expect( + assertDocumentEgressConsent({ + userId: "u1", + providerType: "local", + surface: "insights", + }), + ).resolves.toBeUndefined(); + expect(mockedAutoRead).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 494042b32..e6960c3aa 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -28,6 +28,7 @@ * mapped kind OR the superset `ai_full` grant satisfies the gate. */ import { latestActiveReceipt } from "@/lib/consent/receipts"; +import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; @@ -153,6 +154,16 @@ export function isExternalDocumentEgress(providerType: string): boolean { * machine); for any external pick, throw `ConsentRequiredError` unless an active * receipt of the surface's mapped kind (or `ai_full`) is on file. * + * ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is + * itself the standing consent act (the toggle write also mints an `ai_full` + * receipt for the audit trail), so the gate short-circuits an external pick when + * it is ON — that is what removes the per-document friction ("upload and the AI + * just reads it, no 80 switches"). When it is OFF the branch is inert and the + * gate behaves exactly as shipped: no external egress without an explicit + * receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an + * external pick — a `local` pick is already ungated above, so the toggle can + * never widen egress that was not already external. + * * Call this AFTER the document provider is picked and BEFORE the first * `generateCompletion` on it. */ @@ -162,6 +173,7 @@ export async function assertDocumentEgressConsent(args: { surface: ConsentSurface; }): Promise { if (!isExternalDocumentEgress(args.providerType)) return; + if (await documentAutoReadEnabled(args.userId)) return; if (await hasActiveConsentForSurface(args.userId, args.surface)) return; throw new ConsentRequiredError(args.surface); } diff --git a/src/lib/documents/document-settings.ts b/src/lib/documents/document-settings.ts new file mode 100644 index 000000000..22d9b4f8c --- /dev/null +++ b/src/lib/documents/document-settings.ts @@ -0,0 +1,28 @@ +/** + * Per-user document-vault AI settings. + * + * `documentAutoReadEnabled(userId)` reads the `documentsAutoAiRead` opt-in — the + * single switch that authorises ambient (no-per-document-tap) AI reading of + * uploaded documents. OFF by default: the vault stays local-first and every + * external egress needs an explicit per-document action + consent receipt. + * + * Two gates read this flag: + * - the document consent gate (`assertDocumentEgressConsent`) short-circuits an + * external pick when it is ON (the toggle IS the standing consent); + * - the auto-index-on-upload job stays strictly local when it is OFF and uses + * the document-order external pick when it is ON. + */ +import { prisma } from "@/lib/db"; + +/** + * True when the user has opted into automatic AI reading of uploaded documents. + * A missing row (deleted account raced against a job) resolves to `false` — + * fail-closed to the local-first posture, never to external egress. + */ +export async function documentAutoReadEnabled(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { documentsAutoAiRead: true }, + }); + return row?.documentsAutoAiRead ?? false; +}