diff --git a/hub/admin-web/src/routes/admin/org/[slug]/models/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/models/+page.svelte deleted file mode 100644 index 2691edd..0000000 --- a/hub/admin-web/src/routes/admin/org/[slug]/models/+page.svelte +++ /dev/null @@ -1,169 +0,0 @@ - - - - -{#if loading} - -{:else if error} - -{:else} -
-

添加模型

-
- - - - -
-
- -
-
-

已启用模型

- {models.length} -
- {#if models.length === 0} - - {:else} -
- - - - - - - - - - - - - {#each models as m (m.id)} - - - - - - - - - {/each} - -
显示名模型 ID工具能力排序创建
- save(m, { label: (e.target as HTMLInputElement).value })} - /> - - save(m, { modelId: (e.target as HTMLInputElement).value })} - /> - - save(m, { toolCapable })} - /> - - save(m, { sortKey: (e.target as HTMLInputElement).value })} - /> - {fmtDateOnly(m.createdAt)} - -
-
- {/if} -
-{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte deleted file mode 100644 index c6aac88..0000000 --- a/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte +++ /dev/null @@ -1,106 +0,0 @@ - - - - -{#if loading} - -{:else if error} - -{:else} -
-

新建角色

-
- - - -
-
- - {#if roles.length === 0} -
- -
- {:else} -
- {#each roles as r (r.id)} - remove(r)} - onupdated={(updated: OrgRoleRow) => { - roles = roles.map((x) => (x.id === r.id ? updated : x)); - }} - /> - {/each} -
- {/if} -{/if} diff --git a/hub/prisma/migrations/20260710120000_org_model_role_management/migration.sql b/hub/prisma/migrations/20260710120000_org_model_role_management/migration.sql deleted file mode 100644 index 14522f1..0000000 --- a/hub/prisma/migrations/20260710120000_org_model_role_management/migration.sql +++ /dev/null @@ -1,62 +0,0 @@ --- ADR-0021 / ADR-0017: org-scoped model provider connection, enabled models, --- and role presets. Aligns to Spec.System.Organization (ProviderCredentialMode, --- OrganizationProviderConnection) and the "roles are data" seam in agent/models. - -CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED'); - -CREATE TABLE "OrganizationProviderConnection" ( - "organizationId" TEXT NOT NULL, - "providerId" TEXT NOT NULL DEFAULT 'openrouter', - "mode" "ProviderCredentialMode" NOT NULL DEFAULT 'PLATFORM_MANAGED', - "baseUrl" TEXT, - "authToken" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("organizationId") -); - -ALTER TABLE "OrganizationProviderConnection" - ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey" - FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE TABLE "OrgModel" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "modelId" TEXT NOT NULL, - "label" TEXT NOT NULL, - "toolCapable" BOOLEAN NOT NULL DEFAULT true, - "sortKey" TEXT NOT NULL DEFAULT '', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OrgModel_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "OrgModel_organizationId_modelId_key" ON "OrgModel"("organizationId", "modelId"); -CREATE INDEX "OrgModel_organizationId_sortKey_idx" ON "OrgModel"("organizationId", "sortKey"); - -ALTER TABLE "OrgModel" - ADD CONSTRAINT "OrgModel_organizationId_fkey" - FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE TABLE "OrgRole" ( - "id" TEXT NOT NULL, - "organizationId" TEXT NOT NULL, - "roleId" TEXT NOT NULL, - "label" TEXT NOT NULL, - "defaultModelId" TEXT, - "systemPrompt" TEXT, - "tools" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OrgRole_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "OrgRole_organizationId_roleId_key" ON "OrgRole"("organizationId", "roleId"); -CREATE INDEX "OrgRole_organizationId_idx" ON "OrgRole"("organizationId"); - -ALTER TABLE "OrgRole" - ADD CONSTRAINT "OrgRole_organizationId_fkey" - FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/src/admin/routes/modelRoutes.ts b/hub/src/admin/routes/modelRoutes.ts deleted file mode 100644 index bd292cf..0000000 --- a/hub/src/admin/routes/modelRoutes.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * 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 { - 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; -} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 1440ddc..8bec133 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -12,7 +12,6 @@ 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"; @@ -106,10 +105,6 @@ 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, diff --git a/hub/src/org/agentConfig.ts b/hub/src/org/agentConfig.ts deleted file mode 100644 index 227cb0f..0000000 --- a/hub/src/org/agentConfig.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * Org-scoped agent config: provider connection, enabled models, and role - * presets (ADR-0017 / ADR-0021). - * - * Roles and models are **data** (ADR-0017): admin/teacher-defined, persisted - * per organization. The runtime registry (`settings/runtime.ts`) reads these - * rows, falling back to env-based defaults when an org has none configured. - * - * Provider credential storage is a pilot placeholder: the spec leaves key/base - * URL encryption, rotation and resolver mechanism OPEN (secret-control-plane). - * BYOK tokens are stored as opaque strings here; platform-managed credentials - * are resolved from process env, not stored in this table. - */ -import type { PrismaClient } from "@prisma/client"; -import { Prisma } from "@prisma/client"; -import { assertSupportedRoleTools } from "../agent/roleTools.js"; - -export interface ProviderConnectionRow { - readonly organizationId: string; - readonly providerId: string; - readonly mode: "BYOK" | "PLATFORM_MANAGED"; - readonly baseUrl: string | null; - readonly hasAuthToken: boolean; - readonly createdAt: string; - readonly updatedAt: string; -} - -export async function getProviderConnection( - prisma: PrismaClient, - organizationId: string, -): Promise { - const row = await prisma.organizationProviderConnection.findUnique({ - where: { organizationId }, - }); - if (row === null) { - return { - organizationId, - providerId: "openrouter", - mode: "PLATFORM_MANAGED", - baseUrl: null, - hasAuthToken: false, - createdAt: "", - updatedAt: "", - }; - } - return { - organizationId: row.organizationId, - providerId: row.providerId, - mode: row.mode, - baseUrl: row.baseUrl, - hasAuthToken: row.authToken !== null && row.authToken !== "", - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -export async function upsertProviderConnection( - prisma: PrismaClient, - input: { - readonly organizationId: string; - readonly providerId?: string | undefined; - readonly mode: "BYOK" | "PLATFORM_MANAGED"; - readonly baseUrl?: string | null | undefined; - readonly authToken?: string | null | undefined; - }, -): Promise { - const providerId = input.providerId ?? "openrouter"; - if (providerId.trim() === "") throw new Error("providerId is required"); - - const baseUrl = - input.baseUrl === undefined || input.baseUrl === null - ? null - : input.baseUrl.trim() === "" - ? null - : input.baseUrl.trim(); - - const authToken = - input.authToken === undefined || input.authToken === null - ? undefined - : input.authToken === "" - ? null - : input.authToken; - - const row = await prisma.organizationProviderConnection.upsert({ - where: { organizationId: input.organizationId }, - create: { - organizationId: input.organizationId, - providerId, - mode: input.mode, - baseUrl, - ...(authToken !== undefined ? { authToken } : {}), - }, - update: { - providerId, - mode: input.mode, - baseUrl, - ...(authToken !== undefined ? { authToken } : {}), - }, - }); - return { - organizationId: row.organizationId, - providerId: row.providerId, - mode: row.mode, - baseUrl: row.baseUrl, - hasAuthToken: row.authToken !== null && row.authToken !== "", - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -export interface OrgModelRow { - readonly id: string; - readonly modelId: string; - readonly label: string; - readonly toolCapable: boolean; - readonly sortKey: string; - readonly createdAt: string; - readonly updatedAt: string; -} - -export async function listOrgModels( - prisma: PrismaClient, - organizationId: string, -): Promise { - const rows = await prisma.orgModel.findMany({ - where: { organizationId }, - orderBy: [{ sortKey: "asc" }, { createdAt: "asc" }], - }); - return rows.map(toModelRow); -} - -export async function createOrgModel( - prisma: PrismaClient, - input: { - readonly organizationId: string; - readonly modelId: string; - readonly label: string; - readonly toolCapable?: boolean | undefined; - readonly sortKey?: string | undefined; - }, -): Promise { - const modelId = requireNonEmpty(input.modelId, "modelId"); - const label = requireNonEmpty(input.label, "label"); - const existing = await prisma.orgModel.findUnique({ - where: { organizationId_modelId: { organizationId: input.organizationId, modelId } }, - select: { id: true }, - }); - if (existing !== null) { - throw new Error(`model already exists: ${modelId}`); - } - const row = await prisma.orgModel.create({ - data: { - organizationId: input.organizationId, - modelId, - label, - ...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}), - ...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}), - }, - }); - return toModelRow(row); -} - -export async function updateOrgModel( - prisma: PrismaClient, - input: { - readonly organizationId: string; - readonly id: string; - readonly label?: string | undefined; - readonly toolCapable?: boolean | undefined; - readonly sortKey?: string | undefined; - readonly modelId?: string | undefined; - }, -): Promise { - const row = await prisma.orgModel.findUnique({ where: { id: input.id } }); - if (row === null || row.organizationId !== input.organizationId) { - throw new Error(`model not found: ${input.id}`); - } - const updated = await prisma.orgModel.update({ - where: { id: row.id }, - data: { - ...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}), - ...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}), - ...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}), - ...(input.modelId !== undefined - ? { modelId: requireNonEmpty(input.modelId, "modelId") } - : {}), - }, - }); - return toModelRow(updated); -} - -export async function deleteOrgModel( - prisma: PrismaClient, - input: { readonly organizationId: string; readonly id: string }, -): Promise<{ readonly deleted: true; readonly id: string }> { - const row = await prisma.orgModel.findUnique({ where: { id: input.id } }); - if (row === null || row.organizationId !== input.organizationId) { - throw new Error(`model not found: ${input.id}`); - } - await prisma.orgModel.delete({ where: { id: row.id } }); - return { deleted: true as const, id: row.id }; -} - -export interface OrgRoleRow { - readonly id: string; - readonly roleId: string; - readonly label: string; - readonly defaultModelId: string | null; - readonly systemPrompt: string | null; - readonly tools: readonly string[] | null; - readonly createdAt: string; - readonly updatedAt: string; -} - -export async function listOrgRoles( - prisma: PrismaClient, - organizationId: string, -): Promise { - const rows = await prisma.orgRole.findMany({ - where: { organizationId }, - orderBy: [{ roleId: "asc" }], - }); - return rows.map(toRoleRow); -} - -export async function createOrgRole( - prisma: PrismaClient, - input: { - readonly organizationId: string; - readonly roleId: string; - readonly label: string; - readonly defaultModelId?: string | null | undefined; - readonly systemPrompt?: string | null | undefined; - readonly tools?: readonly string[] | null | undefined; - }, -): Promise { - const roleId = requireNonEmpty(input.roleId, "roleId"); - const label = requireNonEmpty(input.label, "label"); - const existing = await prisma.orgRole.findUnique({ - where: { organizationId_roleId: { organizationId: input.organizationId, roleId } }, - select: { id: true }, - }); - if (existing !== null) { - throw new Error(`role already exists: ${roleId}`); - } - const tools = normalizeTools(input.tools); - const row = await prisma.orgRole.create({ - data: { - organizationId: input.organizationId, - roleId, - label, - ...(input.defaultModelId !== undefined && input.defaultModelId !== null - ? { defaultModelId: input.defaultModelId } - : {}), - ...(input.defaultModelId === null ? { defaultModelId: null } : {}), - ...(input.systemPrompt !== undefined && input.systemPrompt !== null - ? { systemPrompt: input.systemPrompt } - : {}), - ...(input.systemPrompt === null ? { systemPrompt: null } : {}), - ...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}), - }, - }); - return toRoleRow(row); -} - -export async function updateOrgRole( - prisma: PrismaClient, - input: { - readonly organizationId: string; - readonly id: string; - readonly label?: string | undefined; - readonly defaultModelId?: string | null | undefined; - readonly systemPrompt?: string | null | undefined; - readonly tools?: readonly string[] | null | undefined; - readonly roleId?: string | undefined; - }, -): Promise { - const row = await prisma.orgRole.findUnique({ where: { id: input.id } }); - if (row === null || row.organizationId !== input.organizationId) { - throw new Error(`role not found: ${input.id}`); - } - const tools = normalizeTools(input.tools); - const updated = await prisma.orgRole.update({ - where: { id: row.id }, - data: { - ...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}), - ...(input.defaultModelId !== undefined && input.defaultModelId !== null - ? { defaultModelId: input.defaultModelId } - : {}), - ...(input.defaultModelId === null ? { defaultModelId: null } : {}), - ...(input.systemPrompt !== undefined && input.systemPrompt !== null - ? { systemPrompt: input.systemPrompt } - : {}), - ...(input.systemPrompt === null ? { systemPrompt: null } : {}), - ...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}), - ...(input.roleId !== undefined ? { roleId: requireNonEmpty(input.roleId, "roleId") } : {}), - }, - }); - return toRoleRow(updated); -} - -export async function deleteOrgRole( - prisma: PrismaClient, - input: { readonly organizationId: string; readonly id: string }, -): Promise<{ readonly deleted: true; readonly id: string }> { - const row = await prisma.orgRole.findUnique({ where: { id: input.id } }); - if (row === null || row.organizationId !== input.organizationId) { - throw new Error(`role not found: ${input.id}`); - } - await prisma.orgRole.delete({ where: { id: row.id } }); - return { deleted: true as const, id: row.id }; -} - -function toModelRow( - row: { - readonly id: string; - readonly modelId: string; - readonly label: string; - readonly toolCapable: boolean; - readonly sortKey: string; - readonly createdAt: Date; - readonly updatedAt: Date; - }, -): OrgModelRow { - return { - id: row.id, - modelId: row.modelId, - label: row.label, - toolCapable: row.toolCapable, - sortKey: row.sortKey, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -function toRoleRow( - row: { - readonly id: string; - readonly roleId: string; - readonly label: string; - readonly defaultModelId: string | null; - readonly systemPrompt: string | null; - readonly tools: unknown; - readonly createdAt: Date; - readonly updatedAt: Date; - }, -): OrgRoleRow { - return { - id: row.id, - roleId: row.roleId, - label: row.label, - defaultModelId: row.defaultModelId, - systemPrompt: row.systemPrompt, - tools: parseTools(row.tools), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -/** - * Normalize a tools input into a value safe for prisma `tools` (Json?) column. - * - `undefined` ⇒ omit (leave unchanged): returns `undefined`. - * - `null` ⇒ unrestricted role (SQL NULL): returns `null`. - * - array ⇒ validated against the supported role tool set (fails fast). - */ -function normalizeTools( - tools: readonly string[] | null | undefined, -): readonly string[] | null | undefined { - if (tools === undefined) return undefined; - if (tools === null) return null; - const list = [...tools]; - assertSupportedRoleTools(list); - return list; -} - -function parseTools(tools: unknown): readonly string[] | null { - if (tools === null) return null; - if (!Array.isArray(tools)) return null; - return tools.filter((t): t is string => typeof t === "string"); -} - -function requireNonEmpty(value: string, label: string): string { - const trimmed = value.trim(); - if (trimmed === "") throw new Error(`${label} is required`); - return trimmed; -} diff --git a/hub/test/integration/admin-agent-config.test.ts b/hub/test/integration/admin-agent-config.test.ts deleted file mode 100644 index b23ca59..0000000 --- a/hub/test/integration/admin-agent-config.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Org agent-config API tests: provider connection, models, roles (ADR-0017 / - * ADR-0021). Also covers the DB-backed runtime registry wiring. - */ -import Fastify from "fastify"; -import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { registerAdminPlugin } from "../../src/admin/plugin.js"; -import { - mintSessionToken, - sessionCookieHeader, -} from "../../src/admin/routes/authRoutes.js"; -import { createDbRuntimeSettings } from "../../src/settings/runtime.js"; -import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; - -const SESSION_SECRET = "integration-test-session-secret"; - -async function buildApp() { - const app = Fastify({ logger: false }); - await registerAdminPlugin(app, { - prisma, - sessionSecret: SESSION_SECRET, - publicBaseUrl: "http://127.0.0.1:8788", - feishuAppId: "cli_test", - feishuAppSecret: "secret_test", - projectWorkspaceRoot: "/tmp/cph-test-ws", - cookieSecure: false, - }); - await app.ready(); - return app; -} - -async function seedOwner(): Promise { - await prisma.user.create({ - data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" }, - }); - await prisma.organizationMembership.create({ - data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" }, - }); - return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET); -} - -describe("admin agent-config API (provider/models/roles)", () => { - beforeEach(async () => { - await resetDb(); - }); - - afterAll(async () => { - await prisma.$disconnect(); - }); - - it("upserts a BYOK provider connection and reads it back without exposing the token", async () => { - const token = await seedOwner(); - const app = await buildApp(); - try { - const cookie = sessionCookieHeader(token); - - const put = await app.inject({ - method: "PUT", - url: "/api/org/test-default/provider-connection", - headers: { cookie, "content-type": "application/json" }, - payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "secret-token-123" }, - }); - expect(put.statusCode).toBe(200); - const conn = put.json() as { mode: string; baseUrl: string; hasAuthToken: boolean }; - expect(conn.mode).toBe("BYOK"); - expect(conn.baseUrl).toBe("https://gw.test/api"); - expect(conn.hasAuthToken).toBe(true); - - const get = await app.inject({ - method: "GET", - url: "/api/org/test-default/provider-connection", - headers: { cookie }, - }); - const got = get.json() as { mode: string; hasAuthToken: boolean }; - expect(got.mode).toBe("BYOK"); - expect(got.hasAuthToken).toBe(true); - // Token value must never be returned by GET. - expect(get.body).not.toContain("secret-token-123"); - } finally { - await app.close(); - } - }); - - it("creates, updates and deletes an enabled model", async () => { - const token = await seedOwner(); - const app = await buildApp(); - try { - const cookie = sessionCookieHeader(token); - - const create = await app.inject({ - method: "POST", - url: "/api/org/test-default/models", - headers: { cookie, "content-type": "application/json" }, - payload: { modelId: "anthropic/claude-sonnet-5", label: "Sonnet 5" }, - }); - expect(create.statusCode).toBe(201); - const model = create.json() as { id: string; modelId: string }; - expect(model.modelId).toBe("anthropic/claude-sonnet-5"); - - // Duplicate modelId is rejected. - const dup = await app.inject({ - method: "POST", - url: "/api/org/test-default/models", - headers: { cookie, "content-type": "application/json" }, - payload: { modelId: "anthropic/claude-sonnet-5", label: "dup" }, - }); - expect(dup.statusCode).toBe(400); - - const upd = await app.inject({ - method: "PATCH", - url: `/api/org/test-default/models/${model.id}`, - headers: { cookie, "content-type": "application/json" }, - payload: { label: "Sonnet 5 (tool)", toolCapable: true, sortKey: "10" }, - }); - expect(upd.statusCode).toBe(200); - expect((upd.json() as { label: string }).label).toBe("Sonnet 5 (tool)"); - - const list = await app.inject({ - method: "GET", - url: "/api/org/test-default/models", - headers: { cookie }, - }); - expect((list.json() as { models: unknown[] }).models).toHaveLength(1); - - const del = await app.inject({ - method: "DELETE", - url: `/api/org/test-default/models/${model.id}`, - headers: { cookie }, - }); - expect(del.statusCode).toBe(200); - const after = await app.inject({ - method: "GET", - url: "/api/org/test-default/models", - headers: { cookie }, - }); - expect((after.json() as { models: unknown[] }).models).toHaveLength(0); - } finally { - await app.close(); - } - }); - - it("creates a role with tools whitelist and rejects unknown tool ids", async () => { - const token = await seedOwner(); - const app = await buildApp(); - try { - const cookie = sessionCookieHeader(token); - - const create = await app.inject({ - method: "POST", - url: "/api/org/test-default/roles", - headers: { cookie, "content-type": "application/json" }, - payload: { - roleId: "draft", - label: "草稿", - systemPrompt: "You are a teaching drafter.", - tools: ["read_file", "write_file", "cph_check"], - }, - }); - expect(create.statusCode).toBe(201); - const role = create.json() as { id: string; roleId: string; tools: string[] }; - expect(role.roleId).toBe("draft"); - expect(role.tools).toEqual(["read_file", "write_file", "cph_check"]); - - // Unknown tool id is rejected. - const bad = await app.inject({ - method: "POST", - url: "/api/org/test-default/roles", - headers: { cookie, "content-type": "application/json" }, - payload: { roleId: "bad", label: "bad", tools: ["not_a_real_tool"] }, - }); - expect(bad.statusCode).toBe(500); - - // null tools ⇒ unrestricted. - const unrestricted = await app.inject({ - method: "PATCH", - url: `/api/org/test-default/roles/${role.id}`, - headers: { cookie, "content-type": "application/json" }, - payload: { tools: null }, - }); - expect(unrestricted.statusCode).toBe(200); - expect((unrestricted.json() as { tools: string[] | null }).tools).toBeNull(); - } finally { - await app.close(); - } - }); - - it("DbRuntimeSettings reads org-scoped models/roles and falls back to env", async () => { - const token = await seedOwner(); - const app = await buildApp(); - try { - const cookie = sessionCookieHeader(token); - - await app.inject({ - method: "POST", - url: "/api/org/test-default/models", - headers: { cookie, "content-type": "application/json" }, - payload: { modelId: "z-ai/glm-4.7", label: "GLM", toolCapable: true }, - }); - await app.inject({ - method: "POST", - url: "/api/org/test-default/roles", - headers: { cookie, "content-type": "application/json" }, - payload: { roleId: "draft", label: "草稿", defaultModelId: "z-ai/glm-4.7" }, - }); - - await prisma.project.create({ - data: { id: "p-cfg", organizationId: DEFAULT_ORG_ID, name: "CfgProj", workspaceDir: "/tmp/p-cfg" }, - }); - - const settings = createDbRuntimeSettings(prisma, { - ANTHROPIC_AUTH_TOKEN: "env-token", - ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5", - }); - const registry = await settings.modelRegistry({ projectId: "p-cfg" }); - const models = registry.listModels(); - expect(models.map((m) => m.id)).toContain("z-ai/glm-4.7"); - const draft = registry.role("draft"); - expect(draft?.label).toBe("草稿"); - expect(registry.resolve(undefined, "draft")).toBe("z-ai/glm-4.7"); - - // Unknown project ⇒ env fallback registry. - const fallback = await settings.modelRegistry({ projectId: "no-such-project" }); - expect(fallback.listModels().map((m) => m.id)).toContain("anthropic/claude-sonnet-5"); - - // BYOK provider connection resolves org credentials. - await app.inject({ - method: "PUT", - url: "/api/org/test-default/provider-connection", - headers: { cookie, "content-type": "application/json" }, - payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "byok-token" }, - }); - const provider = await settings.provider("openrouter", { projectId: "p-cfg" }); - expect(provider.baseUrl).toBe("https://gw.test/api"); - expect(provider.authToken).toBe("byok-token"); - } finally { - await app.close(); - } - }); -});