mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
168a8ea5c5
commit
813dc2547b
6 files changed
+253
-1
No files matched your search
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user