feat: add org admin SPA for models, roles and provider

Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
2026-07-11 12:33:56 +08:00
parent 461d2e89b0
commit 552c1c353e
50 changed files with 6986 additions and 151 deletions
+252
View File
@@ -0,0 +1,252 @@
/**
* Org-admin routes for agent config: provider connection, enabled models, and
* role presets (ADR-0017 / ADR-0021). Mounted under `/api/org/:orgSlug`.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
createOrgModel,
createOrgRole,
deleteOrgModel,
deleteOrgRole,
getProviderConnection,
listOrgModels,
listOrgRoles,
updateOrgModel,
updateOrgRole,
upsertProviderConnection,
} from "../../org/agentConfig.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const PROVIDER_MODES = ["BYOK", "PLATFORM_MANAGED"] as const;
type ProviderMode = (typeof PROVIDER_MODES)[number];
export async function registerModelRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// --- Provider connection ---------------------------------------------
app.get("/api/org/:orgSlug/provider-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await getProviderConnection(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/provider-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
providerId?: unknown;
mode?: unknown;
baseUrl?: unknown;
authToken?: unknown;
};
const mode = parseProviderMode(body.mode);
if (mode === null) {
return reply.status(400).send({
error: { code: "bad_request", message: "mode must be BYOK or PLATFORM_MANAGED" },
});
}
const connection = await upsertProviderConnection(config.prisma, {
organizationId: auth.organization.id,
mode,
...(typeof body.providerId === "string" ? { providerId: body.providerId } : {}),
...(body.baseUrl === null || typeof body.baseUrl === "string"
? { baseUrl: body.baseUrl as string | null }
: {}),
...(body.authToken === null || typeof body.authToken === "string"
? { authToken: body.authToken as string | null }
: {}),
});
return connection;
} catch (err) {
return handleRouteError(reply, err);
}
});
// --- Models ----------------------------------------------------------
app.get("/api/org/:orgSlug/models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { models: await listOrgModels(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
modelId?: unknown;
label?: unknown;
toolCapable?: unknown;
sortKey?: unknown;
};
if (typeof body.modelId !== "string" || typeof body.label !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "modelId and label are required" },
});
}
const model = await createOrgModel(config.prisma, {
organizationId: auth.organization.id,
modelId: body.modelId,
label: body.label,
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
});
return reply.status(201).send(model);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
try {
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
label?: unknown;
toolCapable?: unknown;
sortKey?: unknown;
modelId?: unknown;
};
return await updateOrgModel(config.prisma, {
organizationId: auth.organization.id,
id: modelId,
...(typeof body.label === "string" ? { label: body.label } : {}),
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
...(typeof body.modelId === "string" ? { modelId: body.modelId } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
try {
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await deleteOrgModel(config.prisma, {
organizationId: auth.organization.id,
id: modelId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
// --- Roles -----------------------------------------------------------
app.get("/api/org/:orgSlug/roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { roles: await listOrgRoles(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
roleId?: unknown;
label?: unknown;
defaultModelId?: unknown;
systemPrompt?: unknown;
tools?: unknown;
};
if (typeof body.roleId !== "string" || typeof body.label !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "roleId and label are required" },
});
}
const role = await createOrgRole(config.prisma, {
organizationId: auth.organization.id,
roleId: body.roleId,
label: body.label,
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
...(body.tools === null ? { tools: null } : {}),
});
return reply.status(201).send(role);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
label?: unknown;
defaultModelId?: unknown;
systemPrompt?: unknown;
tools?: unknown;
roleId?: unknown;
};
return await updateOrgRole(config.prisma, {
organizationId: auth.organization.id,
id: roleId,
...(typeof body.label === "string" ? { label: body.label } : {}),
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
...(body.tools === null ? { tools: null } : {}),
...(typeof body.roleId === "string" ? { roleId: body.roleId } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await deleteOrgRole(config.prisma, {
organizationId: auth.organization.id,
id: roleId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
function parseProviderMode(value: unknown): ProviderMode | null {
if (typeof value !== "string") return null;
return (PROVIDER_MODES as readonly string[]).includes(value) ? (value as ProviderMode) : null;
}
+5
View File
@@ -12,6 +12,7 @@ import {
import { registerAccessRoutes } from "./accessRoutes.js";
import { registerExplorerRoutes } from "./explorerRoutes.js";
import { registerMembersRoutes } from "./membersRoutes.js";
import { registerModelRoutes } from "./modelRoutes.js";
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerModelRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
+82
View File
@@ -0,0 +1,82 @@
/**
* Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and
* the SPA index fallback for client-side routes under `/admin/*`.
*
* The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there
* to produce the static output in `admin-web/build/`. In development, use
* `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub.
*
* Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
function resolveUiDir(): string {
const override = process.env["CPH_ADMIN_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "admin-web", "build"));
}
export async function registerStaticSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit static assets (_app/*, favicon, etc.)
app.get("/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
});
// Other top-level static assets (favicon.svg, robots.txt, etc.)
app.get("/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
// SPA client-side route fallback. `/admin/login` is registered earlier as a
// static route and takes precedence; everything else under /admin/* serves
// the index so SvelteKit's client-side router can resolve the view.
app.get("/admin", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/admin/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
}