modules/{auth-settings,sources-identity,acquisition-organize,agent} placeholders listed in
SERVER_MODULES; ServerModule registers routes (under /api/v1, authenticated), tasks,
outbox routes, capabilities and background loops. bootstrap() builds ServerContext (db,
config, logger, events, engine, outbox writer, secrets placeholder, settings reader,
capability registry), fills unregistered tasks with stubs and rejects duplicate task or
outbox ownership. worker starts the OpenWorkflow worker, OutboxRelay and module start().
--role migrate also migrates the engine schema; api/worker connect without migrating.
tasks: TaskRegistry.has, migrateEngineSchema.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
import type { DB } from "@umt/db";
|
|
import { type Kysely, sql } from "@umt/db";
|
|
import { cors } from "hono/cors";
|
|
import { HTTPException } from "hono/http-exception";
|
|
import { requestId } from "hono/request-id";
|
|
import type { Logger } from "pino";
|
|
import type { Config } from "../config.ts";
|
|
import { isDev } from "../config.ts";
|
|
import { redactString } from "../logger.ts";
|
|
import type { ServerContext, ServerModule } from "../modules/types.ts";
|
|
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth.ts";
|
|
import { ApiException, errorBody } from "./errors.ts";
|
|
import { healthRoutes } from "./routes/health.ts";
|
|
import { mountOpenApi } from "./routes/openapi.ts";
|
|
import { type EventBusPort, sseRoutes } from "./sse.ts";
|
|
import { mountWebRoot } from "./static.ts";
|
|
|
|
export interface AppDeps {
|
|
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
|
|
logger: Logger;
|
|
bus: EventBusPort;
|
|
version: string;
|
|
role: "api" | "worker" | "all";
|
|
startedAt?: number;
|
|
db?: Kysely<DB>;
|
|
/** 测试可注入;默认用 db 构造 */
|
|
apiKeyStore?: ApiKeyStore;
|
|
/** 单容器模式:托管 web 静态产物的目录 */
|
|
webRoot?: string;
|
|
/** 业务模块(modules/index.ts 的 SERVER_MODULES);给了 ctx 才挂载 */
|
|
modules?: readonly ServerModule[];
|
|
ctx?: ServerContext;
|
|
}
|
|
|
|
export const API_PREFIX = "/api/v1";
|
|
|
|
export function createApp(deps: AppDeps) {
|
|
const { config, logger, bus, db } = deps;
|
|
const dev = isDev(config);
|
|
|
|
const app = newApiApp();
|
|
|
|
app.use("*", requestId());
|
|
// 访问日志:路径中的 passkey/token 参数脱敏
|
|
app.use("*", async (c, next) => {
|
|
const t0 = performance.now();
|
|
await next();
|
|
logger.info(
|
|
{
|
|
reqId: c.get("requestId"),
|
|
method: c.req.method,
|
|
url: redactString(c.req.url.replace(/^https?:\/\/[^/]+/, "")),
|
|
status: c.res.status,
|
|
ms: Math.round(performance.now() - t0),
|
|
},
|
|
"http",
|
|
);
|
|
});
|
|
if (dev) {
|
|
app.use("/api/*", cors({ origin: ["http://localhost:5180", "http://127.0.0.1:5180"], credentials: true }));
|
|
}
|
|
|
|
app.onError((err, c) => {
|
|
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
|
|
if (err instanceof HTTPException) {
|
|
const code = err.status === 401 ? "unauthorized" : err.status === 404 ? "not_found" : "bad_request";
|
|
return c.json(errorBody(code, err.message), err.status);
|
|
}
|
|
logger.error({ err, reqId: c.get("requestId") }, "unhandled error");
|
|
return c.json(errorBody("internal", "服务器内部错误"), 500);
|
|
});
|
|
app.notFound((c) => c.json(errorBody("not_found", `未找到 ${c.req.method} ${c.req.path}`), 404));
|
|
|
|
// ── 免鉴权 ──
|
|
app.route(
|
|
API_PREFIX,
|
|
healthRoutes({
|
|
version: deps.version,
|
|
role: deps.role,
|
|
startedAt: deps.startedAt ?? Date.now(),
|
|
...(db
|
|
? {
|
|
pingDb: async () => {
|
|
await sql`select 1`.execute(db);
|
|
return true;
|
|
},
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
mountOpenApi(app, deps.version);
|
|
|
|
// ── 需鉴权:/api/v1 下除 health 外全部 ──
|
|
const store = deps.apiKeyStore ?? (db ? kyselyApiKeyStore(db) : null);
|
|
const auth = apiKeyAuth({ store, devAdminKey: dev ? config.DEV_ADMIN_KEY : undefined });
|
|
app.use(`${API_PREFIX}/*`, async (c, next) => {
|
|
if (c.req.path === `${API_PREFIX}/health`) return next();
|
|
return auth(c, next);
|
|
});
|
|
|
|
app.use(`${API_PREFIX}/events`, requireScope("read"));
|
|
app.route(API_PREFIX, sseRoutes({ bus }));
|
|
|
|
// 鉴权自检:返回当前调用者
|
|
app.get(`${API_PREFIX}/auth/whoami`, requireScope("read"), (c) => {
|
|
const p = c.get("principal");
|
|
return c.json({ ok: true, data: { id: p.id, name: p.name, kind: p.kind, scopes: p.scopes } });
|
|
});
|
|
|
|
// ── 业务模块:各自挂在 /api/v1 下(已鉴权),按清单顺序注册 ──
|
|
if (deps.ctx && deps.modules?.length) {
|
|
const api = newApiApp();
|
|
for (const m of deps.modules) m.registerRoutes(api, deps.ctx);
|
|
app.route(API_PREFIX, api);
|
|
}
|
|
|
|
if (deps.webRoot) mountWebRoot(app, deps.webRoot);
|
|
|
|
return app;
|
|
}
|
|
export type App = ReturnType<typeof createApp>;
|
|
|
|
/** 统一的 OpenAPIHono 实例:zod 校验失败统一成 validation_failed 信封 */
|
|
function newApiApp() {
|
|
return new OpenAPIHono<AuthEnv>({
|
|
defaultHook: (result, c) => {
|
|
if (!result.success) {
|
|
const msg = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
return c.json(errorBody("validation_failed", msg), 422);
|
|
}
|
|
},
|
|
});
|
|
}
|