Files
HealthLog/src/app/api/insights/settings/route.ts
T
Marc-André Bombeck d7e616eaa8 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.
2026-07-10 00:10:56 +02:00

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