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
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 10:32:59 +02:00
1 parent 168a8ea5c5
commit 813dc2547b
6 files changed
+253 -1

No files matched your search

+55 -1
View File
@@ -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();
});
});
+12
View File
@@ -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<void> {
if (!isExternalDocumentEgress(args.providerType)) return;
if (await documentAutoReadEnabled(args.userId)) return;
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}
+28
View File
@@ -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<boolean> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { documentsAutoAiRead: true },
});
return row?.documentsAutoAiRead ?? false;
}