Files
HealthLog/src/lib/documents/provider-order.ts
T
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00

171 lines
5.9 KiB
TypeScript

/**
* 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 { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
type ChainReorder,
type VisionProviderPick,
} from "@/lib/labs/ocr-capability";
import type {
DocumentAiCapabilityDto,
DocumentEgressClass,
} from "@/lib/validations/inbound-documents";
/**
* 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 paths are LAST (rank 3) — the user's own `codex`
* AND the operator's shared `admin-codex`, both train 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":
case "admin-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<VisionProviderPick> {
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,
});
}
/**
* 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";
}
/**
* 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<DocumentAiCapabilityDto> {
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. PDFs are readable
// whenever the picked provider natively supports them (Anthropic) OR the
// server-side rasterizer is available (every other vision provider reads a
// PDF via rasterized page images), so the UI offers a PDF read on codex too.
if (pick) {
return {
available: true,
mode: "vision",
reason: null,
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
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,
};
}