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

@@ -0,0 +1,106 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { pollDeviceCode, encryptAdminCodexCreds } from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
/**
* Operator-shared central Codex — device-code poll (admin only).
*
* Cookie-only `requireAdmin()`. The client polls this every `intervalSeconds`;
* on the operator finishing the approval on chatgpt.com we exchange the code,
* then persist the three token pieces (access + refresh + `ChatGPT-Account-ID`),
* each AES-256-GCM encrypted, on the `AppSettings` singleton. The device cookie
* is deleted on success. Tokens are never logged and never leave the server.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
const rl = await checkRateLimit(
`central-codex-device-poll:${user.id}`,
60,
60_000,
);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const cookieStore = await cookies();
const cookie = cookieStore.get("central_codex_device")?.value;
if (!cookie) {
return apiError("No device-auth attempt in progress", 400);
}
let deviceAuthId: string;
let userCode: string;
try {
const decoded = JSON.parse(decrypt(cookie)) as {
deviceAuthId: string;
userCode: string;
};
deviceAuthId = decoded.deviceAuthId;
userCode = decoded.userCode;
} catch {
cookieStore.delete("central_codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
}
let result;
try {
result = await pollDeviceCode({ deviceAuthId, userCode });
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { central_codex_device_poll_error: message } });
return apiError(
"Central Codex device-poll failed — please restart the flow",
502,
);
}
if (result.status === "pending") {
annotate({
action: { name: "admin.central-codex.device.poll" },
meta: { pending: true },
});
return apiSuccess({ status: "pending" });
}
const enc = encryptAdminCodexCreds(result.creds);
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
adminCodexConnectedAt: new Date(),
adminCodexConnectionStatus: "connected",
},
create: {
id: "singleton",
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
adminCodexConnectedAt: new Date(),
adminCodexConnectionStatus: "connected",
},
});
cookieStore.delete("central_codex_device");
await auditLog("admin.central-codex.connected", {
userId: user.id,
ipAddress: getClientIp(request),
details: { method: "device_code" },
});
annotate({
action: { name: "admin.central-codex.device.poll" },
meta: { connected: true },
});
return apiSuccess({ status: "connected" });
});
@@ -0,0 +1,61 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { requestDeviceCode } from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { apiSuccess } from "@/lib/api-response";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Operator-shared central Codex — device-code flow start (admin only).
*
* Mirrors the per-user `auth/codex/device-start`, but cookie-only
* `requireAdmin()` gates it and the device-auth cookie is namespaced
* (`central_codex_device`) so it never collides with an operator's own per-user
* Codex connect flow. The device secret is persisted in an encrypted httpOnly
* cookie for the 15-minute window Hydra allows.
*/
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin();
const rl = await checkRateLimit(
`central-codex-device-start:${user.id}`,
5,
60_000,
);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const code = await requestDeviceCode();
const cookieStore = await cookies();
cookieStore.set(
"central_codex_device",
encrypt(
JSON.stringify({
deviceAuthId: code.deviceAuthId,
userCode: code.userCode,
}),
),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
path: "/",
maxAge: 15 * 60,
},
);
annotate({
action: { name: "admin.central-codex.device.start" },
meta: { interval_seconds: code.intervalSeconds },
});
return apiSuccess({
userCode: code.userCode,
verificationUrl: code.verificationUrl,
intervalSeconds: code.intervalSeconds,
});
});
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* Operator-shared central Codex (ChatGPT subscription) — status + disconnect.
*
* GET /api/admin/central-codex — connection status + connected-at.
* DELETE /api/admin/central-codex — clear the shared credential.
*
* Cookie-only `requireAdmin()`: a Bearer token, even with `["*"]` scope, can
* never reach this. The encrypted token columns are NEVER returned — only the
* status string and the connected-at timestamp leave the server.
*/
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.central-codex.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexConnectionStatus: true,
adminCodexConnectedAt: true,
},
});
return apiSuccess({
status: settings?.adminCodexConnectionStatus ?? "disconnected",
connectedAt: settings?.adminCodexConnectedAt ?? null,
});
});
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
const rl = await checkRateLimit("admin-central-codex-disconnect", 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: {
adminCodexAccessTokenEncrypted: null,
adminCodexRefreshTokenEncrypted: null,
adminCodexAccountIdEncrypted: null,
adminCodexTokenExpiresAt: null,
adminCodexConnectedAt: null,
adminCodexConnectionStatus: "disconnected",
},
create: { id: "singleton", adminCodexConnectionStatus: "disconnected" },
});
await auditLog("admin.central-codex.disconnected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "admin.central-codex.disconnect" } });
return apiSuccess({ disconnected: true });
});
@@ -0,0 +1,118 @@
/**
* Per-user opt-in to the operator's shared central Codex (ChatGPT subscription).
*
* GET /api/auth/me/use-central-codex — current flag.
* PATCH /api/auth/me/use-central-codex — body `{ useCentralCodex: boolean }`.
*
* OFF by default. When `true`, and the operator has connected the central Codex,
* the user's resolved provider chain gains a trailing `admin-codex` entry — an
* external, shared, train-by-default subscription account bound by the
* operator's rate limits. Using it is external egress: the existing consent gate
* (`admin-codex` is server-managed) still requires an active AI consent receipt
* before any PHI leaves for it, so flipping this ON never egresses anything on
* its own.
*
* Mirrors `auth/me/labs-local-ocr`: 60/min rate limit, Zod `safeParse` → 422 via
* `returnAllZodIssues`, audit-log row, field-by-field write (no mass
* assignment). Idempotent — always returns the resolved next state.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({
useCentralCodex: z.boolean(),
});
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type UseCentralCodexResponse = {
useCentralCodex: boolean;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.useCentralCodex.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { useCentralCodex: true },
});
const payload: UseCentralCodexResponse = {
useCentralCodex: row?.useCentralCodex ?? false,
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`use-central-codex:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.useCentralCodex;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { useCentralCodex: true },
});
await prisma.user.update({
where: { id: user.id },
data: { useCentralCodex: next },
});
await auditLog("user.useCentralCodex.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous?.useCentralCodex ?? false,
next,
},
});
annotate({
action: { name: "auth.me.useCentralCodex.patch" },
meta: { useCentralCodex: next },
});
const payload: UseCentralCodexResponse = { useCentralCodex: next };
return apiSuccess(payload);
});
+21 -1
View File
@@ -13,6 +13,7 @@ export const GET = apiHandler(async () => {
select: {
codexConnectionStatus: true,
codexConnectedAt: true,
useCentralCodex: true,
insightsPrivacyMode: true,
insightsCachedAt: true,
},
@@ -20,9 +21,24 @@ export const GET = apiHandler(async () => {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiKeyEncrypted: true },
select: {
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
},
});
// Presence-only: whether the operator has connected the shared central Codex.
// The encrypted tokens themselves are NEVER returned to a non-admin client —
// only this boolean and the user's own opt-in flag leave the server.
const centralCodexAvailable =
settings?.adminCodexConnectionStatus === "connected" &&
!!settings.adminCodexAccessTokenEncrypted &&
!!settings.adminCodexRefreshTokenEncrypted &&
!!settings.adminCodexAccountIdEncrypted;
annotate({ action: { name: "insights.settings.get" } });
return apiSuccess({
@@ -34,6 +50,10 @@ export const GET = apiHandler(async () => {
// the UI hides the button instead of redirecting to a chatgpt.com
// login the user can never complete.
codexOauthConfigured: isCodexOAuthConfigured(),
// Operator-shared central Codex — only surfaced so the per-user opt-in
// switch shows when the operator connected it. OFF everywhere by default.
centralCodexAvailable,
useCentralCodex: dbUser?.useCentralCodex ?? false,
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
lastInsightAt: dbUser?.insightsCachedAt ?? null,
});