mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the operator connects once and shares server-wide, plus a per-user switch to route AI egress through it. - Schema (migration 0235): three encrypted columns on AppSettings for the operator's central Codex access/refresh tokens and the ChatGPT-Account-ID claim, plus expiry/connected-at/status; a per-user useCentralCodex flag (default false). Register the new encrypted columns in the rotation registry and script. - Resolution: add the admin-codex chain type; append it to a user's resolved chain only when they opted in AND the operator connected it. Tokens refresh server-side against the singleton row (CAS-style), same primitives as the per-user Codex path. - Budget: bill an admin-codex primary against the operator cost cap, not the user plan — it drains the operator's account. - Consent: admin-codex is server-managed external egress, gated by the existing consent machinery (self-snapshot and document surfaces alike). No existing consent path is weakened. - Document order: rank admin-codex with the subscription tier (last), so the document class stays local-first. - UI: admin device-code connect/disconnect card (cookie-only admin routes) with honest copy on account pooling, shared rate limits, and training-by-default; a per-user opt-in switch shown only once the operator has connected it, off by default, behind a one-time honesty confirm. - i18n across all six locales; unit tests for the chain type, budget cap, consent gate, vision capability, and document order; a mocked e2e for both the admin connect surface and the per-user switch. The central credential's tokens are encrypted at rest and never returned to a non-admin API; the opt-in only takes effect once the operator has connected the shared account.
This commit is contained in:
1 parent
fa42c8bc0b
commit
d7e616eaa8
35 files changed
+1553
-26
No files matched your search
@@ -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({
|
||||
>
|
||||
<AssistantSection />
|
||||
<AiServerKeySection />
|
||||
<CentralCodexSection />
|
||||
<CoachFeedbackSection />
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { pollDeviceCode, encryptAdminCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex — device-code poll (admin only).
|
||||
*
|
||||
* Cookie-only `requireAdmin()`. The client polls this every `intervalSeconds`;
|
||||
* on the operator finishing the approval on chatgpt.com we exchange the code,
|
||||
* then persist the three token pieces (access + refresh + `ChatGPT-Account-ID`),
|
||||
* each AES-256-GCM encrypted, on the `AppSettings` singleton. The device cookie
|
||||
* is deleted on success. Tokens are never logged and never leave the server.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`central-codex-device-poll:${user.id}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get("central_codex_device")?.value;
|
||||
if (!cookie) {
|
||||
return apiError("No device-auth attempt in progress", 400);
|
||||
}
|
||||
|
||||
let deviceAuthId: string;
|
||||
let userCode: string;
|
||||
try {
|
||||
const decoded = JSON.parse(decrypt(cookie)) as {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
};
|
||||
deviceAuthId = decoded.deviceAuthId;
|
||||
userCode = decoded.userCode;
|
||||
} catch {
|
||||
cookieStore.delete("central_codex_device");
|
||||
return apiError("Invalid device-auth state — restart the flow", 400);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await pollDeviceCode({ deviceAuthId, userCode });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
annotate({ meta: { central_codex_device_poll_error: message } });
|
||||
return apiError(
|
||||
"Central Codex device-poll failed — please restart the flow",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "pending") {
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.poll" },
|
||||
meta: { pending: true },
|
||||
});
|
||||
return apiSuccess({ status: "pending" });
|
||||
}
|
||||
|
||||
const enc = encryptAdminCodexCreds(result.creds);
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
adminCodexConnectedAt: new Date(),
|
||||
adminCodexConnectionStatus: "connected",
|
||||
},
|
||||
create: {
|
||||
id: "singleton",
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
adminCodexConnectedAt: new Date(),
|
||||
adminCodexConnectionStatus: "connected",
|
||||
},
|
||||
});
|
||||
|
||||
cookieStore.delete("central_codex_device");
|
||||
|
||||
await auditLog("admin.central-codex.connected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: "device_code" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.poll" },
|
||||
meta: { connected: true },
|
||||
});
|
||||
|
||||
return apiSuccess({ status: "connected" });
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requestDeviceCode } from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex — device-code flow start (admin only).
|
||||
*
|
||||
* Mirrors the per-user `auth/codex/device-start`, but cookie-only
|
||||
* `requireAdmin()` gates it and the device-auth cookie is namespaced
|
||||
* (`central_codex_device`) so it never collides with an operator's own per-user
|
||||
* Codex connect flow. The device secret is persisted in an encrypted httpOnly
|
||||
* cookie for the 15-minute window Hydra allows.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`central-codex-device-start:${user.id}`,
|
||||
5,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const code = await requestDeviceCode();
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(
|
||||
"central_codex_device",
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
deviceAuthId: code.deviceAuthId,
|
||||
userCode: code.userCode,
|
||||
}),
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 15 * 60,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.start" },
|
||||
meta: { interval_seconds: code.intervalSeconds },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
userCode: code.userCode,
|
||||
verificationUrl: code.verificationUrl,
|
||||
intervalSeconds: code.intervalSeconds,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex (ChatGPT subscription) — status + disconnect.
|
||||
*
|
||||
* GET /api/admin/central-codex — connection status + connected-at.
|
||||
* DELETE /api/admin/central-codex — clear the shared credential.
|
||||
*
|
||||
* Cookie-only `requireAdmin()`: a Bearer token, even with `["*"]` scope, can
|
||||
* never reach this. The encrypted token columns are NEVER returned — only the
|
||||
* status string and the connected-at timestamp leave the server.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.central-codex.get" } });
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexConnectedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
status: settings?.adminCodexConnectionStatus ?? "disconnected",
|
||||
connectedAt: settings?.adminCodexConnectedAt ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit("admin-central-codex-disconnect", 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
adminCodexAccessTokenEncrypted: null,
|
||||
adminCodexRefreshTokenEncrypted: null,
|
||||
adminCodexAccountIdEncrypted: null,
|
||||
adminCodexTokenExpiresAt: null,
|
||||
adminCodexConnectedAt: null,
|
||||
adminCodexConnectionStatus: "disconnected",
|
||||
},
|
||||
create: { id: "singleton", adminCodexConnectionStatus: "disconnected" },
|
||||
});
|
||||
|
||||
await auditLog("admin.central-codex.disconnected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "admin.central-codex.disconnect" } });
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Per-user opt-in to the operator's shared central Codex (ChatGPT subscription).
|
||||
*
|
||||
* GET /api/auth/me/use-central-codex — current flag.
|
||||
* PATCH /api/auth/me/use-central-codex — body `{ useCentralCodex: boolean }`.
|
||||
*
|
||||
* OFF by default. When `true`, and the operator has connected the central Codex,
|
||||
* the user's resolved provider chain gains a trailing `admin-codex` entry — an
|
||||
* external, shared, train-by-default subscription account bound by the
|
||||
* operator's rate limits. Using it is external egress: the existing consent gate
|
||||
* (`admin-codex` is server-managed) still requires an active AI consent receipt
|
||||
* before any PHI leaves for it, so flipping this ON never egresses anything on
|
||||
* its own.
|
||||
*
|
||||
* Mirrors `auth/me/labs-local-ocr`: 60/min rate limit, Zod `safeParse` → 422 via
|
||||
* `returnAllZodIssues`, audit-log row, field-by-field write (no mass
|
||||
* assignment). Idempotent — always returns the resolved next state.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
useCentralCodex: z.boolean(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
type UseCentralCodexResponse = {
|
||||
useCentralCodex: boolean;
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.useCentralCodex.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { useCentralCodex: true },
|
||||
});
|
||||
const payload: UseCentralCodexResponse = {
|
||||
useCentralCodex: row?.useCentralCodex ?? false,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`use-central-codex:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = parsed.data.useCentralCodex;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { useCentralCodex: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { useCentralCodex: next },
|
||||
});
|
||||
|
||||
await auditLog("user.useCentralCodex.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous?.useCentralCodex ?? false,
|
||||
next,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.useCentralCodex.patch" },
|
||||
meta: { useCentralCodex: next },
|
||||
});
|
||||
|
||||
const payload: UseCentralCodexResponse = { useCentralCodex: next };
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<CentralCodexStatus>("/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 (
|
||||
<SettingsCard data-slot="admin-central-codex">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.centralCodex.title")}
|
||||
description={<p>{t("admin.centralCodex.description")}</p>}
|
||||
status={
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className={
|
||||
status === "expired"
|
||||
? "border-warning/30 bg-warning/15 text-warning"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isConnected
|
||||
? t("admin.centralCodex.statusConnected")
|
||||
: status === "expired"
|
||||
? t("admin.centralCodex.statusExpired")
|
||||
: t("admin.centralCodex.statusDisconnected")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
|
||||
</div>
|
||||
|
||||
{data?.connectedAt && isConnected && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.centralCodex.connectedSince", {
|
||||
when: formatDateTime(data.connectedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
data-slot="admin-central-codex-disconnect"
|
||||
>
|
||||
{disconnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("admin.centralCodex.disconnectButton")}
|
||||
</Button>
|
||||
) : deviceCode ? (
|
||||
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.deviceCodeHeading")}
|
||||
</p>
|
||||
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
|
||||
<li>
|
||||
{t("settings.ai.deviceCodeStep1")}{" "}
|
||||
<a
|
||||
href={deviceCode.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary font-medium underline"
|
||||
>
|
||||
{deviceCode.verificationUrl}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{t("settings.ai.deviceCodeStep2")}
|
||||
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
|
||||
{deviceCode.userCode}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(deviceCode.userCode)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline"
|
||||
>
|
||||
{t("settings.ai.deviceCodeCopy")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li>{t("settings.ai.deviceCodeStep3")}</li>
|
||||
</ol>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{devicePolling && (
|
||||
<span className="text-muted-foreground inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
||||
{t("settings.ai.deviceCodeWaiting")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
{t("settings.ai.deviceCodeCancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
disabled={devicePolling}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
data-slot="admin-central-codex-connect"
|
||||
>
|
||||
{devicePolling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{t("admin.centralCodex.connectButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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). */}
|
||||
<AutoReadCard />
|
||||
|
||||
{/* Use the operator's shared central Codex connection (opt-in; only
|
||||
renders when the operator has connected it). */}
|
||||
<CentralCodexSwitch settings={insightsSettings} />
|
||||
|
||||
<RuntimeActionsRow
|
||||
provider={selectedProvider}
|
||||
userProvider={userProvider}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Use the server's shared AI access (per-user opt-in).
|
||||
*
|
||||
* Shown ONLY when the operator has connected a shared central Codex
|
||||
* (ChatGPT-subscription) account. OFF by default. Turning it ON reveals a
|
||||
* once-shown honesty confirm before the write, because the trade — a shared,
|
||||
* signed-in AI account bound by the operator's rate limits, on which the
|
||||
* provider may use content to improve its models with no data-processing
|
||||
* agreement — must be acknowledged, not buried. Turning it OFF is immediate.
|
||||
*
|
||||
* Opting in does not egress anything on its own: the shared connection is
|
||||
* server-managed external egress, so the existing AI-consent gate still applies
|
||||
* before any health data leaves for it.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { InsightsSettings } from "./shared";
|
||||
|
||||
interface UseCentralCodexPref {
|
||||
useCentralCodex: boolean;
|
||||
}
|
||||
|
||||
export function CentralCodexSwitch({
|
||||
settings,
|
||||
}: {
|
||||
settings: InsightsSettings | null | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Off→on reveals the honesty confirm; the write only happens on confirm.
|
||||
const [pendingEnable, setPendingEnable] = useState(false);
|
||||
|
||||
const enabled = settings?.useCentralCodex ?? false;
|
||||
|
||||
const save = useMutation<UseCentralCodexPref, Error, boolean>({
|
||||
mutationFn: (next: boolean) =>
|
||||
apiPatch<UseCentralCodexPref>("/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 (
|
||||
<div
|
||||
data-slot="central-codex-switch-card"
|
||||
className="bg-muted/50 space-y-4 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.centralCodex.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.centralCodex.subLabel")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={busy}
|
||||
onCheckedChange={onSwitch}
|
||||
aria-label={t("settings.ai.centralCodex.title")}
|
||||
data-testid="use-central-codex-enable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pendingEnable ? (
|
||||
<div
|
||||
data-slot="central-codex-confirm"
|
||||
role="note"
|
||||
className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("settings.ai.centralCodex.honesty")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(true)}
|
||||
data-slot="central-codex-confirm-cta"
|
||||
>
|
||||
{t("settings.ai.centralCodex.confirm")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={save.isPending}
|
||||
onClick={() => setPendingEnable(false)}
|
||||
>
|
||||
{t("settings.ai.centralCodex.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{save.isError ? (
|
||||
<p className="text-destructive text-xs">
|
||||
{t("settings.ai.errorGeneric")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,15 @@ describe("chainRequiresServerManagedConsent", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for the operator-shared central Codex (admin-codex)", () => {
|
||||
expect(chainRequiresServerManagedConsent([entry("admin-codex")])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
chainRequiresServerManagedConsent([entry("codex"), entry("admin-codex")]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for pure BYOK / local / codex chains", () => {
|
||||
expect(chainRequiresServerManagedConsent([entry("openai")])).toBe(false);
|
||||
expect(chainRequiresServerManagedConsent([entry("anthropic")])).toBe(false);
|
||||
@@ -182,7 +191,13 @@ describe("isExternalDocumentEgress", () => {
|
||||
});
|
||||
|
||||
it("treats every external provider as document egress", () => {
|
||||
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
|
||||
for (const p of [
|
||||
"codex",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"admin-openai",
|
||||
"admin-codex",
|
||||
]) {
|
||||
expect(isExternalDocumentEgress(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,6 +33,18 @@ describe("parseProviderChain", () => {
|
||||
expect(out.map((e) => e.providerType)).toEqual(["codex", "openai"]);
|
||||
});
|
||||
|
||||
it("recognises admin-codex as a valid type but excludes it from the default", () => {
|
||||
// The operator-shared central Codex is a known chain type (so an appended
|
||||
// entry typechecks), but it is opt-in only and never part of the default.
|
||||
expect(PROVIDER_CHAIN_DEFAULT.map((e) => e.providerType)).not.toContain(
|
||||
"admin-codex",
|
||||
);
|
||||
const out = parseProviderChain([
|
||||
{ providerType: "admin-codex", priority: 1, enabled: true },
|
||||
]);
|
||||
expect(out.map((e) => e.providerType)).toEqual(["admin-codex"]);
|
||||
});
|
||||
|
||||
it("sorts by priority ascending (lowest priority value first)", () => {
|
||||
const input: ProviderChainEntry[] = [
|
||||
{ providerType: "openai", priority: 5, enabled: true },
|
||||
|
||||
@@ -101,6 +101,16 @@ describe("supportsVisionForConfig", () => {
|
||||
expect(supportsVisionForConfig("codex", "o3")).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors codex vision capability for the shared central codex (admin-codex)", () => {
|
||||
// The operator-shared central Codex runs the same wire on the same resolved
|
||||
// slug, so its vision gate is identical to `codex`.
|
||||
expect(supportsVisionForConfig("admin-codex", "gpt-5.5")).toBe(true);
|
||||
expect(supportsVisionForConfig("admin-codex", "gpt-5.3-codex")).toBe(true);
|
||||
expect(supportsVisionForConfig("admin-codex", "gpt-4")).toBe(false);
|
||||
expect(supportsVisionForConfig("admin-codex", "o3-mini")).toBe(false);
|
||||
expect(supportsVisionForConfig("admin-codex", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("never reports vision for none", () => {
|
||||
expect(supportsVisionForConfig("none", "gpt-4o")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -120,6 +120,14 @@ describe("resolveDailyCap (F1 — provider-aware cap)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the operator-cost cap to the shared central-codex (admin-codex) primary", () => {
|
||||
// The operator's shared ChatGPT-subscription account drains the operator's
|
||||
// allowance, so it is billed against the operator cap, not the user plan.
|
||||
expect(resolveDailyCap([{ providerType: "admin-codex" }])).toBe(
|
||||
OPERATOR_COST_CAP,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the generous user-plan cap to a ChatGPT-OAuth (codex) primary", () => {
|
||||
expect(resolveDailyCap([{ providerType: "codex" }])).toBe(USER_PLAN_CAP);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,15 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
*/
|
||||
export type ConsentSurface = "coach" | "insights";
|
||||
|
||||
/** Provider tags that egress via the operator's server-managed/global key. */
|
||||
/**
|
||||
* Provider tags that egress via an operator-managed credential the user did not
|
||||
* personally contract: the operator's global OpenAI key (`admin-openai`) and the
|
||||
* operator's shared central Codex / ChatGPT-subscription account (`admin-codex`).
|
||||
* Both require an active consent receipt before any PHI leaves for them.
|
||||
*/
|
||||
const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"admin-openai",
|
||||
"admin-codex",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,16 @@
|
||||
/**
|
||||
* Provider tags used in the chain. `admin-openai` is the legacy
|
||||
* `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users
|
||||
* still see insights via the operator's key); the rest map 1:1 onto
|
||||
* the per-user encrypted-credential columns.
|
||||
* still see insights via the operator's key); `admin-codex` is the
|
||||
* operator-shared central Codex (ChatGPT-subscription) connection a user
|
||||
* opts into via `useCentralCodex`; the rest map 1:1 onto the per-user
|
||||
* encrypted-credential columns.
|
||||
*
|
||||
* `admin-codex` is NOT part of `PROVIDER_CHAIN_DEFAULT` and is never
|
||||
* offered in the chain editor — it is appended to a user's resolved chain
|
||||
* at resolution time ONLY when they opted in AND the operator connected it
|
||||
* (see `resolveProviderChain`), so a persisted `admin-codex` entry never
|
||||
* resolves on its own.
|
||||
*
|
||||
* Distinct from `ProviderType` (in `types.ts`) because that enum tags
|
||||
* the **runtime** provider instance — which has no notion of "admin
|
||||
@@ -36,6 +44,7 @@ export const PROVIDER_CHAIN_TYPES = [
|
||||
"anthropic",
|
||||
"local",
|
||||
"admin-openai",
|
||||
"admin-codex",
|
||||
] as const;
|
||||
|
||||
export type ProviderChainType = (typeof PROVIDER_CHAIN_TYPES)[number];
|
||||
|
||||
+195
-4
@@ -9,6 +9,8 @@ import {
|
||||
refreshDeviceTokens,
|
||||
encryptCodexCreds,
|
||||
decryptCodexCreds,
|
||||
encryptAdminCodexCreds,
|
||||
decryptAdminCodexCreds,
|
||||
} from "./codex-oauth";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "./local-host-allowlist";
|
||||
@@ -217,6 +219,130 @@ async function resolveCodexProvider(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex provider — the `admin-codex` chain entry.
|
||||
*
|
||||
* Mirrors `resolveCodexProvider` but reads the operator's singleton credential
|
||||
* from `AppSettings.adminCodex*` (three dedicated encrypted columns) instead of
|
||||
* the per-user `codex*Encrypted` columns, and writes any refreshed tokens back
|
||||
* to the same singleton row. Returns null unless the operator has actually
|
||||
* connected the central Codex (`adminCodexConnectionStatus === "connected"` with
|
||||
* all three token columns present) — the per-user `useCentralCodex` opt-in is
|
||||
* enforced by the caller before this is ever invoked.
|
||||
*
|
||||
* Never called with any user identity: the credential and every token refresh
|
||||
* belong to the operator's account, so cost + rate limits land on the operator
|
||||
* (billed against the operator cap, not the user plan).
|
||||
*/
|
||||
async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
adminCodexTokenExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
settings?.adminCodexConnectionStatus !== "connected" ||
|
||||
!settings.adminCodexAccessTokenEncrypted ||
|
||||
!settings.adminCodexRefreshTokenEncrypted ||
|
||||
!settings.adminCodexAccountIdEncrypted
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stored = decryptAdminCodexCreds({
|
||||
accessEncrypted: settings.adminCodexAccessTokenEncrypted,
|
||||
refreshEncrypted: settings.adminCodexRefreshTokenEncrypted,
|
||||
accountIdEncrypted: settings.adminCodexAccountIdEncrypted,
|
||||
expiresAt: settings.adminCodexTokenExpiresAt,
|
||||
});
|
||||
if (!stored) {
|
||||
// Corrupt / undecryptable credential (e.g. a rotated-out key) — mark the
|
||||
// singleton expired so the admin UI prompts a re-link. Never plaintext.
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { adminCodexConnectionStatus: "expired" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
let active = stored;
|
||||
|
||||
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
|
||||
try {
|
||||
const fresh = await refreshDeviceTokens(stored.refreshToken);
|
||||
active = fresh;
|
||||
const enc = encryptAdminCodexCreds(fresh);
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: {
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fall through — CodexClient triggers an on-401 refresh and persists via
|
||||
// the callback below.
|
||||
}
|
||||
}
|
||||
|
||||
return new CodexClient({
|
||||
accessToken: active.accessToken,
|
||||
accountId: active.accountId,
|
||||
onTokenRefresh: async () => {
|
||||
const fresh = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
adminCodexTokenExpiresAt: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!fresh?.adminCodexAccessTokenEncrypted ||
|
||||
!fresh.adminCodexRefreshTokenEncrypted ||
|
||||
!fresh.adminCodexAccountIdEncrypted
|
||||
) {
|
||||
throw new Error("No central-codex refresh token available");
|
||||
}
|
||||
const decoded = decryptAdminCodexCreds({
|
||||
accessEncrypted: fresh.adminCodexAccessTokenEncrypted,
|
||||
refreshEncrypted: fresh.adminCodexRefreshTokenEncrypted,
|
||||
accountIdEncrypted: fresh.adminCodexAccountIdEncrypted,
|
||||
expiresAt: fresh.adminCodexTokenExpiresAt,
|
||||
});
|
||||
if (!decoded) {
|
||||
throw new Error(
|
||||
"Central-codex token storage corrupt; re-link required",
|
||||
);
|
||||
}
|
||||
const refreshed = await refreshDeviceTokens(decoded.refreshToken);
|
||||
const enc = encryptAdminCodexCreds(refreshed);
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: {
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
},
|
||||
});
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
accountId: refreshed.accountId,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AI provider for a given user.
|
||||
*
|
||||
@@ -295,6 +421,7 @@ export async function resolveProviderChain(
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiProviderChain: true,
|
||||
useCentralCodex: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -311,6 +438,20 @@ export async function resolveProviderChain(
|
||||
resolved.push({ providerType: entry.providerType, instance });
|
||||
}
|
||||
}
|
||||
|
||||
// Operator-shared central Codex — appended LAST, and ONLY when the user opted
|
||||
// in AND the operator has connected it. It is never part of the persisted
|
||||
// chain (a persisted `admin-codex` entry resolves to null above), so this is
|
||||
// the single place the opt-in gate is enforced. As a trailing fallback the
|
||||
// user's own providers are tried first; a user with no personal provider
|
||||
// resolves to `[admin-codex]` and is billed against the operator cap.
|
||||
if (userRow?.useCentralCodex) {
|
||||
const centralCodex = await resolveAdminCodexProvider();
|
||||
if (centralCodex) {
|
||||
resolved.push({ providerType: "admin-codex", instance: centralCodex });
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -411,14 +552,46 @@ export async function hasAnyConfiguredProvider(
|
||||
codexConnectionStatus: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
useCentralCodex: true,
|
||||
},
|
||||
});
|
||||
if (!userRow) return false;
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
select: {
|
||||
adminAiKeyEncrypted: true,
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
},
|
||||
});
|
||||
return userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted);
|
||||
if (userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted)) {
|
||||
return true;
|
||||
}
|
||||
// A user with no personal provider can still be served by the operator's
|
||||
// shared central Codex when they opted in and the operator connected it.
|
||||
return userRow.useCentralCodex && appSettingsCentralCodexConnected(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence-only check that the operator's central Codex is connected, over an
|
||||
* already-loaded `AppSettings` subset. No decrypt, no network.
|
||||
*/
|
||||
function appSettingsCentralCodexConnected(
|
||||
settings: {
|
||||
adminCodexConnectionStatus: string | null;
|
||||
adminCodexAccessTokenEncrypted: string | null;
|
||||
adminCodexRefreshTokenEncrypted: string | null;
|
||||
adminCodexAccountIdEncrypted: string | null;
|
||||
} | null,
|
||||
): boolean {
|
||||
return (
|
||||
settings?.adminCodexConnectionStatus === "connected" &&
|
||||
!!settings.adminCodexAccessTokenEncrypted &&
|
||||
!!settings.adminCodexRefreshTokenEncrypted &&
|
||||
!!settings.adminCodexAccountIdEncrypted
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,18 +675,30 @@ export async function resolveProviderAvailability(
|
||||
codexConnectionStatus: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
useCentralCodex: true,
|
||||
},
|
||||
});
|
||||
if (!userRow) return { aiAvailable: false, managedBy: null };
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
select: {
|
||||
adminAiKeyEncrypted: true,
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
},
|
||||
});
|
||||
const managedBy = resolveManagedByFromRow(
|
||||
userRow,
|
||||
!!settings?.adminAiKeyEncrypted,
|
||||
);
|
||||
return { aiAvailable: managedBy !== null, managedBy };
|
||||
if (managedBy !== null) return { aiAvailable: true, managedBy };
|
||||
// The operator's shared central Codex is operator-managed egress ("server").
|
||||
if (userRow.useCentralCodex && appSettingsCentralCodexConnected(settings)) {
|
||||
return { aiAvailable: true, managedBy: "server" };
|
||||
}
|
||||
return { aiAvailable: false, managedBy: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,6 +753,12 @@ async function resolveProviderForType(
|
||||
const admin = await resolveAdminProvider();
|
||||
return admin.type === "none" ? null : admin;
|
||||
}
|
||||
case "admin-codex":
|
||||
// Never resolved from a persisted chain entry — the operator-shared
|
||||
// central Codex is appended in `resolveProviderChain` ONLY behind the
|
||||
// per-user `useCentralCodex` opt-in. Returning null here keeps a
|
||||
// hand-crafted `admin-codex` chain entry from bypassing that gate.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -106,6 +106,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
field: "webPushVapidPrivateKeyEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
// Operator-shared central Codex (ChatGPT subscription) OAuth credential — the
|
||||
// access token, the rotating refresh token, and the mandatory
|
||||
// `ChatGPT-Account-ID` claim, each AES-256-GCM at rest like the per-user
|
||||
// `codex*Encrypted` columns.
|
||||
{
|
||||
model: "AppSettings",
|
||||
field: "adminCodexAccessTokenEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
{
|
||||
model: "AppSettings",
|
||||
field: "adminCodexRefreshTokenEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
{
|
||||
model: "AppSettings",
|
||||
field: "adminCodexAccountIdEncrypted",
|
||||
kind: "string",
|
||||
},
|
||||
|
||||
// ───── Custom labels (mood + cycle) ─────
|
||||
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
|
||||
|
||||
@@ -60,6 +60,19 @@ describe("reorderChainForDocumentClass", () => {
|
||||
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
|
||||
});
|
||||
|
||||
it("demotes the shared central codex (admin-codex) to the subscription tier (rank 3)", () => {
|
||||
// Same train-by-default subscription tier as `codex` — behind local, BYOK,
|
||||
// and the operator's no-train admin key.
|
||||
expect(
|
||||
order([
|
||||
entry("admin-codex"),
|
||||
entry("local"),
|
||||
entry("openai"),
|
||||
entry("admin-openai"),
|
||||
]),
|
||||
).toEqual(["local", "openai", "admin-openai", "admin-codex"]);
|
||||
});
|
||||
|
||||
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
|
||||
expect(order([entry("anthropic"), entry("openai")])).toEqual([
|
||||
"anthropic",
|
||||
@@ -85,6 +98,7 @@ describe("documentEgressClass", () => {
|
||||
expect(documentEgressClass("openai")).toBe("external");
|
||||
expect(documentEgressClass("anthropic")).toBe("external");
|
||||
expect(documentEgressClass("admin-openai")).toBe("external");
|
||||
expect(documentEgressClass("admin-codex")).toBe("external");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user