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}
-
- {/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();
- }
- });
-});