/** * Org-admin Agent configuration routes (ADR-0017 / ADR-0018). * * Surface for browsing and editing Organization-scoped Agent roles, the skills * bound to them, and the model picker. The model list is **derived** from the * org's ACTIVE provider connection via OpenRouter's /v1/models API — there is * no separate model table to maintain. When no ACTIVE provider exists, the * route falls back to the env-default model registry so the admin surface * remains usable. * * Skill management (create, read, edit, disable) is performed in-band through * the deep module `OrganizationAgentConfiguration`, which writes to the same * content-addressed persistent store used by the host-console CLI. All skill * content flows through `importSkillFromFiles` → `inspectSkillDirectory` → * `commitSkillContent`, so the web path and CLI path share one ingestion * pipeline and one set of safety checks (SKILL.md manifest required, 512-file * / 16-byte limits, symlink rejection). */ import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; import { OrganizationAgentConfiguration } from "../../agent/configuration.js"; import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js"; import { createDefaultModelRegistry } from "../../settings/runtime.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; export interface AgentConfigRouteConfig { readonly prisma: PrismaClient; readonly sessionSecret: string; readonly skillStoreRoot: string; readonly secretEnvelope: LocalSecretEnvelope; } export async function registerAgentConfigRoutes( app: FastifyInstance, config: AgentConfigRouteConfig, ): Promise { const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot); const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope); app.get("/api/org/:orgSlug/agent-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 roles = await agentConfig.listRoles({ organizationId: auth.organization.id }); return { roles }; } catch (err) { return handleRouteError(reply, err); } }); app.put("/api/org/:orgSlug/agent-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; defaultModel?: unknown; systemPrompt?: unknown; tools?: unknown; sortOrder?: unknown; isDefault?: unknown; }; if (typeof body.label !== "string" || body.label.trim() === "") { return reply.status(400).send({ error: { code: "bad_request", message: "label is required" }, }); } const role = await agentConfig.upsertRole({ organizationId: auth.organization.id, roleId, label: body.label, ...(body.defaultModel === null || typeof body.defaultModel === "string" ? { defaultModel: body.defaultModel as string | null } : {}), ...(body.systemPrompt === null || typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt as string | null } : {}), ...(body.tools === null || Array.isArray(body.tools) ? { tools: body.tools as readonly string[] | null } : {}), ...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}), ...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}), }); return role; } catch (err) { return handleRouteError(reply, err); } }); app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", 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 { skillNames?: unknown }; if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) { return reply.status(400).send({ error: { code: "bad_request", message: "skillNames must be a string array" }, }); } await agentConfig.setRoleSkills({ organizationId: auth.organization.id, roleId, skillNames: body.skillNames as readonly string[], }); return { skillNames: body.skillNames as string[] }; } catch (err) { return handleRouteError(reply, err); } }); app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const skills = await agentConfig.listSkills({ organizationId: auth.organization.id }); return { skills }; } catch (err) { return handleRouteError(reply, err); } }); app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => { try { const { orgSlug, name } = request.params as { orgSlug: string; name: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const skills = await agentConfig.listSkills({ organizationId: auth.organization.id }); const skill = skills.find((s) => s.name === name && s.disabledAt === null); if (skill === undefined) { return reply.status(404).send({ error: { code: "not_found", message: `skill not found: ${name}` }, }); } const files = await agentConfig.readSkillFiles({ organizationId: auth.organization.id, contentDigest: skill.contentDigest, }); return { files }; } catch (err) { return handleRouteError(reply, err); } }); app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => { try { const { orgSlug, name } = request.params as { orgSlug: string; name: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const body = request.body as { version?: unknown; files?: unknown }; if (typeof body.version !== "string" || body.version.trim() === "") { return reply.status(400).send({ error: { code: "bad_request", message: "version is required" }, }); } if (!Array.isArray(body.files) || body.files.length === 0) { return reply.status(400).send({ error: { code: "bad_request", message: "files must be a non-empty array" }, }); } const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = []; for (const entry of body.files) { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { return reply.status(400).send({ error: { code: "bad_request", message: "each file must be an object" }, }); } const e = entry as { path?: unknown; content?: unknown }; if (typeof e.path !== "string" || typeof e.content !== "string") { return reply.status(400).send({ error: { code: "bad_request", message: "each file needs string path and content" }, }); } skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") }); } const result = await agentConfig.installSkillFromFiles({ organizationId: auth.organization.id, files: skillFiles, version: body.version, }); // The manifest name is authoritative — reject if it doesn't match the // URL param, so clients can't silently rename a skill under a different // route key. if (result.name !== name) { return reply.status(400).send({ error: { code: "bad_request", message: `skill manifest name "${result.name}" does not match route "${name}"`, }, }); } return { id: result.id, name: result.name, contentDigest: result.contentDigest }; } catch (err) { return handleRouteError(reply, err); } }); app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => { try { const { orgSlug, name } = request.params as { orgSlug: string; name: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const body = request.body as { description?: unknown; disabled?: unknown }; if (body.disabled === true) { await agentConfig.disableSkill({ organizationId: auth.organization.id, name }); return { disabled: true }; } if (typeof body.description === "string") { await agentConfig.updateSkillDescription({ organizationId: auth.organization.id, name, description: body.description, }); return { updated: true }; } return reply.status(400).send({ error: { code: "bad_request", message: "provide description or disabled: true" }, }); } catch (err) { return handleRouteError(reply, err); } }); app.get("/api/org/:orgSlug/agent-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 providerModels = await modelCatalog.listModels(auth.organization.id); // Fall back to env-default registry when the org has no ACTIVE provider // (or the provider API is unreachable on first load). const models = providerModels.length > 0 ? providerModels : createDefaultModelRegistry(process.env).listModels(); return { models }; } catch (err) { return handleRouteError(reply, err); } }); }