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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 00:10:56 +02:00
1 parent fa42c8bc0b
commit d7e616eaa8
35 files changed
+1553 -26

No files matched your search

+16 -1
View File
@@ -63,6 +63,15 @@ describe("chainRequiresServerManagedConsent", () => {
).toBe(true);
});
it("is true for the operator-shared central Codex (admin-codex)", () => {
expect(chainRequiresServerManagedConsent([entry("admin-codex")])).toBe(
true,
);
expect(
chainRequiresServerManagedConsent([entry("codex"), entry("admin-codex")]),
).toBe(true);
});
it("is false for pure BYOK / local / codex chains", () => {
expect(chainRequiresServerManagedConsent([entry("openai")])).toBe(false);
expect(chainRequiresServerManagedConsent([entry("anthropic")])).toBe(false);
@@ -182,7 +191,13 @@ describe("isExternalDocumentEgress", () => {
});
it("treats every external provider as document egress", () => {
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
for (const p of [
"codex",
"openai",
"anthropic",
"admin-openai",
"admin-codex",
]) {
expect(isExternalDocumentEgress(p)).toBe(true);
}
});
@@ -33,6 +33,18 @@ describe("parseProviderChain", () => {
expect(out.map((e) => e.providerType)).toEqual(["codex", "openai"]);
});
it("recognises admin-codex as a valid type but excludes it from the default", () => {
// The operator-shared central Codex is a known chain type (so an appended
// entry typechecks), but it is opt-in only and never part of the default.
expect(PROVIDER_CHAIN_DEFAULT.map((e) => e.providerType)).not.toContain(
"admin-codex",
);
const out = parseProviderChain([
{ providerType: "admin-codex", priority: 1, enabled: true },
]);
expect(out.map((e) => e.providerType)).toEqual(["admin-codex"]);
});
it("sorts by priority ascending (lowest priority value first)", () => {
const input: ProviderChainEntry[] = [
{ providerType: "openai", priority: 5, enabled: true },
@@ -101,6 +101,16 @@ describe("supportsVisionForConfig", () => {
expect(supportsVisionForConfig("codex", "o3")).toBe(true);
});
it("mirrors codex vision capability for the shared central codex (admin-codex)", () => {
// The operator-shared central Codex runs the same wire on the same resolved
// slug, so its vision gate is identical to `codex`.
expect(supportsVisionForConfig("admin-codex", "gpt-5.5")).toBe(true);
expect(supportsVisionForConfig("admin-codex", "gpt-5.3-codex")).toBe(true);
expect(supportsVisionForConfig("admin-codex", "gpt-4")).toBe(false);
expect(supportsVisionForConfig("admin-codex", "o3-mini")).toBe(false);
expect(supportsVisionForConfig("admin-codex", null)).toBe(false);
});
it("never reports vision for none", () => {
expect(supportsVisionForConfig("none", "gpt-4o")).toBe(false);
});
@@ -120,6 +120,14 @@ describe("resolveDailyCap (F1 — provider-aware cap)", () => {
);
});
it("applies the operator-cost cap to the shared central-codex (admin-codex) primary", () => {
// The operator's shared ChatGPT-subscription account drains the operator's
// allowance, so it is billed against the operator cap, not the user plan.
expect(resolveDailyCap([{ providerType: "admin-codex" }])).toBe(
OPERATOR_COST_CAP,
);
});
it("applies the generous user-plan cap to a ChatGPT-OAuth (codex) primary", () => {
expect(resolveDailyCap([{ providerType: "codex" }])).toBe(USER_PLAN_CAP);
});
+10 -7
View File
@@ -52,18 +52,21 @@ export const USER_PLAN_CAP = 2_000_000;
* the daily cap that applies.
*
* The chain is a fallback list; the FIRST entry is the provider that will be
* tried first and is the expected cost owner. Only when that primary provider
* is `admin-openai` (the user has no personal provider and falls back to the
* operator's key) does the operator pay — so the operator-cost cap applies.
* Every other primary (`codex` / `openai` / `anthropic` / `local`) is the
* user's own egress, so the generous user-plan cap applies. An empty chain
* (no provider resolved) defaults to the operator cap — the conservative side.
* tried first and is the expected cost owner. The operator pays when that
* primary provider is `admin-openai` (the operator's shared API key) OR
* `admin-codex` (the operator's shared ChatGPT-subscription account) — both
* drain the operator's resources, so the operator-cost cap applies. Every other
* primary (`codex` / `openai` / `anthropic` / `local`) is the user's own
* egress, so the generous user-plan cap applies. An empty chain (no provider
* resolved) defaults to the operator cap — the conservative side.
*/
export function resolveDailyCap(
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
): number {
const primary = chain[0]?.providerType;
return primary === "admin-openai" || primary === undefined
return primary === "admin-openai" ||
primary === "admin-codex" ||
primary === undefined
? OPERATOR_COST_CAP
: USER_PLAN_CAP;
}
+52
View File
@@ -350,3 +350,55 @@ export function decryptCodexCreds(encrypted: {
expiresAt: new Date(parsed.expiresAt),
};
}
// ─── Operator-shared central-codex storage codec (AppSettings) ───────
//
// The operator-level central Codex credential lives on `AppSettings` in three
// dedicated encrypted columns plus a plaintext expiry `DateTime`, rather than
// the per-user packed JSON blob. Three separate columns keep each token piece
// independently rotatable and make the `ChatGPT-Account-ID` claim an explicit,
// registered encrypted column (`admin_codex_account_id_encrypted`).
export interface EncryptedAdminCodexCreds {
accessEncrypted: string;
refreshEncrypted: string;
accountIdEncrypted: string;
expiresAt: Date;
}
export function encryptAdminCodexCreds(
creds: CodexCreds,
): EncryptedAdminCodexCreds {
return {
accessEncrypted: encrypt(creds.accessToken),
refreshEncrypted: encrypt(creds.refreshToken),
accountIdEncrypted: encrypt(creds.accountId),
expiresAt: creds.expiresAt,
};
}
export function decryptAdminCodexCreds(encrypted: {
accessEncrypted: string;
refreshEncrypted: string;
accountIdEncrypted: string;
expiresAt: Date | null;
}): CodexCreds | null {
try {
const accessToken = decrypt(encrypted.accessEncrypted);
const refreshToken = decrypt(encrypted.refreshEncrypted);
const accountId = decrypt(encrypted.accountIdEncrypted);
if (!accessToken || !refreshToken || !accountId) return null;
return {
accessToken,
refreshToken,
accountId,
// A missing expiry column (shouldn't happen once connected) is treated as
// already expired so the caller proactively refreshes before first use.
expiresAt: encrypted.expiresAt ?? new Date(0),
};
} catch {
// Fail closed: a decrypt failure (missing / rotated-out key) surfaces as a
// corrupt connection the operator must re-link, never as plaintext.
return null;
}
}
+7 -1
View File
@@ -38,9 +38,15 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
*/
export type ConsentSurface = "coach" | "insights";
/** Provider tags that egress via the operator's server-managed/global key. */
/**
* 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",
]);
/**
+11 -2
View File
@@ -21,8 +21,16 @@
/**
* Provider tags used in the chain. `admin-openai` is the legacy
* `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users
* still see insights via the operator's key); the rest map 1:1 onto
* the per-user encrypted-credential columns.
* still see insights via the operator's key); `admin-codex` is the
* operator-shared central Codex (ChatGPT-subscription) connection a user
* opts into via `useCentralCodex`; the rest map 1:1 onto the per-user
* encrypted-credential columns.
*
* `admin-codex` is NOT part of `PROVIDER_CHAIN_DEFAULT` and is never
* offered in the chain editor — it is appended to a user's resolved chain
* at resolution time ONLY when they opted in AND the operator connected it
* (see `resolveProviderChain`), so a persisted `admin-codex` entry never
* resolves on its own.
*
* Distinct from `ProviderType` (in `types.ts`) because that enum tags
* the **runtime** provider instance — which has no notion of "admin
@@ -36,6 +44,7 @@ export const PROVIDER_CHAIN_TYPES = [
"anthropic",
"local",
"admin-openai",
"admin-codex",
] as const;
export type ProviderChainType = (typeof PROVIDER_CHAIN_TYPES)[number];
+195 -4
View File
@@ -9,6 +9,8 @@ import {
refreshDeviceTokens,
encryptCodexCreds,
decryptCodexCreds,
encryptAdminCodexCreds,
decryptAdminCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
@@ -217,6 +219,130 @@ async function resolveCodexProvider(
});
}
/**
* Operator-shared central Codex provider — the `admin-codex` chain entry.
*
* Mirrors `resolveCodexProvider` but reads the operator's singleton credential
* from `AppSettings.adminCodex*` (three dedicated encrypted columns) instead of
* the per-user `codex*Encrypted` columns, and writes any refreshed tokens back
* to the same singleton row. Returns null unless the operator has actually
* connected the central Codex (`adminCodexConnectionStatus === "connected"` with
* all three token columns present) — the per-user `useCentralCodex` opt-in is
* enforced by the caller before this is ever invoked.
*
* Never called with any user identity: the credential and every token refresh
* belong to the operator's account, so cost + rate limits land on the operator
* (billed against the operator cap, not the user plan).
*/
async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
});
if (
settings?.adminCodexConnectionStatus !== "connected" ||
!settings.adminCodexAccessTokenEncrypted ||
!settings.adminCodexRefreshTokenEncrypted ||
!settings.adminCodexAccountIdEncrypted
) {
return null;
}
const stored = decryptAdminCodexCreds({
accessEncrypted: settings.adminCodexAccessTokenEncrypted,
refreshEncrypted: settings.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: settings.adminCodexAccountIdEncrypted,
expiresAt: settings.adminCodexTokenExpiresAt,
});
if (!stored) {
// Corrupt / undecryptable credential (e.g. a rotated-out key) — mark the
// singleton expired so the admin UI prompts a re-link. Never plaintext.
await prisma.appSettings.update({
where: { id: "singleton" },
data: { adminCodexConnectionStatus: "expired" },
});
return null;
}
let active = stored;
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptAdminCodexCreds(fresh);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
} catch {
// Fall through — CodexClient triggers an on-401 refresh and persists via
// the callback below.
}
}
return new CodexClient({
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const fresh = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
});
if (
!fresh?.adminCodexAccessTokenEncrypted ||
!fresh.adminCodexRefreshTokenEncrypted ||
!fresh.adminCodexAccountIdEncrypted
) {
throw new Error("No central-codex refresh token available");
}
const decoded = decryptAdminCodexCreds({
accessEncrypted: fresh.adminCodexAccessTokenEncrypted,
refreshEncrypted: fresh.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: fresh.adminCodexAccountIdEncrypted,
expiresAt: fresh.adminCodexTokenExpiresAt,
});
if (!decoded) {
throw new Error(
"Central-codex token storage corrupt; re-link required",
);
}
const refreshed = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptAdminCodexCreds(refreshed);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
return {
accessToken: refreshed.accessToken,
accountId: refreshed.accountId,
};
},
});
}
/**
* Resolve the AI provider for a given user.
*
@@ -295,6 +421,7 @@ export async function resolveProviderChain(
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiProviderChain: true,
useCentralCodex: true,
},
});
@@ -311,6 +438,20 @@ export async function resolveProviderChain(
resolved.push({ providerType: entry.providerType, instance });
}
}
// Operator-shared central Codex — appended LAST, and ONLY when the user opted
// in AND the operator has connected it. It is never part of the persisted
// chain (a persisted `admin-codex` entry resolves to null above), so this is
// the single place the opt-in gate is enforced. As a trailing fallback the
// user's own providers are tried first; a user with no personal provider
// resolves to `[admin-codex]` and is billed against the operator cap.
if (userRow?.useCentralCodex) {
const centralCodex = await resolveAdminCodexProvider();
if (centralCodex) {
resolved.push({ providerType: "admin-codex", instance: centralCodex });
}
}
return resolved;
}
@@ -411,14 +552,46 @@ export async function hasAnyConfiguredProvider(
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
useCentralCodex: true,
},
});
if (!userRow) return false;
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiKeyEncrypted: true },
select: {
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
},
});
return userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted);
if (userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted)) {
return true;
}
// A user with no personal provider can still be served by the operator's
// shared central Codex when they opted in and the operator connected it.
return userRow.useCentralCodex && appSettingsCentralCodexConnected(settings);
}
/**
* Presence-only check that the operator's central Codex is connected, over an
* already-loaded `AppSettings` subset. No decrypt, no network.
*/
function appSettingsCentralCodexConnected(
settings: {
adminCodexConnectionStatus: string | null;
adminCodexAccessTokenEncrypted: string | null;
adminCodexRefreshTokenEncrypted: string | null;
adminCodexAccountIdEncrypted: string | null;
} | null,
): boolean {
return (
settings?.adminCodexConnectionStatus === "connected" &&
!!settings.adminCodexAccessTokenEncrypted &&
!!settings.adminCodexRefreshTokenEncrypted &&
!!settings.adminCodexAccountIdEncrypted
);
}
/**
@@ -502,18 +675,30 @@ export async function resolveProviderAvailability(
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
useCentralCodex: true,
},
});
if (!userRow) return { aiAvailable: false, managedBy: null };
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiKeyEncrypted: true },
select: {
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
},
});
const managedBy = resolveManagedByFromRow(
userRow,
!!settings?.adminAiKeyEncrypted,
);
return { aiAvailable: managedBy !== null, managedBy };
if (managedBy !== null) return { aiAvailable: true, managedBy };
// The operator's shared central Codex is operator-managed egress ("server").
if (userRow.useCentralCodex && appSettingsCentralCodexConnected(settings)) {
return { aiAvailable: true, managedBy: "server" };
}
return { aiAvailable: false, managedBy: null };
}
/**
@@ -568,6 +753,12 @@ async function resolveProviderForType(
const admin = await resolveAdminProvider();
return admin.type === "none" ? null : admin;
}
case "admin-codex":
// Never resolved from a persisted chain entry — the operator-shared
// central Codex is appended in `resolveProviderChain` ONLY behind the
// per-user `useCentralCodex` opt-in. Returning null here keeps a
// hand-crafted `admin-codex` chain entry from bypassing that gate.
return null;
default:
return null;
}
+4
View File
@@ -115,6 +115,7 @@ export type VisionProviderType =
| "admin-key"
| "local"
| "codex"
| "admin-codex"
| "none";
/**
@@ -146,6 +147,9 @@ export function supportsVisionForConfig(
case "local":
return true;
case "codex":
// The operator-shared central Codex runs the same `codex/responses` wire on
// a resolved GPT-5.x slug, so its vision capability is identical to `codex`.
case "admin-codex":
return model ? codexSupportsVision(model) : false;
case "none":
default:
+19
View File
@@ -106,6 +106,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
field: "webPushVapidPrivateKeyEncrypted",
kind: "string",
},
// Operator-shared central Codex (ChatGPT subscription) OAuth credential — the
// access token, the rotating refresh token, and the mandatory
// `ChatGPT-Account-ID` claim, each AES-256-GCM at rest like the per-user
// `codex*Encrypted` columns.
{
model: "AppSettings",
field: "adminCodexAccessTokenEncrypted",
kind: "string",
},
{
model: "AppSettings",
field: "adminCodexRefreshTokenEncrypted",
kind: "string",
},
{
model: "AppSettings",
field: "adminCodexAccountIdEncrypted",
kind: "string",
},
// ───── Custom labels (mood + cycle) ─────
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
@@ -60,6 +60,19 @@ describe("reorderChainForDocumentClass", () => {
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
});
it("demotes the shared central codex (admin-codex) to the subscription tier (rank 3)", () => {
// Same train-by-default subscription tier as `codex` — behind local, BYOK,
// and the operator's no-train admin key.
expect(
order([
entry("admin-codex"),
entry("local"),
entry("openai"),
entry("admin-openai"),
]),
).toEqual(["local", "openai", "admin-openai", "admin-codex"]);
});
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
expect(order([entry("anthropic"), entry("openai")])).toEqual([
"anthropic",
@@ -85,6 +98,7 @@ describe("documentEgressClass", () => {
expect(documentEgressClass("openai")).toBe("external");
expect(documentEgressClass("anthropic")).toBe("external");
expect(documentEgressClass("admin-openai")).toBe("external");
expect(documentEgressClass("admin-codex")).toBe("external");
});
});
+4 -2
View File
@@ -41,8 +41,9 @@ import type {
* 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.
* 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) {
@@ -55,6 +56,7 @@ function documentProviderRank(providerType: string): number {
case "admin-openai":
return 2;
case "codex":
case "admin-codex":
return 3;
default:
return 2;
+5 -2
View File
@@ -35,8 +35,11 @@ function modelForEntry(
): string | null {
// Codex resolves its working slug at request time from the OAuth slug
// fallback chain — NOT from the user's `aiModel`. Use the cached/chain-head
// slug so the codex vision gate tests the model that actually runs.
if (providerType === "codex") return resolveCodexVisionSlug();
// slug so the codex vision gate tests the model that actually runs. The
// operator-shared central Codex (`admin-codex`) rides the same slug chain.
if (providerType === "codex" || providerType === "admin-codex") {
return resolveCodexVisionSlug();
}
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
}
+6
View File
@@ -16,6 +16,12 @@ export const adminKeys = {
* `insightsSettings.hasAdminKey` flag mirrors the configured state.
*/
adminAiServerKey: () => ["admin", "ai-server-key"] as const,
/**
* Operator-shared central Codex (ChatGPT subscription) connection status.
* Read/write via `/api/admin/central-codex`; the user-facing
* `insightsSettings.centralCodexAvailable` flag mirrors the connected state.
*/
adminCentralCodex: () => ["admin", "central-codex"] as const,
adminAppLogs: (
traceId: string | undefined,
action: string | undefined,