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,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,
});
});