From d7e616eaa87aac8425443b2c152bf6ccde7739d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 10 Jul 2026 00:10:56 +0200
Subject: [PATCH] feat(ai): add operator-shared central Codex connection with
per-user opt-in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
e2e/central-codex.spec.ts | 197 +++++++++++++
messages/de.json | 23 +-
messages/en.json | 23 +-
messages/es.json | 23 +-
messages/fr.json | 23 +-
messages/it.json | 23 +-
messages/pl.json | 23 +-
.../0235_admin_central_codex/migration.sql | 28 ++
prisma/schema.prisma | 22 ++
scripts/rotate-encryption-key.ts | 5 +
src/app/admin/[section]/renderer.tsx | 2 +
.../admin/central-codex/device-poll/route.ts | 106 +++++++
.../admin/central-codex/device-start/route.ts | 61 ++++
src/app/api/admin/central-codex/route.ts | 65 +++++
.../api/auth/me/use-central-codex/route.ts | 118 ++++++++
src/app/api/insights/settings/route.ts | 22 +-
.../admin/central-codex-section.tsx | 270 ++++++++++++++++++
.../settings/ai/ai-insights-card.tsx | 5 +
.../settings/ai/central-codex-switch.tsx | 143 ++++++++++
src/components/settings/ai/shared.ts | 5 +
src/lib/ai/__tests__/consent-guard.test.ts | 17 +-
src/lib/ai/__tests__/provider-chain.test.ts | 12 +
.../ai/__tests__/vision-capability.test.ts | 10 +
src/lib/ai/coach/__tests__/budget.test.ts | 8 +
src/lib/ai/coach/budget.ts | 17 +-
src/lib/ai/codex-oauth.ts | 52 ++++
src/lib/ai/consent-guard.ts | 8 +-
src/lib/ai/provider-chain.ts | 13 +-
src/lib/ai/provider.ts | 199 ++++++++++++-
src/lib/ai/vision-capability.ts | 4 +
src/lib/crypto/encrypted-columns.ts | 19 ++
.../__tests__/provider-order.test.ts | 14 +
src/lib/documents/provider-order.ts | 6 +-
src/lib/labs/ocr-capability.ts | 7 +-
src/lib/query-keys/admin.ts | 6 +
35 files changed, 1553 insertions(+), 26 deletions(-)
create mode 100644 e2e/central-codex.spec.ts
create mode 100644 prisma/migrations/0235_admin_central_codex/migration.sql
create mode 100644 src/app/api/admin/central-codex/device-poll/route.ts
create mode 100644 src/app/api/admin/central-codex/device-start/route.ts
create mode 100644 src/app/api/admin/central-codex/route.ts
create mode 100644 src/app/api/auth/me/use-central-codex/route.ts
create mode 100644 src/components/admin/central-codex-section.tsx
create mode 100644 src/components/settings/ai/central-codex-switch.tsx
diff --git a/e2e/central-codex.spec.ts b/e2e/central-codex.spec.ts
new file mode 100644
index 000000000..dec89459b
--- /dev/null
+++ b/e2e/central-codex.spec.ts
@@ -0,0 +1,197 @@
+import { expect, test } from "@playwright/test";
+
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+
+/**
+ * Operator-shared central Codex (ChatGPT subscription) — two mocked flows:
+ *
+ * A. The per-user opt-in switch on /settings/ai, shown only when the operator
+ * has connected the shared account (`centralCodexAvailable: true`). Toggle
+ * on → honesty confirm → PATCH /api/auth/me/use-central-codex.
+ * B. The admin connect surface on /admin/coach: device-code panel →
+ * connected state, driven by the three admin endpoints.
+ *
+ * The real OAuth handshake bounces through chatgpt.com, which CI never hits;
+ * every relevant endpoint is mocked so the assertions focus on the UI state.
+ */
+test.describe("central codex opt-in switch (mocked)", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test("switch shows when available and writes the opt-in on confirm", async ({
+ page,
+ }) => {
+ let useCentralCodex = false;
+
+ await page.route("**/api/insights/settings", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ codexStatus: "disconnected",
+ codexConnectedAt: null,
+ hasAdminKey: false,
+ codexOauthConfigured: true,
+ centralCodexAvailable: true,
+ useCentralCodex,
+ privacyMode: "aggregated",
+ lastInsightAt: null,
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ await page.route("**/api/user/ai-provider", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ provider: null,
+ model: null,
+ baseUrl: null,
+ hasAnthropicKey: false,
+ anthropicKeyPreview: null,
+ hasLocalKey: false,
+ hasOpenaiKey: false,
+ openaiKeyPreview: null,
+ responseTimeoutSeconds: null,
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ await page.route("**/api/insights/provider-chain", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ activeProvider: "codex",
+ cachedActiveProvider: null,
+ configuredChain: [{ providerType: "codex", available: true }],
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { documentsAutoAiRead: false },
+ error: null,
+ }),
+ }),
+ );
+
+ await page.route("**/api/consent/ai/web", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: { ok: true }, error: null }),
+ }),
+ );
+
+ // The opt-in write — flips the mocked state so a re-read reflects it.
+ await page.route("**/api/auth/me/use-central-codex", (route) => {
+ useCentralCodex = true;
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { useCentralCodex: true },
+ error: null,
+ }),
+ });
+ });
+
+ await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
+
+ const toggle = page.getByTestId("use-central-codex-enable");
+ await expect(toggle).toBeVisible({ timeout: 10_000 });
+
+ // Off → on reveals the honesty confirm; the write only happens on confirm.
+ await toggle.click();
+ const confirm = page.locator('[data-slot="central-codex-confirm-cta"]');
+ await expect(confirm).toBeVisible();
+
+ const wrote = page.waitForRequest(
+ (req) =>
+ req.url().includes("/api/auth/me/use-central-codex") &&
+ req.method() === "PATCH",
+ );
+ await confirm.click();
+ await wrote;
+ });
+});
+
+test.describe("admin central codex connect (mocked)", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test("connect button → device panel → connected badge", async ({ page }) => {
+ let connected = false;
+
+ await page.route("**/api/admin/central-codex", (route) => {
+ if (route.request().method() !== "GET") return route.continue();
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ status: connected ? "connected" : "disconnected",
+ connectedAt: connected ? new Date().toISOString() : null,
+ },
+ error: null,
+ }),
+ });
+ });
+
+ await page.route("**/api/admin/central-codex/device-start", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ userCode: "WXYZ-7890",
+ verificationUrl: "https://chatgpt.com/codex/device",
+ intervalSeconds: 1,
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ let pollCount = 0;
+ await page.route("**/api/admin/central-codex/device-poll", (route) => {
+ pollCount += 1;
+ if (pollCount >= 2) connected = true;
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { status: pollCount >= 2 ? "connected" : "pending" },
+ error: null,
+ }),
+ });
+ });
+
+ await page.goto("/admin/coach", { waitUntil: "domcontentloaded" });
+
+ const connect = page.locator('[data-slot="admin-central-codex-connect"]');
+ await expect(connect).toBeVisible({ timeout: 10_000 });
+ await connect.click();
+
+ // Device-code panel surfaces the user code.
+ await expect(page.getByText("WXYZ-7890")).toBeVisible();
+
+ // After the mocked poll flips to connected, the disconnect action shows.
+ await expect(
+ page.locator('[data-slot="admin-central-codex-disconnect"]'),
+ ).toBeVisible({ timeout: 15_000 });
+ });
+});
diff --git a/messages/de.json b/messages/de.json
index 32b001003..9721c7848 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4781,6 +4781,13 @@
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für die von dir hochgeladenen Dokumente akzeptierst.",
"confirm": "Ich verstehe – automatisches KI-Lesen aktivieren",
"cancel": "Abbrechen"
+ },
+ "centralCodex": {
+ "title": "Gemeinsamen KI-Zugang des Servers nutzen",
+ "subLabel": "Leite KI-Anfragen über die vom Betreiber für diesen Server eingerichtete Verbindung — zusätzlich zu deinen eigenen Anbietern als Rückfallebene.",
+ "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.",
+ "confirm": "Verstanden — gemeinsame Verbindung nutzen",
+ "cancel": "Abbrechen"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Standard-Speicherplatz pro Konto für Dokumente (0,1–1024 GB).",
"userDocumentQuota": "Abweichendes Dokumenten-Kontingent",
"userDocumentQuotaDefault": "Standard",
- "userDocumentQuotaHint": "Leer lassen, um den Instanz-Standard zu verwenden."
+ "userDocumentQuotaHint": "Leer lassen, um den Instanz-Standard zu verwenden.",
+ "centralCodex": {
+ "title": "Gemeinsame KI-Verbindung",
+ "description": "Verbinde ein angemeldetes ChatGPT-Konto, das jede Nutzerin und jeder Nutzer dieses Servers für KI-Funktionen aktivieren kann.",
+ "honesty": "Dies ist ein einzelnes gemeinsames, angemeldetes KI-Konto — kein API-Schlüssel. Alle, die zustimmen, senden Anfragen darüber, sodass die Stunden- und Wochenlimits des Kontos unter ihnen geteilt werden; dieselben Limits speisen bereits den Coach. Abo-Anbieter dürfen die Inhalte zur Verbesserung ihrer Modelle verwenden, und keine Auftragsverarbeitungsvereinbarung deckt dies ab — daher ist es nie Standard und steht beim Lesen von Dokumenten hinter Anbietern ohne Training. Verbinde nur ein Konto, das du auf diese Weise teilen möchtest.",
+ "statusConnected": "Verbunden",
+ "statusDisconnected": "Nicht verbunden",
+ "statusExpired": "Abgelaufen",
+ "connectButton": "ChatGPT-Konto verbinden",
+ "disconnectButton": "Trennen",
+ "connectedSince": "Verbunden seit {when}",
+ "connected": "Gemeinsame KI-Verbindung verknüpft",
+ "disconnected": "Gemeinsame KI-Verbindung entfernt",
+ "error": "Etwas ist schiefgelaufen — bitte erneut versuchen"
+ }
},
"achievements": {
"title": "Erfolge",
diff --git a/messages/en.json b/messages/en.json
index 04dea0d72..26fe1b008 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4781,6 +4781,13 @@
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for the documents you upload.",
"confirm": "I understand — turn on automatic AI reading",
"cancel": "Cancel"
+ },
+ "centralCodex": {
+ "title": "Use the server's shared AI access",
+ "subLabel": "Route AI requests through the connection the operator set up for this server, alongside your own providers as a fallback.",
+ "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.",
+ "confirm": "I understand — use the shared connection",
+ "cancel": "Cancel"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Default per-account document storage (0.1–1024 GB).",
"userDocumentQuota": "Document quota override",
"userDocumentQuotaDefault": "Default",
- "userDocumentQuotaHint": "Leave empty to use the instance default."
+ "userDocumentQuotaHint": "Leave empty to use the instance default.",
+ "centralCodex": {
+ "title": "Shared AI connection",
+ "description": "Connect one signed-in ChatGPT account that any user on this server can opt into for AI features.",
+ "honesty": "This is a single shared, signed-in AI account — not an API key. Every user who opts in sends requests through it, so the account's hourly and weekly limits are shared across all of them, and the same limits already feed the Coach. Subscription providers may use the content to improve their models and no data-processing agreement covers it, so it is never the default and stays below no-training providers for document reads. Only connect an account you are comfortable sharing this way.",
+ "statusConnected": "Connected",
+ "statusDisconnected": "Not connected",
+ "statusExpired": "Expired",
+ "connectButton": "Connect a ChatGPT account",
+ "disconnectButton": "Disconnect",
+ "connectedSince": "Connected since {when}",
+ "connected": "Shared AI connection linked",
+ "disconnected": "Shared AI connection removed",
+ "error": "Something went wrong — please try again"
+ }
},
"achievements": {
"title": "Achievements",
diff --git a/messages/es.json b/messages/es.json
index da4b1fd5a..d01179e8c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4781,6 +4781,13 @@
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para los documentos que subes.",
"confirm": "Lo entiendo: activar la lectura automática con IA",
"cancel": "Cancelar"
+ },
+ "centralCodex": {
+ "title": "Usar el acceso de IA compartido del servidor",
+ "subLabel": "Enruta las solicitudes de IA a través de la conexión que el operador configuró para este servidor, junto a tus propios proveedores como alternativa.",
+ "honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.",
+ "confirm": "Entendido: usar la conexión compartida",
+ "cancel": "Cancelar"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Almacenamiento de documentos por cuenta por defecto (0,1–1024 GB).",
"userDocumentQuota": "Cuota de documentos personalizada",
"userDocumentQuotaDefault": "Predeterminado",
- "userDocumentQuotaHint": "Déjalo vacío para usar el valor predeterminado de la instancia."
+ "userDocumentQuotaHint": "Déjalo vacío para usar el valor predeterminado de la instancia.",
+ "centralCodex": {
+ "title": "Conexión de IA compartida",
+ "description": "Conecta una cuenta de ChatGPT con sesión iniciada que cualquier usuario de este servidor pueda activar para las funciones de IA.",
+ "honesty": "Es una única cuenta de IA compartida con sesión iniciada, no una clave de API. Todos los que se unen envían solicitudes a través de ella, por lo que los límites por hora y semanales de la cuenta se comparten entre ellos, y esos mismos límites ya alimentan al Coach. Los proveedores de suscripción pueden usar el contenido para mejorar sus modelos y ningún acuerdo de tratamiento de datos lo cubre, por lo que nunca es la opción predeterminada y queda por debajo de los proveedores sin entrenamiento al leer documentos. Conecta solo una cuenta que te sientas cómodo compartiendo así.",
+ "statusConnected": "Conectado",
+ "statusDisconnected": "No conectado",
+ "statusExpired": "Caducado",
+ "connectButton": "Conectar una cuenta de ChatGPT",
+ "disconnectButton": "Desconectar",
+ "connectedSince": "Conectado desde {when}",
+ "connected": "Conexión de IA compartida vinculada",
+ "disconnected": "Conexión de IA compartida eliminada",
+ "error": "Algo salió mal: inténtalo de nuevo"
+ }
},
"achievements": {
"title": "Logros",
diff --git a/messages/fr.json b/messages/fr.json
index 545782c31..6e591f39c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4781,6 +4781,13 @@
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour les documents que vous téléversez.",
"confirm": "Je comprends — activer la lecture automatique par l'IA",
"cancel": "Annuler"
+ },
+ "centralCodex": {
+ "title": "Utiliser l'accès IA partagé du serveur",
+ "subLabel": "Acheminez les requêtes IA via la connexion que l'opérateur a configurée pour ce serveur, en complément de vos propres fournisseurs comme solution de repli.",
+ "honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.",
+ "confirm": "J'ai compris — utiliser la connexion partagée",
+ "cancel": "Annuler"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Stockage de documents par compte par défaut (0,1–1024 Go).",
"userDocumentQuota": "Quota de documents personnalisé",
"userDocumentQuotaDefault": "Par défaut",
- "userDocumentQuotaHint": "Laisser vide pour utiliser la valeur par défaut de l'instance."
+ "userDocumentQuotaHint": "Laisser vide pour utiliser la valeur par défaut de l'instance.",
+ "centralCodex": {
+ "title": "Connexion IA partagée",
+ "description": "Reliez un compte ChatGPT connecté que chaque utilisateur de ce serveur peut activer pour les fonctions IA.",
+ "honesty": "Il s'agit d'un unique compte IA partagé et connecté, pas d'une clé d'API. Chaque personne qui l'active envoie ses requêtes par ce biais : les limites horaires et hebdomadaires du compte sont donc partagées entre elles, et ces mêmes limites alimentent déjà le Coach. Les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles et aucun accord de traitement des données ne le couvre — ce n'est donc jamais l'option par défaut et cela reste après les fournisseurs sans entraînement pour la lecture de documents. Ne reliez qu'un compte que vous êtes prêt à partager ainsi.",
+ "statusConnected": "Connecté",
+ "statusDisconnected": "Non connecté",
+ "statusExpired": "Expiré",
+ "connectButton": "Relier un compte ChatGPT",
+ "disconnectButton": "Déconnecter",
+ "connectedSince": "Connecté depuis le {when}",
+ "connected": "Connexion IA partagée reliée",
+ "disconnected": "Connexion IA partagée supprimée",
+ "error": "Une erreur s'est produite — veuillez réessayer"
+ }
},
"achievements": {
"title": "Réussites",
diff --git a/messages/it.json b/messages/it.json
index 8e5194643..a97fdc16e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4781,6 +4781,13 @@
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i documenti che carichi.",
"confirm": "Ho capito: attiva la lettura automatica con l'IA",
"cancel": "Annulla"
+ },
+ "centralCodex": {
+ "title": "Usa l'accesso IA condiviso del server",
+ "subLabel": "Instrada le richieste IA attraverso la connessione che l'operatore ha configurato per questo server, insieme ai tuoi provider come ripiego.",
+ "honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.",
+ "confirm": "Ho capito — usa la connessione condivisa",
+ "cancel": "Annulla"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Spazio documenti predefinito per account (0,1–1024 GB).",
"userDocumentQuota": "Quota documenti personalizzata",
"userDocumentQuotaDefault": "Predefinito",
- "userDocumentQuotaHint": "Lascia vuoto per usare il valore predefinito dell'istanza."
+ "userDocumentQuotaHint": "Lascia vuoto per usare il valore predefinito dell'istanza.",
+ "centralCodex": {
+ "title": "Connessione IA condivisa",
+ "description": "Collega un account ChatGPT con accesso eseguito che qualsiasi utente di questo server può attivare per le funzioni IA.",
+ "honesty": "È un unico account IA condiviso con accesso eseguito, non una chiave API. Tutti coloro che aderiscono inviano richieste attraverso di esso, quindi i limiti orari e settimanali dell'account sono condivisi tra loro, e gli stessi limiti alimentano già il Coach. I provider in abbonamento possono usare i contenuti per migliorare i loro modelli e nessun accordo sul trattamento dei dati lo copre, perciò non è mai l'opzione predefinita e resta dopo i provider senza addestramento nella lettura dei documenti. Collega solo un account che sei disposto a condividere in questo modo.",
+ "statusConnected": "Connesso",
+ "statusDisconnected": "Non connesso",
+ "statusExpired": "Scaduto",
+ "connectButton": "Collega un account ChatGPT",
+ "disconnectButton": "Disconnetti",
+ "connectedSince": "Connesso dal {when}",
+ "connected": "Connessione IA condivisa collegata",
+ "disconnected": "Connessione IA condivisa rimossa",
+ "error": "Qualcosa è andato storto — riprova"
+ }
},
"achievements": {
"title": "Obiettivi",
diff --git a/messages/pl.json b/messages/pl.json
index 26917e36e..b4840cc70 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4781,6 +4781,13 @@
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla przesyłanych dokumentów.",
"confirm": "Rozumiem — włącz automatyczne czytanie przez AI",
"cancel": "Anuluj"
+ },
+ "centralCodex": {
+ "title": "Użyj wspólnego dostępu AI serwera",
+ "subLabel": "Kieruj zapytania AI przez połączenie skonfigurowane przez operatora dla tego serwera, obok Twoich własnych dostawców jako rozwiązanie awaryjne.",
+ "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.",
+ "confirm": "Rozumiem — użyj wspólnego połączenia",
+ "cancel": "Anuluj"
}
},
"dashboard": {
@@ -6053,7 +6060,21 @@
"documentQuotaDescription": "Domyślne miejsce na dokumenty na konto (0,1–1024 GB).",
"userDocumentQuota": "Indywidualny limit dokumentów",
"userDocumentQuotaDefault": "Domyślny",
- "userDocumentQuotaHint": "Pozostaw puste, aby użyć domyślnego limitu instancji."
+ "userDocumentQuotaHint": "Pozostaw puste, aby użyć domyślnego limitu instancji.",
+ "centralCodex": {
+ "title": "Wspólne połączenie AI",
+ "description": "Podłącz jedno zalogowane konto ChatGPT, które każdy użytkownik tego serwera może włączyć dla funkcji AI.",
+ "honesty": "To jedno wspólne, zalogowane konto AI — nie klucz API. Każdy, kto je włączy, wysyła przez nie zapytania, więc godzinowe i tygodniowe limity konta są dzielone między nimi, a te same limity zasilają już Coacha. Dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych, dlatego nigdy nie jest to opcja domyślna i pozostaje poniżej dostawców bez trenowania przy odczycie dokumentów. Podłącz tylko konto, które chcesz udostępniać w ten sposób.",
+ "statusConnected": "Połączono",
+ "statusDisconnected": "Nie połączono",
+ "statusExpired": "Wygasło",
+ "connectButton": "Podłącz konto ChatGPT",
+ "disconnectButton": "Rozłącz",
+ "connectedSince": "Połączono od {when}",
+ "connected": "Wspólne połączenie AI podłączone",
+ "disconnected": "Wspólne połączenie AI usunięte",
+ "error": "Coś poszło nie tak — spróbuj ponownie"
+ }
},
"achievements": {
"title": "Osiągnięcia",
diff --git a/prisma/migrations/0235_admin_central_codex/migration.sql b/prisma/migrations/0235_admin_central_codex/migration.sql
new file mode 100644
index 000000000..f78dca5b6
--- /dev/null
+++ b/prisma/migrations/0235_admin_central_codex/migration.sql
@@ -0,0 +1,28 @@
+-- Operator-shared central Codex (ChatGPT subscription) OAuth + per-user opt-in.
+--
+-- The only operator-level AI credential so far has been an API key
+-- (`app_settings.admin_ai_key_encrypted`). This adds an operator-level Codex
+-- OAuth credential: the operator connects ONE signed-in ChatGPT account (the
+-- same device-code flow the per-user `codex_*_encrypted` columns use) and shares
+-- it server-wide. Each user opts in individually via `users.use_central_codex`.
+--
+-- All columns are additive and defaulted, so existing rows keep the current
+-- behaviour (no central Codex, no user opted in) with no backfill. The three
+-- token pieces are AES-256-GCM encrypted at rest, same crypto as every other
+-- `*_encrypted` column, so the key-rotation script covers them.
+--
+-- Routing through the central Codex is external egress on a shared,
+-- train-by-default consumer account bound by the operator's own rate limits.
+-- It is consent-gated (server-managed provider) and billed against the operator
+-- cost cap, never the user plan. OFF until the operator connects it.
+
+ALTER TABLE "app_settings"
+ ADD COLUMN "admin_codex_access_token_encrypted" TEXT,
+ ADD COLUMN "admin_codex_refresh_token_encrypted" TEXT,
+ ADD COLUMN "admin_codex_account_id_encrypted" TEXT,
+ ADD COLUMN "admin_codex_token_expires_at" TIMESTAMP(3),
+ ADD COLUMN "admin_codex_connected_at" TIMESTAMP(3),
+ ADD COLUMN "admin_codex_connection_status" TEXT NOT NULL DEFAULT 'disconnected';
+
+ALTER TABLE "users"
+ ADD COLUMN "use_central_codex" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d3a51cf52..7ecfb1fca 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -76,6 +76,13 @@ model User {
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
+ // Per-user opt-in to the operator's shared central Codex (ChatGPT
+ // subscription) connection stored on `AppSettings.adminCodex*`. OFF by
+ // default; only takes effect when the operator has actually connected the
+ // central Codex. Routing through it is external egress on a shared,
+ // train-by-default account bound by the operator's rate limits — consent-gated
+ // exactly like any other server-managed egress.
+ useCentralCodex Boolean @default(false) @map("use_central_codex")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
@@ -3782,6 +3789,21 @@ model AppSettings {
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
+ // Operator-shared central Codex (ChatGPT subscription) OAuth credential. The
+ // operator connects ONE signed-in ChatGPT account here (device-code flow, same
+ // mechanism as the per-user `codex*Encrypted` columns) and any user who flips
+ // the per-user `useCentralCodex` opt-in routes their AI egress through it. The
+ // three token pieces (access, refresh, and the mandatory `ChatGPT-Account-ID`
+ // claim) are each AES-256-GCM encrypted at rest. OFF until the operator
+ // connects it; the shared account's rate limits + training-by-default caveat
+ // apply to every opted-in user (see the admin connect UI's honesty copy).
+ adminCodexAccessTokenEncrypted String? @map("admin_codex_access_token_encrypted")
+ adminCodexRefreshTokenEncrypted String? @map("admin_codex_refresh_token_encrypted")
+ adminCodexAccountIdEncrypted String? @map("admin_codex_account_id_encrypted")
+ adminCodexTokenExpiresAt DateTime? @map("admin_codex_token_expires_at")
+ adminCodexConnectedAt DateTime? @map("admin_codex_connected_at")
+ adminCodexConnectionStatus String @default("disconnected") @map("admin_codex_connection_status")
+
// v1.4.16 phase B5e — daily-aggregated rec-feedback summary written
// by `feedback-aggregator.ts`. Shape:
// {
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 8d190ad96..3c0a29d76 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -246,9 +246,14 @@ async function main() {
// ───── AppSettings — operator credentials (String) ─────
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
+ // "adminCodexAccessTokenEncrypted" "adminCodexRefreshTokenEncrypted"
+ // "adminCodexAccountIdEncrypted"
for (const field of [
"adminAiKeyEncrypted",
"webPushVapidPrivateKeyEncrypted",
+ "adminCodexAccessTokenEncrypted",
+ "adminCodexRefreshTokenEncrypted",
+ "adminCodexAccountIdEncrypted",
]) {
results.push(
await rotateStringColumn("AppSettings", field, prisma.appSettings),
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index dcac2cc13..9829379ff 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -19,6 +19,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { AboutSection } from "@/components/settings/about-section";
import { AiQualitySection } from "@/components/admin/ai-quality-section";
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
+import { CentralCodexSection } from "@/components/admin/central-codex-section";
import { AssistantSection } from "@/components/admin/assistant-section";
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
@@ -104,6 +105,7 @@ export function AdminSectionRenderer({
>
+
diff --git a/src/app/api/admin/central-codex/device-poll/route.ts b/src/app/api/admin/central-codex/device-poll/route.ts
new file mode 100644
index 000000000..5f8fecc65
--- /dev/null
+++ b/src/app/api/admin/central-codex/device-poll/route.ts
@@ -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" });
+});
diff --git a/src/app/api/admin/central-codex/device-start/route.ts b/src/app/api/admin/central-codex/device-start/route.ts
new file mode 100644
index 000000000..fc8a7aebf
--- /dev/null
+++ b/src/app/api/admin/central-codex/device-start/route.ts
@@ -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,
+ });
+});
diff --git a/src/app/api/admin/central-codex/route.ts b/src/app/api/admin/central-codex/route.ts
new file mode 100644
index 000000000..fc766d0c2
--- /dev/null
+++ b/src/app/api/admin/central-codex/route.ts
@@ -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 });
+});
diff --git a/src/app/api/auth/me/use-central-codex/route.ts b/src/app/api/auth/me/use-central-codex/route.ts
new file mode 100644
index 000000000..e07ab0603
--- /dev/null
+++ b/src/app/api/auth/me/use-central-codex/route.ts
@@ -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);
+});
diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts
index 6fb8d79f6..b7c8eb9cd 100644
--- a/src/app/api/insights/settings/route.ts
+++ b/src/app/api/insights/settings/route.ts
@@ -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,
});
diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx
new file mode 100644
index 000000000..6e357267a
--- /dev/null
+++ b/src/components/admin/central-codex-section.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+/**
+ * Operator panel for the shared central Codex (ChatGPT subscription) account.
+ *
+ * The operator connects ONE signed-in ChatGPT account here (device-code flow,
+ * cookie-only `requireAdmin` on every backing route) and any user can opt into
+ * it from their own AI settings. This is the ONE admin surface where the vendor
+ * is named, consistent with the per-user Codex connect form — the operator is
+ * knowingly linking a named account.
+ *
+ * The copy is loud and honest: it is a shared, signed-in account (not an API
+ * key), subscription providers may use content to improve their models, the
+ * account's 5-hour / weekly rate limits are shared across every opted-in user,
+ * and it is never a default. OFF until the operator connects it.
+ */
+
+import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+interface CentralCodexStatus {
+ status: string;
+ connectedAt: string | null;
+}
+
+export function CentralCodexSection() {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const { data } = useQuery({
+ queryKey: queryKeys.adminCentralCodex(),
+ queryFn: () => apiGet("/api/admin/central-codex"),
+ });
+
+ const [deviceCode, setDeviceCode] = useState<{
+ userCode: string;
+ verificationUrl: string;
+ intervalSeconds: number;
+ } | null>(null);
+ const [devicePolling, setDevicePolling] = useState(false);
+ const [disconnecting, setDisconnecting] = useState(false);
+
+ const status = data?.status ?? "disconnected";
+ const isConnected = status === "connected";
+
+ async function handleConnect() {
+ setDevicePolling(true);
+ try {
+ const res = await apiFetchRaw("/api/admin/central-codex/device-start", {
+ method: "POST",
+ });
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error"));
+ setDeviceCode({
+ userCode: json.data.userCode,
+ verificationUrl: json.data.verificationUrl,
+ intervalSeconds: json.data.intervalSeconds,
+ });
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : t("admin.centralCodex.error"),
+ );
+ setDevicePolling(false);
+ }
+ }
+
+ useEffect(() => {
+ if (!deviceCode) return;
+ let cancelled = false;
+ const intervalMs = Math.max(deviceCode.intervalSeconds, 3) * 1000;
+
+ async function tick() {
+ try {
+ const res = await apiFetchRaw("/api/admin/central-codex/device-poll", {
+ method: "POST",
+ });
+ const json = await res.json();
+ if (cancelled) return;
+ if (!res.ok) {
+ throw new Error(json.error || t("admin.centralCodex.error"));
+ }
+ if (json.data?.status === "connected") {
+ setDeviceCode(null);
+ setDevicePolling(false);
+ toast.success(t("admin.centralCodex.connected"));
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.adminCentralCodex(),
+ });
+ return;
+ }
+ if (!cancelled) setTimeout(tick, intervalMs);
+ } catch (err) {
+ if (cancelled) return;
+ toast.error(
+ err instanceof Error ? err.message : t("admin.centralCodex.error"),
+ );
+ setDeviceCode(null);
+ setDevicePolling(false);
+ }
+ }
+
+ const handle = setTimeout(tick, intervalMs);
+ return () => {
+ cancelled = true;
+ clearTimeout(handle);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [deviceCode]);
+
+ async function handleDisconnect() {
+ setDisconnecting(true);
+ try {
+ const res = await apiFetchRaw("/api/admin/central-codex", {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const json = await res.json();
+ throw new Error(json.error || t("admin.centralCodex.error"));
+ }
+ toast.success(t("admin.centralCodex.disconnected"));
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.adminCentralCodex(),
+ });
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : t("admin.centralCodex.error"),
+ );
+ } finally {
+ setDisconnecting(false);
+ }
+ }
+
+ return (
+
+ {t("admin.centralCodex.description")}