forked from EduCraft/curriculum-project-hub
54837717fd
Register pbank as an ADR-0027 external capability with org-scoped username/password envelopes, readiness via /login, and in-process cph_hub MCP tools (search/get/get_many) that materialize sources under the run workspace. Extend the capability secret payload for docmind vs pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the pbank-problem-report skill. Credentials never reach the Agent process.
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<string, unknown>;
|
|
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<string, unknown>, 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<string, unknown>, name: string): string | undefined {
|
|
const value = body[name];
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed === "" ? undefined : trimmed;
|
|
}
|