import 'server-only'; import type { SomaHostRequestContext, SomaRequestHook, } from 'soma-core/host/request-hook'; import { resolveTrustedRequestContext, trustedActorKey, } from '../../api/common/TrustedRequestContext'; // 使用正式 `soma-core/server/registry` 子路径:Host webpack 与 jiti 都通过包导出 // 解析,不能再穿透 monorepo 目录结构。 import { isRegistryDemoOpenDoor, REGISTRY_TOKEN_ENV, } from 'soma-core/server/registry'; export const TRUSTED_REGISTRY_ACTORS_ENV = 'SOMA_WORKBENCH_TRUSTED_ACTORS'; export const SOMA_PAGE_HREF_HEADER = 'x-soma-page-href'; // 注册中心的**全部**写端点。漏一条的后果很隐蔽:demo 靠 SOMA_REGISTRY_OPEN=1 // 让网关整体让位,所以本地怎么点都是绿的;一旦部署配了 SOMA_REGISTRY_TOKEN, // 网关不会替浏览器注入 Bearer,工作台点提交就拿 401——而且只在配了 token 的 // 环境才复现。REQ-002 迁移时只搬了 pages 三条,apis 两条是后加的端点,漏在了这里。 const WRITE_PATHS = new Set([ '/api/soma-registry/pages/register', '/api/soma-registry/pages/rollback', '/api/soma-registry/pages/unregister', '/api/soma-registry/apis/register', '/api/soma-registry/apis/unregister', ]); export interface TrustedPageRegistryGatewayOptions { /** 严格管理来源:管理请求缺少可信身份时直接拒绝(缺省仅 /workbench)。 */ sourcePages?: readonly string[]; /** * 可选只读提权来源:可信身份可读取草稿/归档;匿名则原样下沉,仍可读取 published。 * 该集合永远不会获得写权限。 */ optionalReadSourcePages?: readonly string[]; actorKeys?: readonly string[]; registryToken?: string; } interface ManagementRequest { kind: 'read' | 'write'; consumesNonceDownstream: boolean; } export function createTrustedPageRegistryGateway( options: TrustedPageRegistryGatewayOptions = {}, ): SomaRequestHook { const sourcePages = new Set(options.sourcePages ?? ['/workbench']); const optionalReadSourcePages = new Set(options.optionalReadSourcePages ?? []); return async (request, context) => { const sourcePage = normalizePath(request.headers.get(SOMA_PAGE_HREF_HEADER)); if (!sourcePage) return undefined; const management = classifyManagementRequest(request, context); if (!management) return undefined; const strictSource = sourcePages.has(sourcePage); const optionalReadSource = management.kind === 'read' && optionalReadSourcePages.has(sourcePage); if (!strictSource && !optionalReadSource) return undefined; // 演示门(RegistrySubjects:未配凭据 + 显式 SOMA_REGISTRY_OPEN=1 + 非生产)下网关让位: // 它的全部职责是替浏览器注入服务端凭据,开放模式没有凭据可注入, // 下游会把匿名调用方解析成全权 demo 主体("下游只认主体")。生产该门恒不生效,fail-closed 不变。 if (isRegistryDemoOpenDoor()) return undefined; const actor = await resolveTrustedRequestContext(request, { consumeNonce: !management.consumesNonceDownstream, }); const actorKey = trustedActorKey(actor); const allowedActors = new Set(options.actorKeys ?? parseActorKeys()); if (!actorKey || !allowedActors.has(actorKey)) { // 独立发布页是公开产品入口:可信 actor 才升级为私有版本读取,匿名必须继续 // 下沉给 PageService,让 published 可读、draft/archived 按既有 404 闸收口。 if (optionalReadSource) return undefined; return Response.json( { success: false, message: '工作台管理操作需要可信且已授权的请求身份' }, { status: 403 }, ); } const token = (options.registryToken ?? process.env[REGISTRY_TOKEN_ENV] ?? '').trim(); if (!token) { console.error('[soma-ai][注册网关] 工作台网关:注册表服务端凭据未配置,管理请求已拒绝'); return Response.json( { success: false, message: '工作台管理服务暂不可用' }, { status: 503 }, ); } request.headers.set('authorization', `Bearer ${token}`); return undefined; }; } function classifyManagementRequest( request: Request, context: SomaHostRequestContext, ): ManagementRequest | null { if (context.kind !== 'api') return null; const method = request.method.toUpperCase(); if (method === 'POST' && WRITE_PATHS.has(context.path)) { return { kind: 'write', consumesNonceDownstream: true }; } if (method !== 'GET') return null; const url = new URL(request.url); if (context.path === '/api/soma-registry/pages/list') { const includeArchived = url.searchParams.get('includeArchived'); return includeArchived === '1' || includeArchived === 'true' ? { kind: 'read', consumesNonceDownstream: false } : null; } if (context.path === '/api/soma-registry/pages/get' && url.searchParams.has('version')) { return { kind: 'read', consumesNonceDownstream: false }; } return null; } function parseActorKeys(): string[] { return (process.env[TRUSTED_REGISTRY_ACTORS_ENV] ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); } function normalizePath(value: string | null): string | null { if (!value) return null; try { return new URL(value, 'http://soma.local').pathname; } catch { return null; } }