mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
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 });
|
|
});
|