/** * ADR-0027: Organization-scoped capability connection service. Manages the * lifecycle (rotate / read / disable) of capability credentials stored in * ADR-0024 encrypted envelopes with purpose="capability". * * Mirrors FeishuApplicationConnectionService, but keyed by (organizationId, * capabilityId) instead of 1:1 — an org may have multiple capabilities. */ import { randomUUID } from "node:crypto"; import type { Prisma, PrismaClient } from "@prisma/client"; import { lockActiveOrganization } from "../org/status.js"; import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js"; import type { CapabilitySecretPayload } from "./types.js"; const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]); export interface CapabilityCredentialInput { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string; } export interface RotateCapabilityInput extends CapabilityCredentialInput { readonly organizationId: string; readonly capabilityId: string; readonly actorUserId: string; } export interface CapabilityConnectionMetadata { readonly id: string; readonly capabilityId: string; readonly status: "DRAFT" | "ACTIVE" | "DISABLED"; readonly activeVersion: number | null; readonly keyId: string | null; readonly createdAt: Date; readonly updatedAt: Date; } export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata { readonly created: boolean; } export type CapabilitySecretPayloadV1 = CapabilitySecretPayload; export class CapabilityConnectionService { constructor( private readonly prisma: PrismaClient, private readonly secrets: LocalSecretEnvelope, private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential, ) {} async rotate(input: RotateCapabilityInput): Promise { if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) { throw new Error(`invalid capabilityId: ${input.capabilityId}`); } const payload = validateCredential(input); await this.prisma.$transaction(async (tx) => { await requireCapabilityAdmin(tx, input); }); await this.readinessProbe({ endpoint: payload.endpoint, accessKeyId: payload.accessKeyId, accessKeySecret: payload.accessKeySecret, }); return this.prisma.$transaction(async (tx) => { await requireCapabilityAdmin(tx, input); const connection = await tx.organizationCapabilityConnection.upsert({ where: { organizationId_capabilityId: { organizationId: input.organizationId, capabilityId: input.capabilityId, }, }, update: {}, create: { id: randomUUID(), organizationId: input.organizationId, capabilityId: input.capabilityId, status: "DRAFT", }, }); await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`; const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({ where: { id: connection.id }, include: { activeSecretVersion: true, secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } }, }, }); if (locked.organizationId !== input.organizationId) { throw new Error("Capability Connection scope changed during rotation"); } const version = (locked.secretVersions[0]?.version ?? 0) + 1; const secretVersionId = randomUUID(); const envelope = this.secrets.encryptJson( { purpose: "capability", organizationId: input.organizationId, connectionId: locked.id, secretVersionId, }, payload, ); const now = new Date(); const secretVersion = await tx.capabilityCredentialVersion.create({ data: { id: secretVersionId, connectionId: locked.id, version, envelopeVersion: envelope.version, keyId: envelope.keyId, envelope: envelope as unknown as Prisma.InputJsonValue, createdByUserId: input.actorUserId, }, }); if (locked.activeSecretVersion !== null) { await tx.capabilityCredentialVersion.update({ where: { id: locked.activeSecretVersion.id }, data: { retiredAt: now }, }); } const activated = await tx.organizationCapabilityConnection.update({ where: { id: locked.id }, data: { status: "ACTIVE", activeSecretVersionId: secretVersion.id, activatedAt: now, disabledAt: null, }, }); await tx.auditEntry.create({ data: { organizationId: input.organizationId, actorUserId: input.actorUserId, action: version === 1 ? "capability.created" : "capability.rotated", metadata: { connectionId: locked.id, capabilityId: input.capabilityId, status: "ACTIVE", secretVersion: version, keyId: envelope.keyId, }, }, }); return { ...toMetadata(activated, { version, keyId: secretVersion.keyId }), created: version === 1, }; }); } async list(organizationId: string): Promise { const connections = await this.prisma.organizationCapabilityConnection.findMany({ where: { organizationId }, include: { activeSecretVersion: { select: { version: true, keyId: true } } }, orderBy: { capabilityId: "asc" }, }); return connections.map((c) => toMetadata(c, c.activeSecretVersion)); } async read(organizationId: string, capabilityId: string): Promise { const connection = await this.prisma.organizationCapabilityConnection.findFirst({ where: { organizationId, capabilityId }, include: { activeSecretVersion: { select: { version: true, keyId: true } } }, }); return connection === null ? null : toMetadata(connection, connection.activeSecretVersion); } async disable(input: { readonly organizationId: string; readonly capabilityId: string; readonly actorUserId: string; }): Promise { return this.prisma.$transaction(async (tx) => { await requireCapabilityAdmin(tx, input); const connection = await tx.organizationCapabilityConnection.findFirst({ where: { organizationId: input.organizationId, capabilityId: input.capabilityId }, select: { id: true }, }); if (connection === null) throw new Error("Capability Connection not found"); await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`; const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({ where: { id: connection.id }, include: { activeSecretVersion: { select: { version: true, keyId: true } } }, }); if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion); const disabled = await tx.organizationCapabilityConnection.update({ where: { id: locked.id }, data: { status: "DISABLED", disabledAt: new Date() }, }); await tx.auditEntry.create({ data: { organizationId: input.organizationId, actorUserId: input.actorUserId, action: "capability.disabled", metadata: { connectionId: locked.id, capabilityId: input.capabilityId, previousStatus: locked.status, status: "DISABLED", }, }, }); return toMetadata(disabled, locked.activeSecretVersion); }); } } function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 { if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) { throw new Error(`unsupported capabilityId: ${input.capabilityId}`); } const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId"); const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret"); const endpoint = nonEmpty(input.endpoint, "endpoint"); return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint }; } function toMetadata( connection: { readonly id: string; readonly capabilityId: string; readonly status: string; readonly createdAt: Date; readonly updatedAt: Date; }, secret: { readonly version: number; readonly keyId: string } | null, ): CapabilityConnectionMetadata { return { id: connection.id, capabilityId: connection.capabilityId, status: connection.status as CapabilityConnectionMetadata["status"], activeVersion: secret?.version ?? null, keyId: secret?.keyId ?? null, createdAt: connection.createdAt, updatedAt: connection.updatedAt, }; } async function requireCapabilityAdmin( tx: Prisma.TransactionClient, input: { readonly organizationId: string; readonly actorUserId: string }, ): Promise { await lockActiveOrganization(tx, input.organizationId); const membership = await tx.organizationMembership.findFirst({ where: { organizationId: input.organizationId, userId: input.actorUserId, role: { in: ["OWNER", "ADMIN"] }, revokedAt: null, }, select: { id: true }, }); if (membership === null) { throw new Error("only Organization OWNER or ADMIN may manage capability connections"); } } function nonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} must not be empty`); return trimmed; }