import type { Request, RequestHandler, Response } from "express"; import express from "express"; import { LOCAL_RUNTIME_API_VERSION, LOCAL_RUNTIME_ID, type LocalRuntimeModuleDescriptor, type LocalRuntimeModuleId, type LocalRuntimeScope, } from "./local-runtime-contract.js"; import { assertExactKeys, exactAuthorityGuard, noStore, protectedCorsHeaders, legacyOrSignedRuntimeGuard, runtimeCors, runtimeErrorHandler, signedRuntimeGuard, strictJsonObject, trustedOriginGuard, type RuntimeCorsPolicy, } from "./local-runtime-security.js"; import { LocalRuntimeSessionError, type LocalRuntimeSessionManager, } from "./local-runtime-session.js"; export type LocalRuntimeProtectedRoute = { method: "GET" | "POST"; path: string; scope: LocalRuntimeScope; handler: RequestHandler; lastEventId?: boolean; queryKeys?: readonly string[]; legacy?: boolean; }; export type LocalRuntimeModule = { descriptor: LocalRuntimeModuleDescriptor; routes: readonly LocalRuntimeProtectedRoute[]; start?: () => void | Promise; onRuntimeSessionRevoked?: (sessionId: string) => void; dispose?: () => void | Promise; publicHealth?: () => Record; }; export type CreateLocalRuntimeAppOptions = { authority: string; endpoint: string; version: string; sessionManager: LocalRuntimeSessionManager; modules: readonly LocalRuntimeModule[]; legacyMasterToken?: string; legacyOrigins?: readonly string[]; }; export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions) { const modules = validateModules(options.modules); const policies = corsPolicies(modules, options.legacyOrigins ?? []); const app = express(); app.disable("x-powered-by"); app.use(express.raw({ type: "application/json", limit: "30mb", })); app.use(noStore); app.use(exactAuthorityGuard(options.authority)); app.use(runtimeCors(policies, options.sessionManager)); app.get("/runtime/info", (req, res) => { const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""; res.json({ runtime: LOCAL_RUNTIME_ID, apiVersion: LOCAL_RUNTIME_API_VERSION, protocolVersion: "framefield-runtime-session-v1", runtimeInstanceId: options.sessionManager.runtimeInstanceId, originTrusted: options.sessionManager.isTrustedOrigin(origin), }); }); app.get("/health", (_req, res) => { const health = modules.reduce>((result, module) => { if (module.publicHealth) Object.assign(result, module.publicHealth()); return result; }, {}); res.json({ ok: true, ...health }); }); app.get("/config", (_req, res) => res.json({ ok: true, url: options.endpoint, hasToken: Boolean(options.legacyMasterToken), })); app.post( "/runtime/session/challenge", trustedOriginGuard(options.sessionManager), route((req, res) => { const body = strictJsonObject(req); if ("publicKeyJwk" in body) { assertExactKeys(body, ["publicKeyJwk"]); res.json(options.sessionManager.createChallenge(requiredOrigin(req), { publicKeyJwk: body.publicKeyJwk as JsonWebKey, })); return; } assertExactKeys(body, ["keyId"]); if (typeof body.keyId !== "string") throw requestInvalid(); res.json(options.sessionManager.createChallenge(requiredOrigin(req), { keyId: body.keyId })); }), ); app.post( "/runtime/session/exchange", trustedOriginGuard(options.sessionManager), route((req, res) => { const body = strictJsonObject(req); assertExactKeys(body, ["challengeId", "signature"]); if (typeof body.challengeId !== "string" || typeof body.signature !== "string") { throw requestInvalid(); } res.json(options.sessionManager.exchange(requiredOrigin(req), { challengeId: body.challengeId, signature: body.signature, })); }), ); app.get( "/runtime/status", signedRuntimeGuard(options.sessionManager, "runtime:status"), (_req, res) => res.json({ ok: true, runtime: { id: LOCAL_RUNTIME_ID, version: options.version, apiVersion: LOCAL_RUNTIME_API_VERSION, }, modules: modules.map((module) => ({ ...module.descriptor })), }), ); app.post( "/runtime/session/revoke", signedRuntimeGuard(options.sessionManager, "runtime:revoke"), route((req, res) => { assertExactKeys(strictJsonObject(req), []); options.sessionManager.revokeSession(requiredRuntimeSessionId(res)); res.json({ ok: true }); }), ); app.post( "/runtime/session/registration/revoke", signedRuntimeGuard(options.sessionManager, "runtime:revoke"), route((req, res) => { assertExactKeys(strictJsonObject(req), []); options.sessionManager.revokeRegistration(requiredRuntimeSessionId(res)); res.json({ ok: true }); }), ); for (const module of modules) { for (const item of module.routes) { const guard = item.legacy ? legacyOrSignedRuntimeGuard(options.sessionManager, item.scope, { queryKeys: item.queryKeys, masterToken: options.legacyMasterToken, origins: options.legacyOrigins, }) : signedRuntimeGuard(options.sessionManager, item.scope, { queryKeys: item.queryKeys, }); const handlers = [ guard, item.handler, ]; if (item.method === "GET") app.get(item.path, ...handlers); else app.post(item.path, ...handlers); } } app.use((_req, res) => res.status(404).json({ ok: false, code: "not_found", message: "未找到本机运行时路由" })); app.use(runtimeErrorHandler); return app; } function validateModules(modules: readonly LocalRuntimeModule[]) { const ids = new Set(); const routes = new Set(); for (const module of modules) { if (module.descriptor.id !== "canvas-agent" && module.descriptor.id !== "dreamina") { throw new Error(`Unsupported Local Runtime module id: ${module.descriptor.id}`); } if (ids.has(module.descriptor.id)) throw new Error(`Duplicate Local Runtime module id: ${module.descriptor.id}`); ids.add(module.descriptor.id); for (const item of module.routes) { const key = `${item.method} ${item.path}`; if (routes.has(key)) throw new Error(`Duplicate Local Runtime route: ${key}`); if (!item.path.startsWith("/") || item.path.includes("?") || item.path.split("/").some((segment) => segment.includes(":") && !/^:[A-Za-z][A-Za-z0-9]*$/.test(segment))) { throw new Error(`Invalid Local Runtime route: ${key}`); } if (!module.descriptor.scopes.includes(item.scope)) { throw new Error(`Undeclared Local Runtime route scope: ${key}`); } routes.add(key); } } return [...modules]; } function corsPolicies(modules: readonly LocalRuntimeModule[], legacyOrigins: readonly string[]) { const policies = new Map([ ["/runtime/info", { methods: ["GET"], headers: [], publicInfo: true }], ["/health", { methods: ["GET"], headers: [], legacyOrigins }], ["/config", { methods: ["GET"], headers: [], legacyOrigins }], ["/runtime/session/challenge", { methods: ["POST"], headers: ["content-type"], trustedOrigin: true }], ["/runtime/session/exchange", { methods: ["POST"], headers: ["content-type"], trustedOrigin: true }], ["/runtime/status", { methods: ["GET"], headers: protectedCorsHeaders("GET"), trustedOrigin: true }], ["/runtime/session/revoke", { methods: ["POST"], headers: protectedCorsHeaders("POST"), trustedOrigin: true }], ["/runtime/session/registration/revoke", { methods: ["POST"], headers: protectedCorsHeaders("POST"), trustedOrigin: true }], ]); for (const module of modules) { for (const item of module.routes) { policies.set(item.path, { methods: [item.method], headers: protectedCorsHeaders(item.method, item.lastEventId), trustedOrigin: true, ...(item.legacy ? { legacyOrigins, legacyHeaders: item.method === "POST" ? ["content-type", "x-canvas-agent-token"] : ["x-canvas-agent-token"], } : {}), }); } } return policies; } function route(handler: (req: Request, res: Response) => void | Promise): RequestHandler { return (req, res, next) => void Promise.resolve(handler(req, res)).catch(next); } function requiredOrigin(req: Request) { const value = req.headers.origin; if (typeof value !== "string") throw requestInvalid(); return value; } function requiredRuntimeSessionId(res: Response) { const value = (res.locals.runtimeSession as { sessionId?: unknown } | undefined)?.sessionId; if (typeof value !== "string" || !value) throw requestInvalid(); return value; } function requestInvalid() { return new LocalRuntimeSessionError("request_invalid", "请求字段无效", 400); }