From f7ad2339ae47c33797f5e7abe9cf23745681346a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:46:44 +0200 Subject: [PATCH 1/7] feat(documents): document-scoped provider order and egress consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a document-class provider resolution that demotes external/train-by-default providers below local (local -> BYOK no-train API -> operator key -> codex last) and a pick-based document-egress consent gate that requires an active receipt for any external provider. Coach/insights keep the app-wide cost-first order. Co-Authored-By: Marc-André Bombeck --- src/lib/ai/consent-guard.ts | 47 +++++++ src/lib/documents/provider-order.ts | 183 ++++++++++++++++++++++++++++ src/lib/labs/ocr-capability.ts | 48 ++++++-- 3 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 src/lib/documents/provider-order.ts diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 83f39c294..494042b32 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -118,3 +118,50 @@ export async function assertConsentForChain(args: { if (await hasActiveConsentForSurface(args.userId, args.surface)) return; throw new ConsentRequiredError(args.surface); } + +/** + * The DOCUMENT-class consent gate (governance fix, oauth-investigation + * SYNTHESIS §2). + * + * The self-snapshot gate above leaves BYOK / codex / local UNGATED on the + * theory that "configuring the provider was the consent act." That theory holds + * for a metrics snapshot the account owner sends about themselves. It is too + * thin for an uploaded medical DOCUMENT: sending a scanned discharge letter to + * ANY third-party AI is the egress data-protection law cares about, and the + * codex (ChatGPT-subscription) backend trains on consumer content by default. + * + * So for the document surfaces the gate is stricter and PICK-based: the vault + * AI routes call the single resolved provider directly (no runner cascade), so + * the exact egress is the picked provider. A `local` pick never leaves the + * machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic, + * the operator's admin key) requires an active document-class consent receipt. + */ +const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); + +/** + * True when reading a document through this provider egresses it OFF the machine + * to a third-party AI service. Only the self-hosted `local` provider keeps the + * document on the operator's own infrastructure. + */ +export function isExternalDocumentEgress(providerType: string): boolean { + return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType); +} + +/** + * Enforce the document-class consent precondition for the provider that will + * actually receive the document. No-op for a `local` pick (nothing leaves the + * machine); for any external pick, throw `ConsentRequiredError` unless an active + * receipt of the surface's mapped kind (or `ai_full`) is on file. + * + * Call this AFTER the document provider is picked and BEFORE the first + * `generateCompletion` on it. + */ +export async function assertDocumentEgressConsent(args: { + userId: string; + providerType: string; + surface: ConsentSurface; +}): Promise { + if (!isExternalDocumentEgress(args.providerType)) return; + if (await hasActiveConsentForSurface(args.userId, args.surface)) return; + throw new ConsentRequiredError(args.surface); +} diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts new file mode 100644 index 000000000..6299d4147 --- /dev/null +++ b/src/lib/documents/provider-order.ts @@ -0,0 +1,183 @@ +/** + * Document-class AI provider resolution (governance fix, oauth-investigation + * SYNTHESIS §1). + * + * A three-audit investigation found that for uploaded medical DOCUMENTS the + * app-wide provider chain is the wrong default: `codex` (the + * ChatGPT-subscription OAuth backend) sits at chain priority 1, ahead of BYOK + * and local, and OpenAI's consumer policy allows training on that content by + * default. HealthLog is privacy-first, so a scanned discharge letter must NOT + * default to the train-by-default backend. + * + * The fix is scoped to the DOCUMENT class only — Coach / insights keep the + * cost-first app-wide order untouched. This module reprioritises the resolved + * provider chain for the vault's AI surfaces (suggest / summary / extract / + * index / reindex / backfill): + * + * local (no egress) → BYOK no-train API (openai / anthropic) → operator's + * admin key → codex (ChatGPT-subscription OAuth) LAST, as an explicit, + * consented opt-in. + * + * The reorder only decides WHICH configured provider is preferred; it never + * invents a provider. Egress consent (`assertDocumentEgressConsent`) and the + * per-egress vendor-blind UI notice sit on top of this order — the reorder + * keeps codex from being the silent default, the consent gate keeps any + * external egress from happening without an active receipt. + */ +import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { isExternalDocumentEgress } from "@/lib/ai/consent-guard"; +import { + resolveTextProvider, + resolveVisionProvider, + type ChainReorder, + type VisionProviderPick, +} from "@/lib/labs/ocr-capability"; + +/** + * Preference rank for a provider when the payload is a DOCUMENT. Lower wins. + * Local keeps the document on the machine (rank 0); BYOK no-train API keys are + * next (rank 1); the operator's shared no-train key follows (rank 2); the + * ChatGPT-subscription OAuth path (`codex`) is LAST (rank 3) because it trains + * on consumer content by default and cannot be verified opted-out from here. + */ +function documentProviderRank(providerType: string): number { + switch (providerType) { + case "local": + return 0; + case "openai": + case "anthropic": + case "admin-key": + return 1; + case "admin-openai": + return 2; + case "codex": + return 3; + default: + return 2; + } +} + +/** + * Reorder a resolved chain for the document class: stable sort by + * `documentProviderRank`, so within a rank tier the user's own chain order is + * preserved (a stable sort keeps insertion order on ties). Pure — returns a new + * array, never mutates the input. + */ +export const reorderChainForDocumentClass: ChainReorder = (chain) => { + return [...chain].sort( + (a, b) => + documentProviderRank(a.providerType) - + documentProviderRank(b.providerType), + ); +}; + +/** + * Resolve the vision provider for a DOCUMENT read — local-first, codex last. + * Same shape as `resolveVisionProvider`; the returned `chain` is already in + * document order and the `pick` is the first vision-capable entry in it. + */ +export function resolveDocumentVisionProvider( + userId: string, +): Promise { + return resolveVisionProvider(userId, { + reorder: reorderChainForDocumentClass, + }); +} + +/** + * Resolve the text-mode (browser-OCR) structuring provider for a DOCUMENT — + * local-first, codex last. The `pick` is the first entry of the reordered + * chain. + */ +export function resolveDocumentTextProvider(userId: string) { + return resolveTextProvider(userId, { + reorder: reorderChainForDocumentClass, + }); +} + +/** Where a document read egresses, vendor-blind. */ +export type DocumentEgressClass = "local" | "external"; + +/** + * Classify a picked provider's egress for the per-egress UI notice. Vendor-blind + * by design — "local" (stays on the machine) vs "external" (a third-party AI + * service); the copy never names a vendor. + */ +export function documentEgressClass(providerType: string): DocumentEgressClass { + return isExternalDocumentEgress(providerType) ? "external" : "local"; +} + +/** + * The document-scoped capability probe. Mirrors `resolveOcrCapability` but over + * the document provider order, and adds the `egress` class so the vault UI can + * show the "this leaves your machine to a third-party AI" notice BEFORE a read. + */ +export interface DocumentAiCapabilityDto { + available: boolean; + mode: "vision" | "text" | null; + reason: "no-provider" | "enable-local-ocr" | null; + pdfSupported: boolean; + /** + * Where a document read will egress with the current provider order: + * - "local": stays on the operator's machine (self-hosted model). + * - "external": leaves the machine to a third-party AI service. + * - null: no read is available (see `reason`). + */ + egress: DocumentEgressClass | null; +} + +/** + * Resolve the document AI capability for the vault UI. The `mode` / + * `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first), + * so the affordance the UI offers matches exactly what the document routes do. + */ +export async function resolveDocumentAiCapability( + userId: string, +): Promise { + const { chain, pick, localOcrEnabled } = + await resolveDocumentVisionProvider(userId); + + // A vision-capable provider is available — the read runs directly over the + // stored original. Egress follows the picked provider. + if (pick) { + return { + available: true, + mode: "vision", + reason: null, + pdfSupported: pick.pdfSupported, + egress: documentEgressClass(pick.providerType), + }; + } + + // Nothing configured at all — no read, regardless of the local-OCR toggle. + if (chain.length === 0) { + return { + available: false, + mode: null, + reason: "no-provider", + pdfSupported: false, + egress: null, + }; + } + + // A text-only provider is configured. Local OCR runs in the browser and only + // the extracted text is structured by the first provider in document order. + if (localOcrEnabled) { + return { + available: true, + mode: "text", + reason: null, + pdfSupported: false, + egress: documentEgressClass(chain[0]!.providerType), + }; + } + + // A text-only provider is configured but local OCR is not enabled. + return { + available: false, + mode: null, + reason: "enable-local-ocr", + pdfSupported: false, + egress: null, + }; +} diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index 5991932c2..356290267 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -39,6 +39,21 @@ function modelForEntry( return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel; } +/** + * A transform applied to the resolved provider chain before the vision/text + * pick is made. Identity by default (labs behaviour unchanged); the document + * class passes a reorder that demotes external/train-by-default providers below + * local (see `@/lib/documents/provider-order`). + */ +export type ChainReorder = ( + chain: ProviderChainResolved[], +) => ProviderChainResolved[]; + +export interface ResolveProviderOptions { + /** Reorder the resolved chain before picking. Defaults to identity. */ + reorder?: ChainReorder; +} + /** A vision-capable provider pick: its instance, logical tag, and model. */ export interface VisionProviderPick { /** The full resolved chain (for `assertConsentForChain`). */ @@ -61,6 +76,7 @@ export interface VisionProviderPick { */ export async function resolveVisionProvider( userId: string, + options?: ResolveProviderOptions, ): Promise { const userRow = await prisma.user.findUnique({ where: { id: userId }, @@ -84,16 +100,22 @@ export async function resolveVisionProvider( adminModel: settings?.adminAiModel ?? null, }; - const chain = ocr.chain; - if (chain.length === 0) { + const resolvedChain = ocr.chain; + if (resolvedChain.length === 0) { // Mirror the Coach: fall back to the legacy single-provider resolution and // tag it as the admin-managed entry the consent gate recognises. const legacy = await resolveProvider(userId); if (legacy.type !== "none") { - chain.push({ providerType: "admin-openai", instance: legacy }); + resolvedChain.push({ providerType: "admin-openai", instance: legacy }); } } + // Apply the caller's reorder (document class demotes external providers below + // local) before picking; labs passes no reorder → identity. + const chain = options?.reorder + ? options.reorder(resolvedChain) + : resolvedChain; + for (const entry of chain) { const providerType = entry.providerType as VisionProviderType; const model = modelForEntry(entry.providerType, ctx); @@ -119,19 +141,25 @@ export async function resolveVisionProvider( * so this returns the whole chain plus the first entry to drive the structuring * call. Returns `null` when nothing is configured. */ -export async function resolveTextProvider(userId: string): Promise<{ +export async function resolveTextProvider( + userId: string, + options?: ResolveProviderOptions, +): Promise<{ chain: ProviderChainResolved[]; pick: { entry: ProviderChainResolved; providerType: string } | null; }> { // v1.22 (#90) — use the dedicated document-scan provider when enabled, else // the main chain (the text-mode structuring pass needs no vision). - const chain = (await resolveOcrProviderChain(userId)).chain; - if (chain.length === 0) { + const resolvedChain = (await resolveOcrProviderChain(userId)).chain; + if (resolvedChain.length === 0) { const legacy = await resolveProvider(userId); if (legacy.type !== "none") { - chain.push({ providerType: "admin-openai", instance: legacy }); + resolvedChain.push({ providerType: "admin-openai", instance: legacy }); } } + const chain = options?.reorder + ? options.reorder(resolvedChain) + : resolvedChain; const entry = chain[0]; return { chain, @@ -147,8 +175,12 @@ export async function resolveTextProvider(userId: string): Promise<{ */ export async function resolveOcrCapability( userId: string, + options?: ResolveProviderOptions, ): Promise { - const { chain, pick, localOcrEnabled } = await resolveVisionProvider(userId); + const { chain, pick, localOcrEnabled } = await resolveVisionProvider( + userId, + options, + ); // Prefer the native vision path whenever it is available — it is more // accurate and the client does not download the OCR WASM. From 8e5f5f04ae0510d9aafb3bac66cda08ece9e9c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:49:30 +0200 Subject: [PATCH 2/7] feat(documents): route vault AI surfaces through document provider order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint suggest/summary/extract/index/reindex routes and the content-index backfill job to the document-scoped resolvers (local-first, codex last) and the pick-based document-egress consent gate. Labs OCR and Coach/insights untouched. Co-Authored-By: Marc-André Bombeck --- .../documents/inbound/[id]/extract/route.ts | 24 ++++++++++++------- .../api/documents/inbound/[id]/index/route.ts | 17 ++++++++----- .../documents/inbound/[id]/suggest/route.ts | 24 ++++++++++++------- .../documents/inbound/[id]/summary/route.ts | 24 ++++++++++++------- .../api/documents/inbound/reindex/route.ts | 12 ++++++---- .../jobs/document-content-index-backfill.ts | 19 +++++++++------ 6 files changed, 79 insertions(+), 41 deletions(-) diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 765f556c7..2b54f6785 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -31,7 +31,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -52,9 +52,9 @@ import { serialiseDocumentDetail, } from "@/lib/documents/store"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -200,14 +200,18 @@ async function handleTextExtract( }); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `documents-inbound:${userId}`, @@ -298,14 +302,18 @@ async function handleVisionExtract( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `documents-inbound:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7faad4a29..0d318a235 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -14,8 +14,9 @@ * Decision (maintainer, 2026-07-07): content indexing is gated on the EXISTING * AI consent / provider gate — there is NO separate `documentsContentIndexEnabled` * toggle (the plan's P2-D8 opt-in was refused to avoid toggle sprawl). The vision - * path runs `assertConsentForChain`; the text path rides the local-OCR opt-in the - * lab / extract text mode already uses. + * path runs `assertDocumentEgressConsent` (any external provider needs an active + * receipt; a local pick stays ungated); the text path rides the local-OCR opt-in + * the lab / extract text mode already uses — no provider egress at all. * * Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4). */ @@ -29,7 +30,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -51,7 +52,7 @@ import { DocumentDescribeError, transcribeDocument, } from "@/lib/documents/describe"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -164,13 +165,17 @@ async function handleVisionIndex( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 354b69701..e0670d3f7 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -22,7 +22,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -45,9 +45,9 @@ import { type LoadedDocument, } from "@/lib/documents/ai-route-support"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -133,14 +133,18 @@ async function handleTextSuggest( }); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, @@ -209,14 +213,18 @@ async function handleVisionSuggest( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f8acd0d80..6d09219cb 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -23,7 +23,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -47,9 +47,9 @@ import { type DescribeInput, } from "@/lib/documents/describe"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -188,13 +188,17 @@ async function handleTextSummary( return finishSummary(request, userId, document.id, "text", mode, result); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const dateKey = buildDateKey(); const reservation = await reserveBudget( @@ -235,13 +239,17 @@ async function handleVisionSummary( document: LoadedDocument, mode: DocumentSummaryMode, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index 0b396e09c..c8f97cf9b 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -9,10 +9,10 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { auditLog } from "@/lib/auth/audit"; import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -46,13 +46,17 @@ export const POST = apiHandler(async (request) => { // Fail fast when the precondition is not met so the UI gets immediate // feedback rather than a silently no-op'd job. - const { chain, pick } = await resolveVisionProvider(user.id); + const { pick } = await resolveDocumentVisionProvider(user.id); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId: user.id, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId: user.id, + providerType: pick.providerType, + surface: "insights", + }); const { enqueued } = await enqueueContentIndexBackfill(user.id); diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 6bba8b7e3..a9e78f413 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -8,9 +8,10 @@ * indexing egresses to a provider under the user's key + budget, so it must be * a deliberate, consented act, exactly like the per-document index route. * - * Consent: gated on the EXISTING AI consent (`assertConsentForChain`), the same - * gate the extract / index routes use — there is no separate per-user toggle - * (maintainer decision, 2026-07-07). + * Consent: gated on the document-egress consent (`assertDocumentEgressConsent`), + * the same gate the extract / index routes use — any external provider needs an + * active receipt; a local pick stays ungated. There is no separate per-user + * toggle (maintainer decision, 2026-07-07). * * Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set, * capped at `MAX_DOCS_PER_RUN` provider calls per job and stopped the moment the @@ -24,7 +25,7 @@ */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { - assertConsentForChain, + assertDocumentEgressConsent, ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { @@ -41,7 +42,7 @@ import { import { upsertContentIndex } from "@/lib/documents/content-index"; import { transcribeDocument } from "@/lib/documents/describe"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill"; @@ -76,10 +77,14 @@ export interface ContentIndexBackfillSummary { export async function runContentIndexBackfillForUser( userId: string, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) return { indexed: 0, reason: "no-provider" }; try { - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); } catch (err) { if (err instanceof ConsentRequiredError) { return { indexed: 0, reason: "no-consent" }; From c1e11e284a7c00a2858e2d7f519d7d75685296b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:50 +0200 Subject: [PATCH 3/7] test(documents): update vault AI route mocks for document provider order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point route + backfill test mocks at the document-scoped resolvers and the document-egress consent gate. Co-Authored-By: Marc-André Bombeck --- .../[id]/extract/__tests__/route.test.ts | 13 ++++++------ .../[id]/index/__tests__/route.test.ts | 15 +++++++------- .../[id]/suggest/__tests__/route.test.ts | 15 +++++++------- .../[id]/summary/__tests__/route.test.ts | 15 +++++++------- .../document-content-index-backfill.test.ts | 20 +++++++++---------- 5 files changed, 41 insertions(+), 37 deletions(-) diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index 1dde37d5d..b3a264742 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -28,12 +28,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "{}"), })); -vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveVisionProvider: vi.fn(), - resolveTextProvider: vi.fn(), +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), + resolveDocumentTextProvider: vi.fn(), })); vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-27"), @@ -76,7 +77,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -109,7 +110,7 @@ beforeEach(() => { describe("POST /api/documents/inbound/[id]/extract", () => { it("422s without a vision provider and leaves the stored row intact", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, } as never); diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts index 75246fefe..c132d5d6b 100644 --- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -29,11 +29,12 @@ vi.mock("@/lib/documents/describe", () => ({ vi.mock("@/lib/documents/content-index", () => ({ upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }), })); -vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveVisionProvider: vi.fn(), +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), })); vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), @@ -68,7 +69,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { transcribeDocument } from "@/lib/documents/describe"; import { upsertContentIndex } from "@/lib/documents/content-index"; @@ -109,7 +110,7 @@ beforeEach(() => { describe("POST /api/documents/inbound/[id]/index", () => { it("vision: transcribes then upserts the index, never facts", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "anthropic", instance: {} }], pick: { entry: { providerType: "anthropic", instance: {} }, @@ -137,7 +138,7 @@ describe("POST /api/documents/inbound/[id]/index", () => { }); it("vision: 422 without a provider", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, } as never); @@ -160,7 +161,7 @@ describe("POST /api/documents/inbound/[id]/index", () => { expect.objectContaining({ source: "text-ocr", providerType: null }), ); // No provider was resolved on the text path. - expect(resolveVisionProvider).not.toHaveBeenCalled(); + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); }); it("text: 422 when local OCR is not enabled", async () => { diff --git a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts index 8c506d40b..3db7bbd6a 100644 --- a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts @@ -27,12 +27,13 @@ vi.mock("@/lib/documents/assist", () => ({ runDocumentAssist: vi.fn(), DocumentAssistError: class DocumentAssistError extends Error {}, })); -vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveVisionProvider: vi.fn(), - resolveTextProvider: vi.fn(), +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), + resolveDocumentTextProvider: vi.fn(), })); vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), @@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { runDocumentAssist } from "@/lib/documents/assist"; const SESSION_OK = { @@ -98,7 +99,7 @@ beforeEach(() => { describe("POST /api/documents/inbound/[id]/suggest", () => { it("422s without a vision provider and writes nothing", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, } as never); @@ -113,7 +114,7 @@ describe("POST /api/documents/inbound/[id]/suggest", () => { }); it("returns drafts and never persists them", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "anthropic", instance: {} }], pick: { entry: { providerType: "anthropic", instance: {} }, diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index ee1b05c9f..80607b9f1 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -27,12 +27,13 @@ vi.mock("@/lib/documents/describe", () => ({ transcribeDocument: vi.fn(), DocumentDescribeError: class DocumentDescribeError extends Error {}, })); -vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveVisionProvider: vi.fn(), - resolveTextProvider: vi.fn(), +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), + resolveDocumentTextProvider: vi.fn(), })); vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), @@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { auditLog } from "@/lib/auth/audit"; import { runDocumentSummary, @@ -105,7 +106,7 @@ beforeEach(() => { mimeType: "image/png", status: "STORED", } as never); - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "anthropic", instance: {} }], pick: { entry: { providerType: "anthropic", instance: {} }, @@ -151,7 +152,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => { }); it("422s without a provider", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, } as never); diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts index e84a69e1b..dff362c26 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -25,11 +25,11 @@ vi.mock("@/lib/documents/describe", () => ({ vi.mock("@/lib/documents/content-index", () => ({ upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }), })); -vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveVisionProvider: vi.fn(), +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), })); vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), ConsentRequiredError: class ConsentRequiredError extends Error {}, })); vi.mock("@/lib/ai/coach/budget", () => ({ @@ -42,9 +42,9 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); import { runContentIndexBackfillForUser } from "../document-content-index-backfill"; import { prisma } from "@/lib/db"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { - assertConsentForChain, + assertDocumentEgressConsent, ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { reserveBudget } from "@/lib/ai/coach/budget"; @@ -81,7 +81,7 @@ beforeEach(() => { describe("runContentIndexBackfillForUser", () => { it("no-ops with no provider", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue({ + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, } as never); @@ -91,8 +91,8 @@ describe("runContentIndexBackfillForUser", () => { }); it("no-ops when consent is missing", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); - vi.mocked(assertConsentForChain).mockRejectedValueOnce( + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce( new ConsentRequiredError("insights" as never), ); const result = await runContentIndexBackfillForUser("user-1"); @@ -101,7 +101,7 @@ describe("runContentIndexBackfillForUser", () => { }); it("indexes the not-yet-indexed documents", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); // One short page (< PAGE_SIZE) — the walk breaks after it, so a single // findMany return is enough (a trailing once would leak to the next test). vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ @@ -115,7 +115,7 @@ describe("runContentIndexBackfillForUser", () => { }); it("stops when the daily budget is reached (resumable)", async () => { - vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([ { id: "d1" }, { id: "d2" }, From c825d4b5012019e219c5ee06e90d5bfa452b3798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:59:56 +0200 Subject: [PATCH 4/7] feat(documents): per-egress notice + document-AI capability probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/documents/inbound/capability (document provider order + vendor-blind egress class), repoint the vault capability hook to it, and show a per-egress notice in the detail sheet before a document read leaves the machine. Cover the document provider order, the egress consent gate, and the notice with tests. i18n x6 + OpenAPI regenerated. Co-Authored-By: Marc-André Bombeck --- docs/api/openapi.yaml | 73 ++++++++++++ messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../api/documents/inbound/capability/route.ts | 42 +++++++ .../__tests__/document-ai-section.test.tsx | 17 +++ .../documents/document-ai-section.tsx | 20 +++- .../documents/document-detail-sheet.tsx | 1 + .../documents/use-document-assist.ts | 20 ++-- src/lib/ai/__tests__/consent-guard.test.ts | 66 +++++++++++ .../__tests__/provider-order.test.ts | 112 ++++++++++++++++++ src/lib/documents/provider-order.ts | 26 +--- src/lib/openapi/routes/documents.ts | 32 +++++ src/lib/query-keys/documents.ts | 8 ++ src/lib/validations/inbound-documents.ts | 24 ++++ 18 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 src/app/api/documents/inbound/capability/route.ts create mode 100644 src/lib/documents/__tests__/provider-order.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 756279cdb..ee746e936 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8905,6 +8905,27 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/documents/inbound/capability: + get: + tags: + - Documents + summary: Probe document-AI availability + egress + description: "Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read + leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so + `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: + `\"local\"` (a self-hosted model — the document never leaves the operator's machine) or `\"external\"` (a + third-party AI service). The vault shows a per-egress notice before any external document read; sending a + document to any external provider also requires an active AI-consent receipt." + responses: + "200": + description: Document-AI capability flags + egress class. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/usage: get: tags: @@ -26253,6 +26274,58 @@ components: - data - error additionalProperties: false + DocumentAiCapabilityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentAiCapability" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentAiCapability: + type: object + properties: + available: + type: boolean + mode: + anyOf: + - type: string + enum: + - vision + - text + - type: "null" + reason: + anyOf: + - type: string + enum: + - no-provider + - enable-local-ocr + - type: "null" + pdfSupported: + type: boolean + egress: + anyOf: + - type: string + enum: + - local + - external + - type: "null" + required: + - available + - mode + - reason + - pdfSupported + - egress + additionalProperties: false DocumentUsageEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..63950f325 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Nach Titel oder Dateiname suchen", "empty": "Keine Dokumente gefunden." }, + "ai": { + "egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast." + }, "assist": { "suggest": "Details vorschlagen", "suggesting": "Wird gelesen…", diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..d5beaecab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Search by title or filename", "empty": "No documents found." }, + "ai": { + "egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there." + }, "assist": { "suggest": "Suggest details", "suggesting": "Reading…", diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..ab25f0d11 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Buscar por título o nombre de archivo", "empty": "No se encontraron documentos." }, + "ai": { + "egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí." + }, "assist": { "suggest": "Sugerir detalles", "suggesting": "Leyendo…", diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..ed827b2c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Rechercher par titre ou nom de fichier", "empty": "Aucun document trouvé." }, + "ai": { + "egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas." + }, "assist": { "suggest": "Suggérer des détails", "suggesting": "Lecture…", diff --git a/messages/it.json b/messages/it.json index 933289bb6..d1b386dea 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Cerca per titolo o nome del file", "empty": "Nessun documento trovato." }, + "ai": { + "egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì." + }, "assist": { "suggest": "Suggerisci dettagli", "suggesting": "Lettura…", diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..d36997efc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Szukaj po tytule lub nazwie pliku", "empty": "Nie znaleziono dokumentów." }, + "ai": { + "egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam." + }, "assist": { "suggest": "Zaproponuj szczegóły", "suggesting": "Odczytywanie…", diff --git a/src/app/api/documents/inbound/capability/route.ts b/src/app/api/documents/inbound/capability/route.ts new file mode 100644 index 000000000..54e694882 --- /dev/null +++ b/src/app/api/documents/inbound/capability/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/documents/inbound/capability + * + * Document-scoped AI capability probe (no provider call). Mirrors the labs + * `/api/labs/ocr/capability` probe but resolves over the DOCUMENT provider + * order (local-first, codex last — governance fix, oauth-investigation + * SYNTHESIS §1) and adds the vendor-blind `egress` class so the vault UI can + * show the "this leaves your machine to a third-party AI" notice BEFORE a read. + * + * The `mode` / `pdfSupported` / `egress` here reflect exactly what the document + * routes (suggest / summary / extract / index) will do, so the affordance the + * UI offers never diverges from what the endpoint accepts. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { resolveDocumentAiCapability } from "@/lib/documents/provider-order"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const capability = await resolveDocumentAiCapability(user.id); + + annotate({ + action: { name: "documents.ai.capability" }, + meta: { + available: capability.available, + mode: capability.mode, + reason: capability.reason, + pdfSupported: capability.pdfSupported, + egress: capability.egress, + }, + }); + + return apiSuccess(capability); +}); diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 9a304f2f3..9c3def4d2 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -33,6 +33,7 @@ function base( aiEnabled: true, indexEnabled: true, unavailableReason: null, + egressExternal: false, actionsDisabled: false, onSuggest: noop, suggestPending: false, @@ -106,6 +107,22 @@ describe("", () => { expect(html).toContain("Not saved"); }); + it("shows the vendor-blind egress notice before an external document read", () => { + const html = render(base({ aiEnabled: true, egressExternal: true })); + expect(html).toContain('data-slot="document-ai-egress-notice"'); + expect(html).toContain("third-party AI service"); + // Vendor-blind: never names a specific AI vendor in the document surface. + expect(html).not.toContain("OpenAI"); + expect(html).not.toContain("ChatGPT"); + // The actions are still offered — the notice informs, it does not block. + expect(html).toContain('data-slot="assist-suggest"'); + }); + + it("omits the egress notice when the read stays on the local machine", () => { + const html = render(base({ aiEnabled: true, egressExternal: false })); + expect(html).not.toContain('data-slot="document-ai-egress-notice"'); + }); + it("hides the content-index row when indexing is unavailable", () => { const html = render(base({ aiEnabled: true, indexEnabled: false })); expect(html).toContain('data-slot="assist-suggest"'); diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 10070d160..1ffef4e02 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -11,7 +11,7 @@ * calm "set up an AI provider" pointer — never an error, and the manual form * below stays fully usable. */ -import { FileText, Sparkles } from "lucide-react"; +import { FileText, ShieldAlert, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -32,6 +32,7 @@ export function DocumentAiSection({ aiEnabled, indexEnabled, unavailableReason, + egressExternal, actionsDisabled, onSuggest, suggestPending, @@ -57,6 +58,12 @@ export function DocumentAiSection({ aiEnabled: boolean; indexEnabled: boolean; unavailableReason: "no-provider" | "enable-local-ocr" | null; + /** + * True when a document read will egress to a third-party AI service (any + * provider other than a self-hosted local model). Drives the vendor-blind + * "this leaves your machine" notice shown before the AI actions. + */ + egressExternal: boolean; /** Capability still resolving — the transport mode isn't known yet. */ actionsDisabled: boolean; onSuggest: () => void; @@ -92,6 +99,17 @@ export function DocumentAiSection({ return (
+ {egressExternal ? ( +
+ +

{t("documents.ai.egressNotice")}

+
+ ) : null} +