chore: drop superseded admin-panel agent-config prototype after rebase onto main

main now ships the canonical org-scoped agent-config implementation
(OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts
+ hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted
OrganizationProviderConnection per ADR-0024). The earlier admin-panel
prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken,
modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web
models/roles pages) is superseded and clashes with main's schema; drop it.

Follow-up still needed: rewire admin-web SPA to the new config APIs
(+layout.svelte nav still lists models/roles, RoleCard.svelte unused).
This commit is contained in:
2026-07-13 21:44:07 +08:00
parent 9c33a4e9b9
commit b1ddf32238
7 changed files with 0 additions and 1219 deletions
@@ -1,169 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgModelRow } from '$lib/api';
import { fmtDateOnly } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let models = $state<OrgModelRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newModelId = $state('');
let newLabel = $state('');
let newSortKey = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.models(slug);
models = res.models;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
if (!newModelId.trim() || !newLabel.trim()) return;
adding = true;
try {
const m = await api.createModel(slug, {
modelId: newModelId.trim(),
label: newLabel.trim(),
...(newSortKey.trim() ? { sortKey: newSortKey.trim() } : {})
});
models = [...models, m];
newModelId = '';
newLabel = '';
newSortKey = '';
toastSuccess('模型已添加');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function save(m: OrgModelRow, patch: Partial<OrgModelRow>) {
try {
const updated = await api.updateModel(slug, m.id, patch);
models = models.map((x) => (x.id === m.id ? updated : x));
toastSuccess('已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
await load();
}
}
async function remove(m: OrgModelRow) {
if (!confirm(`删除模型 ${m.label}?`)) return;
try {
await api.deleteModel(slug, m.id);
models = models.filter((x) => x.id !== m.id);
toastSuccess('模型已删除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型"
description="本组织启用的模型清单。模型 ID 面向供应方接口;显示名面向教师侧切换。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">添加模型</h2>
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="模型 ID(如 anthropic/claude-sonnet-5"
bind:value={newModelId}
/>
<input class="saas-input" placeholder="显示名(如 Claude Sonnet 5" bind:value={newLabel} />
<input class="saas-input w-28" placeholder="排序" bind:value={newSortKey} />
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
</div>
</div>
<div class="saas-card overflow-hidden">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
<h2 class="text-sm font-semibold">已启用模型</h2>
<span class="saas-badge-neutral">{models.length}</span>
</div>
{#if models.length === 0}
<EmptyState title="暂无已启用模型" description="空清单时会回退到环境默认模型。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>显示名</th>
<th>模型 ID</th>
<th>工具能力</th>
<th>排序</th>
<th>创建</th>
<th></th>
</tr>
</thead>
<tbody>
{#each models as m (m.id)}
<tr>
<td>
<input
class="saas-input py-1.5"
value={m.label}
onchange={(e) => save(m, { label: (e.target as HTMLInputElement).value })}
/>
</td>
<td>
<input
class="saas-input py-1.5 font-mono text-xs"
value={m.modelId}
onchange={(e) => save(m, { modelId: (e.target as HTMLInputElement).value })}
/>
</td>
<td>
<CheckboxControl
checked={m.toolCapable}
onchange={(toolCapable) => save(m, { toolCapable })}
/>
</td>
<td>
<input
class="saas-input w-20 py-1.5"
value={m.sortKey}
onchange={(e) => save(m, { sortKey: (e.target as HTMLInputElement).value })}
/>
</td>
<td class="text-xs text-surface-600">{fmtDateOnly(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-xs" onclick={() => remove(m)}>删除</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
@@ -1,106 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let roles = $state<OrgRoleRow[]>([]);
let models = $state<OrgModelRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, m] = await Promise.all([api.roles(slug), api.models(slug)]);
roles = r.roles;
models = m.models;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
if (!newRoleId.trim() || !newLabel.trim()) return;
adding = true;
try {
const r = await api.createRole(slug, { roleId: newRoleId.trim(), label: newLabel.trim() });
roles = [...roles, r];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function remove(r: OrgRoleRow) {
if (!confirm(`删除角色 ${r.label} (${r.roleId})?`)) return;
try {
await api.deleteRole(slug, r.id);
roles = roles.filter((x) => x.id !== r.id);
toastSuccess('角色已删除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色按数据配置。角色 ID 同时是斜杠命令(如 /draft),可绑定默认模型、工具白名单与系统提示词。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input class="saas-input font-mono text-sm" placeholder="角色 ID(如 draft" bind:value={newRoleId} />
<input class="saas-input" placeholder="显示名(如 草稿)" bind:value={newLabel} />
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="未配置时回退环境默认角色(draft / review)。" />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.id)}
<RoleCard
{r}
{models}
{slug}
onremoved={() => remove(r)}
onupdated={(updated: OrgRoleRow) => {
roles = roles.map((x) => (x.id === r.id ? updated : x));
}}
/>
{/each}
</div>
{/if}
{/if}
@@ -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;
-252
View File
@@ -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<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,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,
-386
View File
@@ -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<ProviderConnectionRow> {
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<ProviderConnectionRow> {
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<readonly OrgModelRow[]> {
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<OrgModelRow> {
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<OrgModelRow> {
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<readonly OrgRoleRow[]> {
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<OrgRoleRow> {
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<OrgRoleRow> {
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;
}
@@ -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<string> {
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();
}
});
});