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")}

} + status={ + + {isConnected + ? t("admin.centralCodex.statusConnected") + : status === "expired" + ? t("admin.centralCodex.statusExpired") + : t("admin.centralCodex.statusDisconnected")} + + } + /> + +
+
+ +

{t("admin.centralCodex.honesty")}

+
+ + {data?.connectedAt && isConnected && ( +

+ {t("admin.centralCodex.connectedSince", { + when: formatDateTime(data.connectedAt), + })} +

+ )} + + {isConnected ? ( + + ) : deviceCode ? ( +
+

+ {t("settings.ai.deviceCodeHeading")} +

+
    +
  1. + {t("settings.ai.deviceCodeStep1")}{" "} + + {deviceCode.verificationUrl} + +
  2. +
  3. + {t("settings.ai.deviceCodeStep2")} +
    + {deviceCode.userCode} + +
    +
  4. +
  5. {t("settings.ai.deviceCodeStep3")}
  6. +
+
+ {devicePolling && ( + + + {t("settings.ai.deviceCodeWaiting")} + + )} + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 73284fbdd..ff7efb9b6 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -32,6 +32,7 @@ import { queryKeys } from "@/lib/query-keys"; import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; import { AnthropicProviderForm } from "./anthropic-provider-form"; import { AutoReadCard } from "./auto-read-card"; +import { CentralCodexSwitch } from "./central-codex-switch"; import { CodexProviderForm } from "./codex-provider-form"; import { FallbackChainCard } from "./fallback-chain-card"; import { LocalProviderForm } from "./local-provider-form"; @@ -181,6 +182,10 @@ export function AiInsightsCard({ {/* Read uploaded vault documents automatically with AI (opt-in). */} + {/* Use the operator's shared central Codex connection (opt-in; only + renders when the operator has connected it). */} + + ({ + mutationFn: (next: boolean) => + apiPatch("/api/auth/me/use-central-codex", { + useCentralCodex: next, + }), + onSuccess: () => { + // The opt-in changes which providers can serve this user — refresh the + // settings summary (which carries the flag) and every insight read. + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsSettings(), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + setPendingEnable(false); + }, + }); + + // Only meaningful once the operator has connected the shared account. + if (!settings?.centralCodexAvailable) return null; + + function onSwitch(next: boolean) { + if (next) { + if (!enabled) setPendingEnable(true); + return; + } + setPendingEnable(false); + if (enabled) save.mutate(false); + } + + const busy = save.isPending; + const checked = enabled || pendingEnable; + + return ( +
+
+
+

+ {t("settings.ai.centralCodex.title")} +

+

+ {t("settings.ai.centralCodex.subLabel")} +

+
+ +
+ + {pendingEnable ? ( +
+
+ +

{t("settings.ai.centralCodex.honesty")}

+
+
+ + +
+
+ ) : null} + + {save.isError ? ( +

+ {t("settings.ai.errorGeneric")} +

+ ) : null} +
+ ); +} diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index 224cbbe96..caf089a10 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -14,6 +14,11 @@ export interface InsightsSettings { * false to avoid the v1.4.2 dead-end where the click bounced the * user to chatgpt.com without any OAuth flow. */ codexOauthConfigured?: boolean; + /** True when the operator has connected the shared central Codex. Drives + * whether the per-user "use the server's shared AI access" switch shows. */ + centralCodexAvailable?: boolean; + /** The user's own opt-in to the operator's shared central Codex. */ + useCentralCodex?: boolean; privacyMode: string; lastInsightAt: string | null; } diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index cabf7f6f1..a7e5a2e0f 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -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); } }); diff --git a/src/lib/ai/__tests__/provider-chain.test.ts b/src/lib/ai/__tests__/provider-chain.test.ts index 39804bfcf..7097ab3c9 100644 --- a/src/lib/ai/__tests__/provider-chain.test.ts +++ b/src/lib/ai/__tests__/provider-chain.test.ts @@ -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 }, diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 982410f8a..c540eabf4 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -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); }); diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index b07cc00ce..63ec53a3f 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -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); }); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 0fc0a3704..875c169f6 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -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; } diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index a8e609203..6d6e78ab2 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -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; + } +} diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index e6960c3aa..213c5a37c 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -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 = new Set([ "admin-openai", + "admin-codex", ]); /** diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index fa02584cb..977646984 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -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]; diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 5ddd2a93e..4b4552a73 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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 { + 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; } diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 32edfa108..673e90c64 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -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: diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index c05ecd21c..7e0015397 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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" }, diff --git a/src/lib/documents/__tests__/provider-order.test.ts b/src/lib/documents/__tests__/provider-order.test.ts index e8e65f806..29adca1bf 100644 --- a/src/lib/documents/__tests__/provider-order.test.ts +++ b/src/lib/documents/__tests__/provider-order.test.ts @@ -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"); }); }); diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts index 5107dcef3..8d036425b 100644 --- a/src/lib/documents/provider-order.ts +++ b/src/lib/documents/provider-order.ts @@ -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; diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index 425023a76..c0a22652e 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -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; } diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index d1caeac07..5ac3496c6 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -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,