mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user