forked from bai/curriculum-project-hub
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
import type { PrismaClient } from "@prisma/client";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { ProviderConnectionService } from "../../connections/providerConnections.js";
|
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
|
import { handleRouteError } from "../errors.js";
|
|
import { ProviderReadinessError, type ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
|
|
|
export interface ProviderConnectionRouteConfig {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
readonly secretEnvelope: LocalSecretEnvelope;
|
|
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
|
}
|
|
|
|
export async function registerProviderConnectionRoutes(
|
|
app: FastifyInstance,
|
|
config: ProviderConnectionRouteConfig,
|
|
): Promise<void> {
|
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
|
const connections = new ProviderConnectionService(
|
|
config.prisma,
|
|
config.secretEnvelope,
|
|
config.providerReadinessProbe,
|
|
);
|
|
|
|
app.get("/api/org/:orgSlug/provider-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: "provider_connection.list",
|
|
...providerFailureFacts(error),
|
|
}, "provider connection read failed");
|
|
return handleRouteError(reply, error);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/provider-connections/:providerId", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, providerId } = request.params as { orgSlug: string; providerId: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = parseCredentialBody(request.body);
|
|
const result = await connections.rotateByok({
|
|
organizationId: auth.organization.id,
|
|
providerId,
|
|
actorUserId: auth.user.id,
|
|
...body,
|
|
});
|
|
request.log.info({
|
|
organizationId: auth.organization.id,
|
|
connectionId: result.id,
|
|
providerId: result.providerId,
|
|
mode: result.mode,
|
|
status: result.status,
|
|
secretVersion: result.activeVersion,
|
|
keyId: result.keyId,
|
|
}, result.created ? "provider connection created" : "provider connection rotated");
|
|
return reply.status(result.created ? 201 : 200).send(withoutWriteResult(result));
|
|
} catch (error) {
|
|
request.log.error({
|
|
requestId: request.id,
|
|
operation: "provider_connection.rotate_byok",
|
|
...providerFailureFacts(error),
|
|
}, "provider connection mutation failed");
|
|
return handleRouteError(reply, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function providerFailureFacts(error: unknown): {
|
|
readonly errorCode: string;
|
|
readonly failureCategory: string;
|
|
readonly upstreamStatus?: number;
|
|
} {
|
|
if (error instanceof ProviderReadinessError) {
|
|
return {
|
|
errorCode: error.code,
|
|
failureCategory: error.category,
|
|
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
|
|
};
|
|
}
|
|
return {
|
|
errorCode: "provider_connection_operation_failed",
|
|
failureCategory: error instanceof Error ? error.name : typeof error,
|
|
};
|
|
}
|
|
|
|
function parseCredentialBody(value: unknown): {
|
|
readonly baseUrl: string;
|
|
readonly authToken: string;
|
|
readonly anthropicApiKey?: string;
|
|
} {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error("invalid provider credential body");
|
|
}
|
|
const body = value as Record<string, unknown>;
|
|
if (typeof body["baseUrl"] !== "string" || typeof body["authToken"] !== "string") {
|
|
throw new Error("provider baseUrl and authToken are required");
|
|
}
|
|
const anthropicApiKey = body["anthropicApiKey"];
|
|
if (anthropicApiKey !== undefined && typeof anthropicApiKey !== "string") {
|
|
throw new Error("invalid anthropicApiKey");
|
|
}
|
|
return {
|
|
baseUrl: body["baseUrl"],
|
|
authToken: body["authToken"],
|
|
...(anthropicApiKey !== undefined ? { anthropicApiKey } : {}),
|
|
};
|
|
}
|
|
|
|
function withoutWriteResult<T extends { readonly created: boolean }>(result: T): Omit<T, "created"> {
|
|
const { created: _created, ...metadata } = result;
|
|
return metadata;
|
|
}
|