mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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.
95 lines
3.2 KiB
TypeScript
95 lines
3.2 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { NextRequest } from "next/server";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
codexConnectionStatus: true,
|
|
codexConnectedAt: true,
|
|
useCentralCodex: true,
|
|
insightsPrivacyMode: true,
|
|
insightsCachedAt: true,
|
|
},
|
|
});
|
|
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
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({
|
|
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
|
|
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
|
|
hasAdminKey: !!settings?.adminAiKeyEncrypted,
|
|
// v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID`
|
|
// configured. Without it the Connect-with-ChatGPT flow is dead, so
|
|
// 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,
|
|
});
|
|
});
|
|
|
|
export const PUT = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
|
|
const { data: body, error: jsonError } = await safeJson<
|
|
Record<string, unknown>
|
|
>(request, { maxBytes: 64 * 1024 });
|
|
if (jsonError) return jsonError;
|
|
|
|
const data: Record<string, unknown> = {};
|
|
|
|
if (typeof body.privacyMode === "string") {
|
|
const mode = body.privacyMode as string;
|
|
if (!["aggregated", "raw"].includes(mode)) {
|
|
return apiError("Invalid privacy mode", 422);
|
|
}
|
|
data.insightsPrivacyMode = mode;
|
|
data.insightsCachedAt = null;
|
|
data.insightsCachedText = null;
|
|
}
|
|
|
|
if (Object.keys(data).length === 0) {
|
|
return apiError("No changes", 422);
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data,
|
|
});
|
|
|
|
annotate({ action: { name: "insights.settings.update" } });
|
|
|
|
return apiSuccess({ updated: true });
|
|
});
|