From ef96f8d33d8c40437972ebe82a084a095e4f299e Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 18 Jul 2026 17:57:04 +0800 Subject: [PATCH] feat(hub): capability connection admin API + UI + release v0.0.32 (#8) Co-authored-by: Hong Jiarong Co-committed-by: Hong Jiarong --- hub/admin-web/src/lib/api.ts | 25 ++ hub/admin-web/src/routes/+layout.svelte | 1 + .../org/[slug]/capabilities/+page.svelte | 202 +++++++++++++ hub/package.json | 2 +- .../routes/capabilityConnectionRoutes.ts | 131 +++++++++ hub/src/admin/routes/orgRoutes.ts | 11 + .../capability/capabilityConnectionService.ts | 266 ++++++++++++++++++ hub/src/capability/capabilityReadiness.ts | 69 +++++ 8 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte create mode 100644 hub/src/admin/routes/capabilityConnectionRoutes.ts create mode 100644 hub/src/capability/capabilityConnectionService.ts create mode 100644 hub/src/capability/capabilityReadiness.ts diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index a037efb..c98cc39 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -168,6 +168,16 @@ export interface FeishuApplicationConnection { updatedAt: string; } +export interface CapabilityConnection { + id: string; + capabilityId: string; + status: 'DRAFT' | 'ACTIVE' | 'DISABLED'; + activeVersion: number | null; + keyId: string | null; + createdAt: string; + updatedAt: string; +} + export interface UsageTotals { runCount: number; runsWithCost: number; @@ -381,6 +391,21 @@ export const api = { disableFeishuApplication: (slug: string) => del(`${orgBase(slug)}/feishu-application-connection`) as Promise, + capabilityConnections: (slug: string) => + get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>, + capabilityConnection: (slug: string, capabilityId: string) => + get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{ + connection: CapabilityConnection | null; + }>, + rotateCapabilityConnection: ( + slug: string, + capabilityId: string, + body: { accessKeyId: string; accessKeySecret: string; endpoint: string }, + ) => + put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise, + disableCapabilityConnection: (slug: string, capabilityId: string) => + del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise, + capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise, setCapacityPolicy: (slug: string, body: { limits: Partial> }) => put(`${orgBase(slug)}/capacity-policy`, body) as Promise, diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index 252a38e..6858858 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -28,6 +28,7 @@ { key: 'skills', label: '技能', icon: 'roles' as const }, { key: 'roles', label: '角色', icon: 'roles' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const }, + { key: 'capabilities', label: '能力', icon: 'provider' as const }, ]; function isAdmin(org: OrgMembership): boolean { diff --git a/hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte new file mode 100644 index 0000000..dd97739 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte @@ -0,0 +1,202 @@ + + + + +{#if loading} + +{:else if error} + +{:else} +
+ {#each KNOWN_CAPABILITIES as cap} + {@const conn = connections.get(cap.id)} +
+
+
+
+

{cap.label}

+ {#if conn} + {statusLabel(conn.status)} + {:else} + 未配置 + {/if} +
+

{cap.description}

+

{cap.id}

+
+
+ {#if conn?.status === 'ACTIVE'} + + {/if} + +
+
+ + {#if conn} +
+
+
版本
+
{conn.activeVersion ?? '—'}
+
+
+
密钥 ID
+
{conn.keyId ?? '—'}
+
+
+
更新时间
+
{fmtDate(conn.updatedAt)}
+
+
+ {/if} + + {#if editingCap === cap.id} +
+

+ 阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。 +

+
+
+ AccessKey ID + +
+
+ AccessKey Secret + +
+
+ Endpoint + +
+
+
+ + +
+
+ {/if} +
+ {/each} +
+{/if} diff --git a/hub/package.json b/hub/package.json index 3cbdf44..d5b108b 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "engines": { diff --git a/hub/src/admin/routes/capabilityConnectionRoutes.ts b/hub/src/admin/routes/capabilityConnectionRoutes.ts new file mode 100644 index 0000000..60eac11 --- /dev/null +++ b/hub/src/admin/routes/capabilityConnectionRoutes.ts @@ -0,0 +1,131 @@ +/** + * 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 { + 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; + 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, + }; +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 15117db..12bdcf4 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -22,6 +22,8 @@ import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js"; import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js"; +import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js"; +import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js"; export interface OrgRouteConfig { readonly prisma: PrismaClient; @@ -30,6 +32,7 @@ export interface OrgRouteConfig { readonly secretEnvelope: LocalSecretEnvelope; readonly providerReadinessProbe?: ProviderReadinessProbe; readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe; + readonly capabilityReadinessProbe?: CapabilityReadinessProbe; } export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise { @@ -138,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo ? { readinessProbe: config.feishuConnectionReadinessProbe } : {}), }); + await registerCapabilityConnectionRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + secretEnvelope: config.secretEnvelope, + ...(config.capabilityReadinessProbe !== undefined + ? { readinessProbe: config.capabilityReadinessProbe } + : {}), + }); } diff --git a/hub/src/capability/capabilityConnectionService.ts b/hub/src/capability/capabilityConnectionService.ts new file mode 100644 index 0000000..9967b63 --- /dev/null +++ b/hub/src/capability/capabilityConnectionService.ts @@ -0,0 +1,266 @@ +/** + * 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; +} diff --git a/hub/src/capability/capabilityReadiness.ts b/hub/src/capability/capabilityReadiness.ts new file mode 100644 index 0000000..5da8f6e --- /dev/null +++ b/hub/src/capability/capabilityReadiness.ts @@ -0,0 +1,69 @@ +/** + * ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind + * credential by calling QueryDocParserStatus with a dummy id — a 400 (bad + * request) means the credential is valid (the API accepted auth but rejected + * the id); a 401/403 means the credential is bad. + */ +import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js"; + +export interface CapabilityReadinessInput { + readonly endpoint: string; + readonly accessKeyId: string; + readonly accessKeySecret: string; +} + +export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise; + +export class CapabilityReadinessError extends Error { + constructor( + readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected", + message: string, + readonly category: NetworkFailureCategory | "configuration" | "http", + readonly upstreamStatus?: number, + ) { + super(message); + this.name = "CapabilityReadinessError"; + } +} + +/** + * Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus + * with a dummy id. The API will return: + * - 400 (InvalidParameter) → credential valid, just a bad id → probe passes + * - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails + * - network error → unreachable + */ +export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => { + const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`; + const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret); + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { authorization: authHeader, accept: "application/json" }, + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + throw new CapabilityReadinessError( + "capability_readiness_unreachable", + "docmind credential readiness check could not reach the API", + classifyNetworkFailure(error), + ); + } + await response.body?.cancel(); + if (response.status === 401 || response.status === 403) { + throw new CapabilityReadinessError( + "capability_readiness_rejected", + `docmind credential rejected: status ${response.status}`, + "http", + response.status, + ); + } +}; + +function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string { + const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64"); + return `Basic ${credentials}`; +}