Files
HealthLog/src/lib/ai/consent-guard.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

186 lines
8.1 KiB
TypeScript

/**
* v1.12.1 — server-side consent gate before external-LLM PHI egress.
*
* The `ConsentReceipt` infrastructure (`latestActiveReceipt`,
* `ai_full | ai_insights_only | ai_coach`, append-only audit trail) was built
* but never enforced as a precondition: a direct API caller with a valid
* bearer/cookie could trigger external-LLM processing of their own health
* snapshot with no receipt on file. Consent was enforced client-side only.
*
* This guard closes that gap. Before the first external-provider call on a
* SERVER-MANAGED path — i.e. the operator's global OpenAI key resolved via
* `resolveAdminProvider` and tagged `admin-openai` in the chain — an active,
* non-revoked receipt is required for the calling user.
*
* Scope, deliberately narrow (per the audit's fix sketch + the launch task):
* - GATED: `admin-openai` — the operator's global key forwards the user's
* metrics to a third-party processor the user did not contract
* with directly. This is the egress consent law cares about.
* - UNGATED: a user's own BYOK key (`openai` / `anthropic`), their own
* ChatGPT OAuth account (`codex`), and the self-hosted `local`
* provider. These are the user's own egress to a processor they
* chose; the existing settings flow is the consent act there.
*
* The check fails CLOSED: a chain that COULD egress via the server-managed
* key requires a receipt even when a BYOK provider sits ahead of it in the
* chain, because the runner may cascade to the admin key on a primary
* failure and we must not race that decision. A receipt of the surface's
* 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";
/**
* The two AI surfaces that egress PHI. Each maps to the consent kind the iOS
* client collects for it; `ai_full` (the master grant) satisfies either.
*/
export type ConsentSurface = "coach" | "insights";
/**
* Provider tags that egress via an operator-managed credential the user did not
* personally contract: the operator's global OpenAI key (`admin-openai`) and the
* operator's shared central Codex / ChatGPT-subscription account (`admin-codex`).
* Both require an active consent receipt before any PHI leaves for them.
*/
const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
* so the api-handler renders the same 403 envelope shape the iOS client
* already branches on:
*
* { data: null, error: "...", meta: { errorCode: "consent.ai.required" } }
*/
export class ConsentRequiredError extends Error {
readonly errorCode = "consent.ai.required" as const;
readonly surface: ConsentSurface;
constructor(surface: ConsentSurface) {
super(
`Active AI consent is required before processing health data on the server-managed provider (surface: ${surface})`,
);
this.name = "ConsentRequiredError";
this.surface = surface;
}
}
/**
* True when the resolved provider chain contains at least one entry that
* would egress via the operator's server-managed/global key. BYOK + local +
* the user's own ChatGPT OAuth never trip this.
*/
export function chainRequiresServerManagedConsent(
chain: ReadonlyArray<ProviderChainResolved>,
): boolean {
return chain.some((entry) =>
SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType),
);
}
/** The consent kinds that satisfy a given surface (the specific + the master). */
function acceptableKinds(surface: ConsentSurface): ConsentKind[] {
return surface === "coach"
? ["ai_coach", "ai_full"]
: ["ai_insights_only", "ai_full"];
}
/**
* Return whether the user has an active receipt that satisfies `surface`.
* Reads the specific kind first, then the master `ai_full` grant — at most
* two indexed point-reads, short-circuiting on the first hit.
*/
export async function hasActiveConsentForSurface(
userId: string,
surface: ConsentSurface,
): Promise<boolean> {
for (const kind of acceptableKinds(surface)) {
if (await latestActiveReceipt(userId, kind)) return true;
}
return false;
}
/**
* Enforce the consent precondition for a resolved provider chain.
*
* No-op when the chain has no server-managed entry (pure BYOK / local / codex
* egress is the user's own act and stays ungated). When the chain COULD egress
* via the operator's key, throw `ConsentRequiredError` unless an active
* receipt of the surface's mapped kind (or `ai_full`) is on file.
*
* Call this AFTER the chain is resolved and BEFORE the first
* `runRawCompletionWithFallback` / `runWithFallback` call.
*/
export async function assertConsentForChain(args: {
userId: string;
chain: ReadonlyArray<ProviderChainResolved>;
surface: ConsentSurface;
}): Promise<void> {
if (!chainRequiresServerManagedConsent(args.chain)) return;
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<string> = 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.
*
* 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.
*/
export async function assertDocumentEgressConsent(args: {
userId: string;
providerType: string;
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);
}