256 lines
9.7 KiB
TypeScript
256 lines
9.7 KiB
TypeScript
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<void>;
|
|
onRuntimeSessionRevoked?: (sessionId: string) => void;
|
|
dispose?: () => void | Promise<void>;
|
|
publicHealth?: () => Record<string, string | number | boolean>;
|
|
};
|
|
|
|
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<Record<string, string | number | boolean>>((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<LocalRuntimeModuleId>();
|
|
const routes = new Set<string>();
|
|
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<string, RuntimeCorsPolicy>([
|
|
["/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<void>): 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);
|
|
}
|