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; logger: Logger; bus: EventBusPort; version: string; role: "api" | "worker" | "all"; startedAt?: number; db?: Kysely; /** 测试可注入;默认用 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; /** 统一的 OpenAPIHono 实例:zod 校验失败统一成 validation_failed 信封 */ function newApiApp() { return new OpenAPIHono({ 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); } }, }); }