/** * ADR-0027: Admin routes for organization-scoped capability connections. * GET /api/org/:orgSlug/capability-connections — list all * GET /api/org/:orgSlug/capability-connections/:capId — read one * PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create * DELETE /api/org/:orgSlug/capability-connections/:capId — disable */ import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; import { CapabilityConnectionService, type CapabilityCredentialInput, } from "../../capability/capabilityConnectionService.js"; import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js"; import { secretKindForCapability } from "../../capability/types.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; export interface CapabilityConnectionRouteConfig { readonly prisma: PrismaClient; readonly sessionSecret: string; readonly secretEnvelope: LocalSecretEnvelope; readonly readinessProbe?: CapabilityReadinessProbe; } export async function registerCapabilityConnectionRoutes( app: FastifyInstance, config: CapabilityConnectionRouteConfig, ): Promise { const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const connections = config.readinessProbe === undefined ? new CapabilityConnectionService(config.prisma, config.secretEnvelope) : new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe); app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; return { connections: await connections.list(auth.organization.id) }; } catch (error) { request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed"); return handleRouteError(reply, error); } }); app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => { try { const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; return { connection: await connections.read(auth.organization.id, capabilityId) }; } catch (error) { request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed"); return handleRouteError(reply, error); } }); app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => { try { const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const credential = parseCredentialBody(capabilityId, request.body); const result = await connections.rotate({ organizationId: auth.organization.id, capabilityId, actorUserId: auth.user.id, credential, }); request.log.info({ organizationId: auth.organization.id, capabilityId, connectionId: result.id, status: result.status, secretVersion: result.activeVersion, }, result.created ? "Capability Connection created" : "Capability Connection rotated"); const { created, ...metadata } = result; return reply.status(created ? 201 : 200).send(metadata); } catch (error) { const facts = error instanceof CapabilityReadinessError ? { errorCode: error.code, failureCategory: error.category, ...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}), } : { errorCode: "capability_connection_write_failed" }; request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed"); return handleRouteError(reply, error); } }); app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => { try { const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; const result = await connections.disable({ organizationId: auth.organization.id, capabilityId, actorUserId: auth.user.id, }); request.log.info({ organizationId: auth.organization.id, capabilityId, connectionId: result.id, status: result.status, }, "Capability Connection disabled"); return reply.send(result); } catch (error) { request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed"); return handleRouteError(reply, error); } }); } function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("invalid capability credential body"); } const body = value as Record; const expectedKind = secretKindForCapability(capabilityId); const kind = body.kind === "docmind" || body.kind === "pbank" ? body.kind : expectedKind; if (kind !== expectedKind) { throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`); } if (kind === "docmind") { return { kind: "docmind", accessKeyId: requireStringField(body, "accessKeyId"), accessKeySecret: requireStringField(body, "accessKeySecret"), endpoint: requireStringField(body, "endpoint"), }; } const rightsStatus = optionalStringField(body, "rightsStatus"); const rightsHolder = optionalStringField(body, "rightsHolder"); const rightsScope = optionalStringField(body, "rightsScope"); const rightsNote = optionalStringField(body, "rightsNote"); return { kind: "pbank", baseUrl: requireStringField(body, "baseUrl"), username: requireStringField(body, "username"), password: requireStringField(body, "password"), ...(rightsStatus !== undefined ? { rightsStatus } : {}), ...(rightsHolder !== undefined ? { rightsHolder } : {}), ...(rightsScope !== undefined ? { rightsScope } : {}), ...(rightsNote !== undefined ? { rightsNote } : {}), }; } function requireStringField(body: Record, name: string): string { const value = body[name]; if (typeof value !== "string" || value.trim() === "") { throw new Error(`${name} is required`); } return value; } function optionalStringField(body: Record, name: string): string | undefined { const value = body[name]; if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed === "" ? undefined : trimmed; }