mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
171 lines
5.9 KiB
TypeScript
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,
|
|
};
|
|
}
|