diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index adfa10b47..16e41710c 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -183,18 +183,22 @@ describe("every provider-resolving module carries a consent helper", () => { }); describe("the consent guard itself stays the single source of the policy", () => { - it("keeps the server-managed provider set defined in exactly one place", () => { - // Both operator-credential tags must be classified in consent-guard.ts and - // nowhere else — a second copy of this list is how the policy drifts. - const guard = read("lib/ai/consent-guard.ts"); - expect(guard).toContain('"admin-openai"'); - expect(guard).toContain('"admin-codex"'); + it("keeps the operator-held provider set defined in exactly one place", () => { + // The two operator-credential tags are listed in provider-egress.ts and + // nowhere else, and the consent guard reads them from there. A second copy + // is how the policy drifts. This guard used to match one variable NAME, so + // a copy under another name in the capability resolver went unseen for as + // long as it existed; it now matches the list itself, in either order. + const pair = + /\[\s*"admin-(?:openai|codex)"\s*,\s*"admin-(?:openai|codex)"\s*,?\s*\]/; + const holders = sourceFiles().filter((rel) => pair.test(read(rel))); + expect(holders).toEqual(["lib/ai/provider-egress.ts"]); - const copies = sourceFiles().filter( - (rel) => - rel !== "lib/ai/consent-guard.ts" && - /SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)), + expect(read("lib/ai/consent-guard.ts")).toMatch( + /from "@\/lib\/ai\/provider-egress"/, + ); + expect(read("lib/ai/capabilities/resolve.ts")).toMatch( + /from "@\/lib\/ai\/provider-egress"/, ); - expect(copies).toEqual([]); }); }); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 78e90ca01..74579d8c3 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress"; export const dynamic = "force-dynamic"; @@ -43,8 +44,11 @@ interface ProviderHealthSummary { lastFailureStatus: number | null; } -/** The two operator-managed tags sort first — they affect every user on the chain. */ -const CENTRAL_TYPES = ["admin-openai", "admin-codex"]; +/** + * The operator-managed tags sort first — they affect every user on the chain. + * Read from the one definition, in its order (the OpenAI key, then Codex). + */ +const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export const GET = apiHandler(async () => { await requireAdmin(); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index c661d78d3..60c48f119 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -13,6 +13,10 @@ * a client acted on it. They stay a per-call refusal with their own codes. */ import { documentProviderRank } from "@/lib/documents/provider-rank"; +import { + isExternalProvider, + isOperatorHeldProvider, +} from "@/lib/ai/provider-egress"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; @@ -70,14 +74,8 @@ export interface AiCapabilityInputs { recordKind: AiRecordKind; } -/** Provider tags that egress through a credential the operator holds. */ -const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet = new Set([ - "admin-openai", - "admin-codex", -]); - -/** The one provider tag that keeps its input on the machine. */ -const LOCAL_PROVIDER_TYPE = "local"; +// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`, +// the list the consent gate reads too. const REASON_RANK: ReadonlyMap = new Map( AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]), @@ -208,18 +206,18 @@ function consentNeeded( // A cascade may fall through to any entry, so one operator-held entry // anywhere in the chain is enough to need the receipt. return provider.entries.some((entry) => - OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType), + isOperatorHeldProvider(entry.providerType), ); } if (def.modality === "document") { // Document reads call the one picked provider, no cascade. const pick = documentPick(def, provider); - return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE; + return pick !== null && isExternalProvider(pick.providerType); } // Text under the document rule cascades like a snapshot does, so any entry // that leaves the machine needs the receipt. - return provider.entries.some( - (entry) => entry.providerType !== LOCAL_PROVIDER_TYPE, + return provider.entries.some((entry) => + isExternalProvider(entry.providerType), ); } diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 85b462a84..062c65552 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -30,6 +30,10 @@ import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { + isExternalProvider, + isOperatorHeldProvider, +} from "@/lib/ai/provider-egress"; /** * The two AI surfaces that egress PHI through a chain. Each maps to the consent @@ -39,17 +43,6 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; */ 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 = 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 the AI capability refusal @@ -79,9 +72,7 @@ export class ConsentRequiredError extends Error { export function chainRequiresServerManagedConsent( chain: ReadonlyArray, ): boolean { - return chain.some((entry) => - SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType), - ); + return chain.some((entry) => isOperatorHeldProvider(entry.providerType)); } /** The consent kinds that satisfy a given surface (the specific + the master). */ @@ -148,13 +139,11 @@ export async function assertConsentForChain(args: { * reads the receipt kinds from the capability table; the auto-read toggle is a * trigger, never the consent, so a revoked receipt wins over a toggle left on. */ -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); + return isExternalProvider(providerType); } diff --git a/src/lib/ai/provider-egress.ts b/src/lib/ai/provider-egress.ts new file mode 100644 index 000000000..615bf5d7b --- /dev/null +++ b/src/lib/ai/provider-egress.ts @@ -0,0 +1,36 @@ +/** + * Which AI provider tags send a record off the machine, and through whose + * credential. One definition, read by the consent gate + * (`src/lib/ai/consent-guard.ts`) and the capability resolver + * (`src/lib/ai/capabilities/resolve.ts`). They each kept their own copy + * before, and a provider added to one list but not the other would have been + * consented for in one place and not the other. + */ + +/** + * Provider tags that egress through a credential the operator holds, which + * the user did not personally contract: the operator's global OpenAI key + * (`admin-openai`) and the operator's shared Codex / ChatGPT-subscription + * account (`admin-codex`). Both need an active consent receipt before any + * health data leaves for them. + */ +export const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet = new Set([ + "admin-openai", + "admin-codex", +]); + +/** The one provider tag that keeps its input on the operator's machine. */ +export const LOCAL_PROVIDER_TYPE = "local"; + +export function isOperatorHeldProvider(providerType: string): boolean { + return OPERATOR_HELD_PROVIDER_TYPES.has(providerType); +} + +/** + * True when this provider sends its input OFF the machine to a third-party AI + * service. Only the self-hosted `local` provider keeps it on the operator's + * own infrastructure. + */ +export function isExternalProvider(providerType: string): boolean { + return providerType !== LOCAL_PROVIDER_TYPE; +} diff --git a/src/lib/ai/server-provider-health.ts b/src/lib/ai/server-provider-health.ts index b4ba030cb..81a991b6d 100644 --- a/src/lib/ai/server-provider-health.ts +++ b/src/lib/ai/server-provider-health.ts @@ -40,6 +40,7 @@ */ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress"; /** * The whole contract. Three words, no payload. @@ -68,11 +69,11 @@ export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000; /** * The provider tags the operator pays for: the instance-wide OpenAI key and - * the shared central Codex account. These are exactly the two tags - * `consent-guard.ts` gates on, and that is not a coincidence — the offer - * this feeds is the consent for these two and for nothing else. + * the shared central Codex account. The same set `consent-guard.ts` gates on, + * read from the one definition, because the offer this feeds is the consent + * for these and for nothing else. */ -const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const; +const SHARED_PROVIDER_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export async function readServerProviderHealth( now: Date = new Date(), @@ -84,7 +85,7 @@ export async function readServerProviderHealth( }>; try { rows = await prisma.providerHealth.findMany({ - where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } }, + where: { providerType: { in: SHARED_PROVIDER_TYPES } }, // Three columns, none of which identifies an account. The absence of // `userId` here is load-bearing, not tidiness. select: { lastResult: true, lastOkAt: true, nextRetryAt: true },