forked from EduCraft/curriculum-project-hub
ef96f8d33d
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
132 lines
5.6 KiB
TypeScript
132 lines
5.6 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 } from "../../capability/capabilityConnectionService.js";
|
|
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.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 = 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 body = parseBody(request.body);
|
|
const result = await connections.rotate({
|
|
organizationId: auth.organization.id,
|
|
capabilityId,
|
|
actorUserId: auth.user.id,
|
|
...body,
|
|
});
|
|
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 parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error("invalid capability credential body");
|
|
}
|
|
const body = value as Record<string, unknown>;
|
|
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
|
|
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
}
|
|
return {
|
|
accessKeyId: body["accessKeyId"] as string,
|
|
accessKeySecret: body["accessKeySecret"] as string,
|
|
endpoint: body["endpoint"] as string,
|
|
};
|
|
}
|