mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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,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);
|
||||
});
|
||||
Reference in new issue
Block a user