chore: scaffold monorepo and migrate uhd_web design system (W0-A)
- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
b22c440139
138 files changed
+14455
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# @umt/server
|
||||
|
||||
Node 服务进程:Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@umt/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file-if-exists=../../.env src/main.ts",
|
||||
"start": "node --env-file-if-exists=../../.env dist/main.js",
|
||||
"build": "node scripts/build.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@hono/swagger-ui": "^0.6.1",
|
||||
"@hono/zod-openapi": "^1.6.3",
|
||||
"@umt/contracts": "workspace:*",
|
||||
"@umt/db": "workspace:*",
|
||||
"hono": "^4.13.9",
|
||||
"kysely": "^0.29.6",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.2",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 打包 server:工作区包(@umt/*)以源码内联,npm 依赖保持 external 运行时从 node_modules 加载。
|
||||
// external 依赖必须在本包 package.json 的 dependencies 里声明(工作区包的运行时依赖也要在此重复声明),
|
||||
// 否则运行时解析不到,构建直接报错。
|
||||
import { readFileSync } from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
const deps = new Set(Object.keys(pkg.dependencies ?? {}));
|
||||
const builtins = new Set(builtinModules);
|
||||
const pkgName = (p) => (p.startsWith("@") ? p.split("/").slice(0, 2).join("/") : p.split("/")[0]);
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
outfile: "dist/main.js",
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
format: "esm",
|
||||
sourcemap: true,
|
||||
logLevel: "info",
|
||||
plugins: [
|
||||
{
|
||||
name: "external-npm",
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^[^./]/ }, (args) => {
|
||||
if (args.path.startsWith("node:") || builtins.has(args.path)) return { path: args.path, external: true };
|
||||
const name = pkgName(args.path);
|
||||
if (name.startsWith("@umt/")) return undefined;
|
||||
if (!deps.has(name)) {
|
||||
return {
|
||||
errors: [
|
||||
{ text: `运行时依赖 ${name} 未在 apps/server/package.json dependencies 中声明(来自 ${args.importer})` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]);
|
||||
|
||||
export const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4380),
|
||||
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
|
||||
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
|
||||
LOG_LEVEL: LogLevel.default("info"),
|
||||
/** 仅开发模式生效的管理员 Key */
|
||||
DEV_ADMIN_KEY: z.string().min(8).optional(),
|
||||
/** worker 心跳间隔 */
|
||||
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000),
|
||||
});
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const parsed = ConfigSchema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
|
||||
throw new Error(`配置无效:\n${lines.join("\n")}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const isDev = (c: Pick<Config, "NODE_ENV">) => c.NODE_ENV !== "production";
|
||||
@@ -0,0 +1,114 @@
|
||||
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";
|
||||
import { isDev } from "../config";
|
||||
import { redactString } from "../logger";
|
||||
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth";
|
||||
import { ApiException, errorBody } from "./errors";
|
||||
import { healthRoutes } from "./routes/health";
|
||||
import { mountOpenApi } from "./routes/openapi";
|
||||
import { type EventBus, sseRoutes } from "./sse";
|
||||
|
||||
export interface AppDeps {
|
||||
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
|
||||
logger: Logger;
|
||||
bus: EventBus;
|
||||
version: string;
|
||||
role: "api" | "worker" | "all";
|
||||
startedAt?: number;
|
||||
db?: Kysely<DB>;
|
||||
/** 测试可注入;默认用 db 构造 */
|
||||
apiKeyStore?: ApiKeyStore;
|
||||
}
|
||||
|
||||
export const API_PREFIX = "/api/v1";
|
||||
|
||||
export function createApp(deps: AppDeps) {
|
||||
const { config, logger, bus, db } = deps;
|
||||
const dev = isDev(config);
|
||||
|
||||
const app = new OpenAPIHono<AuthEnv>({
|
||||
// zod 校验失败统一成 validation_failed 信封
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 } });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
export type App = ReturnType<typeof createApp>;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { ApiScope } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { ApiException } from "./errors";
|
||||
|
||||
export interface Principal {
|
||||
kind: "api_key" | "dev_admin";
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** API Key 查询接口,便于测试替换 */
|
||||
export interface ApiKeyStore {
|
||||
findByHash(hash: string): Promise<ApiKeyRecord | null>;
|
||||
touch(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex");
|
||||
|
||||
export function kyselyApiKeyStore(db: Kysely<DB>): ApiKeyStore {
|
||||
return {
|
||||
async findByHash(hash) {
|
||||
const row = await db
|
||||
.selectFrom("api_key")
|
||||
.select(["id", "name", "scopes", "revoked_at"])
|
||||
.where("key_hash", "=", hash)
|
||||
.executeTakeFirst();
|
||||
return row ? { id: row.id, name: row.name, scopes: row.scopes, revokedAt: row.revoked_at } : null;
|
||||
},
|
||||
async touch(id) {
|
||||
await db.updateTable("api_key").set({ last_used_at: new Date() }).where("id", "=", id).execute();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthOptions {
|
||||
store: ApiKeyStore | null;
|
||||
/** 仅开发模式传入 */
|
||||
devAdminKey?: string | undefined;
|
||||
}
|
||||
|
||||
export type AuthEnv = { Variables: { principal: Principal } };
|
||||
|
||||
function safeEqual(a: string, b: string) {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
/** Bearer API Key 鉴权:成功后在 c.var.principal 写入调用者 */
|
||||
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
|
||||
return async (c, next) => {
|
||||
const header = c.req.header("authorization") ?? "";
|
||||
const m = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
if (!m?.[1]) throw new ApiException("unauthorized", "缺少 Bearer API Key");
|
||||
const key = m[1];
|
||||
|
||||
if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) {
|
||||
c.set("principal", { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] });
|
||||
return next();
|
||||
}
|
||||
if (!opts.store) throw new ApiException("unauthorized", "API Key 无效");
|
||||
const rec = await opts.store.findByHash(hashApiKey(key));
|
||||
if (!rec || rec.revokedAt) throw new ApiException("unauthorized", "API Key 无效或已撤销");
|
||||
c.set("principal", { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes });
|
||||
// last_used_at 尽力更新,失败不影响请求
|
||||
opts.store.touch(rec.id).catch(() => {});
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/** scope 蕴含关系:admin 覆盖全部;organize.execute 蕴含 organize.read */
|
||||
export function hasScope(granted: readonly string[], required: ApiScope): boolean {
|
||||
if (granted.includes("admin")) return true;
|
||||
if (granted.includes(required)) return true;
|
||||
if (required === "organize.read" && granted.includes("organize.execute")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 要求调用者至少具备其中一个 scope */
|
||||
export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
|
||||
return async (c, next) => {
|
||||
const p = c.get("principal");
|
||||
if (!p) throw new ApiException("unauthorized", "未鉴权");
|
||||
if (!anyOf.some((s) => hasScope(p.scopes, s))) {
|
||||
throw new ApiException("forbidden", `需要 scope: ${anyOf.join(" | ")}`);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { API_ERROR_STATUS, type ApiErrorCode } from "@umt/contracts";
|
||||
|
||||
/** 业务可抛出的 API 错误,由 app.onError 统一转换为信封 */
|
||||
export class ApiException extends Error {
|
||||
readonly status: number;
|
||||
constructor(
|
||||
readonly code: ApiErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.status = API_ERROR_STATUS[code];
|
||||
}
|
||||
}
|
||||
|
||||
export const errorBody = (code: ApiErrorCode, message: string) => ({ ok: false as const, error: { code, message } });
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { apiResponse, HealthStatus } from "@umt/contracts";
|
||||
|
||||
export interface HealthDeps {
|
||||
version: string;
|
||||
role: "api" | "worker" | "all";
|
||||
startedAt: number;
|
||||
/** 返回数据库是否可用;未配置时省略 */
|
||||
pingDb?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/health",
|
||||
tags: ["system"],
|
||||
summary: "服务健康状态(免鉴权)",
|
||||
responses: {
|
||||
200: {
|
||||
description: "服务状态",
|
||||
content: { "application/json": { schema: apiResponse(HealthStatus) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function healthRoutes(deps: HealthDeps) {
|
||||
const app = new OpenAPIHono();
|
||||
app.openapi(route, async (c) => {
|
||||
let db: HealthStatus["db"] = "unknown";
|
||||
if (deps.pingDb) {
|
||||
db = await deps.pingDb().then(
|
||||
(ok) => (ok ? "ok" : "down"),
|
||||
() => "down" as const,
|
||||
);
|
||||
}
|
||||
const data: HealthStatus = {
|
||||
status: db === "down" ? "degraded" : "ok",
|
||||
version: deps.version,
|
||||
role: deps.role,
|
||||
uptimeSec: Math.round((Date.now() - deps.startedAt) / 1000),
|
||||
db,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
return c.json({ ok: true, data }, 200);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { swaggerUI } from "@hono/swagger-ui";
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
|
||||
/** 挂载 /api/openapi.json 与 /api/docs */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 根 app 的 Env 泛型各异
|
||||
export function mountOpenApi(app: OpenAPIHono<any>, version: string) {
|
||||
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
description: "API Key",
|
||||
});
|
||||
app.doc31("/api/openapi.json", {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "UHD Media Tool API", version },
|
||||
security: [{ bearerAuth: [] }],
|
||||
});
|
||||
app.get("/api/docs", swaggerUI({ url: "/api/openapi.json" }));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type EventEnvelope, EventType } from "@umt/contracts";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { ApiException } from "./errors";
|
||||
|
||||
type Listener = (e: EventEnvelope) => void;
|
||||
|
||||
/**
|
||||
* 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。
|
||||
* 多进程部署时需换成 PG LISTEN/NOTIFY 或 outbox 投影(W2)。
|
||||
*/
|
||||
export class EventBus {
|
||||
private seq = 0;
|
||||
private readonly buffer: EventEnvelope[] = [];
|
||||
private readonly listeners = new Set<Listener>();
|
||||
|
||||
constructor(private readonly capacity = 1000) {}
|
||||
|
||||
get latestSeq() {
|
||||
return this.seq;
|
||||
}
|
||||
|
||||
publish<P>(type: string, payload: P): EventEnvelope<P> {
|
||||
const e: EventEnvelope<P> = { seq: ++this.seq, type, at: new Date().toISOString(), payload };
|
||||
this.buffer.push(e);
|
||||
if (this.buffer.length > this.capacity) this.buffer.shift();
|
||||
for (const l of this.listeners) l(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 取 seq > after 的缓冲事件;gap=true 表示请求的起点已被淘汰,客户端需重新拉快照 */
|
||||
since(after: number): { events: EventEnvelope[]; gap: boolean } {
|
||||
const oldest = this.buffer[0]?.seq ?? this.seq + 1;
|
||||
const gap = after > 0 && after < oldest - 1;
|
||||
return { events: this.buffer.filter((e) => e.seq > after), gap };
|
||||
}
|
||||
|
||||
subscribe(l: Listener): () => void {
|
||||
this.listeners.add(l);
|
||||
return () => this.listeners.delete(l);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SseOptions {
|
||||
bus: EventBus;
|
||||
keepaliveMs?: number;
|
||||
}
|
||||
|
||||
/** GET /events?after=<seq>(也接受 Last-Event-ID 头) */
|
||||
export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
|
||||
const app = new Hono();
|
||||
app.get("/events", (c) => {
|
||||
const raw = c.req.query("after") ?? c.req.header("last-event-id") ?? "0";
|
||||
const after = Number(raw);
|
||||
if (!Number.isInteger(after) || after < 0) throw new ApiException("bad_request", "after 必须是非负整数");
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const queue: EventEnvelope[] = [];
|
||||
let wake: (() => void) | null = null;
|
||||
// 先订阅再读缓冲,避免两者之间的事件丢失
|
||||
const unsubscribe = bus.subscribe((e) => {
|
||||
queue.push(e);
|
||||
wake?.();
|
||||
});
|
||||
let closed = false;
|
||||
stream.onAbort(() => {
|
||||
closed = true;
|
||||
wake?.();
|
||||
});
|
||||
|
||||
const { events, gap } = bus.since(after);
|
||||
let last = after;
|
||||
if (gap) {
|
||||
await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq: bus.latestSeq }) });
|
||||
}
|
||||
const write = async (e: EventEnvelope) => {
|
||||
if (e.seq <= last) return;
|
||||
last = e.seq;
|
||||
await stream.writeSSE({ id: String(e.seq), event: e.type, data: JSON.stringify(e) });
|
||||
};
|
||||
for (const e of events) await write(e);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void stream.write(": keepalive\n\n");
|
||||
}, keepaliveMs);
|
||||
try {
|
||||
while (!closed) {
|
||||
while (queue.length) {
|
||||
const e = queue.shift();
|
||||
if (e) await write(e);
|
||||
}
|
||||
if (closed) break;
|
||||
await new Promise<void>((r) => {
|
||||
if (queue.length || closed) r();
|
||||
else wake = r;
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import pino, { type Logger } from "pino";
|
||||
|
||||
export const REDACTED = "[REDACTED]";
|
||||
|
||||
// 字段名包含这些词即视为敏感(数值型如 token 计数除外)
|
||||
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
|
||||
// URL query 中的敏感参数
|
||||
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
|
||||
|
||||
/** 脱敏字符串中的 URL query 参数 */
|
||||
export function redactString(s: string): string {
|
||||
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
|
||||
}
|
||||
|
||||
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
|
||||
export function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 8) return value;
|
||||
if (typeof value === "string") return redactString(value);
|
||||
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
|
||||
else out[k] = redact(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
level?: string;
|
||||
pretty?: boolean;
|
||||
/** 测试用:自定义输出流 */
|
||||
destination?: pino.DestinationStream;
|
||||
}
|
||||
|
||||
export function createLogger(opts: LoggerOptions = {}): Logger {
|
||||
const options: pino.LoggerOptions = {
|
||||
level: opts.level ?? "info",
|
||||
formatters: {
|
||||
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
|
||||
log: (obj) => redact(obj) as Record<string, unknown>,
|
||||
},
|
||||
hooks: {
|
||||
// msg 文本也脱敏
|
||||
logMethod(args, method) {
|
||||
const cleaned = args.map((a) => (typeof a === "string" ? redactString(a) : a)) as typeof args;
|
||||
return method.apply(this, cleaned);
|
||||
},
|
||||
},
|
||||
};
|
||||
if (opts.destination) return pino(options, opts.destination);
|
||||
if (opts.pretty) {
|
||||
return pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } });
|
||||
}
|
||||
return pino(options);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { parseArgs } from "node:util";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createDb } from "@umt/db";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { loadConfig } from "./config";
|
||||
import { createApp } from "./http/app";
|
||||
import { EventBus } from "./http/sse";
|
||||
import { createLogger } from "./logger";
|
||||
import { startWorker } from "./worker";
|
||||
|
||||
const Roles = ["api", "worker", "all"] as const;
|
||||
type Role = (typeof Roles)[number];
|
||||
|
||||
const { values } = parseArgs({ options: { role: { type: "string", default: "all" } }, strict: true });
|
||||
const role = values.role as Role;
|
||||
if (!Roles.includes(role)) {
|
||||
console.error(`--role 必须是 ${Roles.join("|")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let config: ReturnType<typeof loadConfig>;
|
||||
try {
|
||||
config = loadConfig();
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.LOG_LEVEL,
|
||||
pretty: config.NODE_ENV === "development" && process.stdout.isTTY,
|
||||
});
|
||||
const db = createDb({ connectionString: config.DATABASE_URL });
|
||||
const bus = new EventBus();
|
||||
const stops: Array<() => Promise<void>> = [];
|
||||
|
||||
if (role === "api" || role === "all") {
|
||||
const app = createApp({ config, logger, bus, db, role, version: pkg.version });
|
||||
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
|
||||
logger.info({ port: info.port, role }, `API 已启动 http://localhost:${info.port}/api/v1/health`);
|
||||
});
|
||||
stops.push(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
// SSE 长连接不会自行结束,强制关闭
|
||||
(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (role === "worker" || role === "all") {
|
||||
const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus });
|
||||
stops.push(worker.stop);
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown(signal: string) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
logger.info({ signal }, "正在退出");
|
||||
await Promise.allSettled(stops.map((s) => s()));
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import type { Logger } from "pino";
|
||||
import type { EventBus } from "../http/sse";
|
||||
|
||||
export interface WorkerDeps {
|
||||
logger: Logger;
|
||||
heartbeatMs: number;
|
||||
/** role=all 时与 API 共享同一总线,心跳可在 SSE 中看到 */
|
||||
bus?: EventBus;
|
||||
}
|
||||
|
||||
/** worker 占位:只打印心跳。持久任务引擎在 W1(packages/tasks)接入 */
|
||||
export function startWorker({ logger, heartbeatMs, bus }: WorkerDeps) {
|
||||
const log = logger.child({ component: "worker" });
|
||||
let n = 0;
|
||||
const beat = () => {
|
||||
n += 1;
|
||||
log.debug({ n }, "worker heartbeat");
|
||||
bus?.publish(EventType.heartbeat, { role: "worker", n });
|
||||
};
|
||||
log.info({ heartbeatMs }, "worker started");
|
||||
beat();
|
||||
const timer = setInterval(beat, heartbeatMs);
|
||||
return {
|
||||
async stop() {
|
||||
clearInterval(timer);
|
||||
log.info("worker stopped");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasScope } from "../src/http/auth";
|
||||
import { DEV_KEY, json, memoryStore, testApp } from "./helpers";
|
||||
|
||||
const store = memoryStore({
|
||||
"key-read": { id: "k1", scopes: ["read"] },
|
||||
"key-org": { id: "k2", scopes: ["organize.execute"] },
|
||||
"key-revoked": { id: "k3", scopes: ["admin"], revoked: true },
|
||||
});
|
||||
const whoami = (app: ReturnType<typeof testApp>["app"], key?: string) =>
|
||||
app.request("/api/v1/auth/whoami", key ? { headers: { Authorization: `Bearer ${key}` } } : {});
|
||||
|
||||
describe("API Key 鉴权", () => {
|
||||
it("缺少 Key → 401", async () => {
|
||||
const res = await whoami(testApp({ store }).app);
|
||||
expect(res.status).toBe(401);
|
||||
expect((await json(res)).error.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
it("有效 Key 通过,返回 principal", async () => {
|
||||
const res = await whoami(testApp({ store }).app, "key-read");
|
||||
expect(res.status).toBe(200);
|
||||
expect((await json(res)).data.id).toBe("k1");
|
||||
});
|
||||
|
||||
it("未知或已撤销 Key → 401", async () => {
|
||||
const { app } = testApp({ store });
|
||||
expect((await whoami(app, "bogus")).status).toBe(401);
|
||||
expect((await whoami(app, "key-revoked")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("scope 不足 → 403", async () => {
|
||||
const res = await whoami(testApp({ store }).app, "key-org");
|
||||
expect(res.status).toBe(403);
|
||||
expect((await json(res)).error.code).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("开发模式 DEV_ADMIN_KEY 可用,生产模式不可用", async () => {
|
||||
expect((await whoami(testApp({ store }).app, DEV_KEY)).status).toBe(200);
|
||||
expect((await whoami(testApp({ store, nodeEnv: "production" }).app, DEV_KEY)).status).toBe(401);
|
||||
});
|
||||
|
||||
it("scope 蕴含关系", () => {
|
||||
expect(hasScope(["admin"], "organize.execute")).toBe(true);
|
||||
expect(hasScope(["organize.execute"], "organize.read")).toBe(true);
|
||||
expect(hasScope(["organize.read"], "organize.execute")).toBe(false);
|
||||
expect(hasScope(["read"], "organize.read")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { json, testApp } from "./helpers";
|
||||
|
||||
describe("GET /api/v1/health", () => {
|
||||
it("免鉴权返回 ok 信封", async () => {
|
||||
const { app } = testApp();
|
||||
const res = await app.request("/api/v1/health");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await json(res);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.status).toBe("ok");
|
||||
expect(body.data.db).toBe("unknown");
|
||||
expect(body.data.role).toBe("all");
|
||||
});
|
||||
|
||||
it("未知路由返回 not_found 信封", async () => {
|
||||
const { app } = testApp();
|
||||
const res = await app.request("/nope");
|
||||
expect(res.status).toBe(404);
|
||||
expect((await json(res)).error.code).toBe("not_found");
|
||||
});
|
||||
|
||||
it("OpenAPI 文档包含 health", async () => {
|
||||
const { app } = testApp();
|
||||
const doc = await json(await app.request("/api/openapi.json"));
|
||||
expect(doc.paths["/api/v1/health"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { createApp } from "../src/http/app";
|
||||
import { type ApiKeyStore, hashApiKey } from "../src/http/auth";
|
||||
import { EventBus } from "../src/http/sse";
|
||||
import { createLogger } from "../src/logger";
|
||||
|
||||
export const DEV_KEY = "dev-admin-test-key";
|
||||
|
||||
/** 收集日志行 */
|
||||
export function memoryLogger() {
|
||||
const lines: string[] = [];
|
||||
const dest = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(chunk.toString());
|
||||
cb();
|
||||
},
|
||||
});
|
||||
return { logger: createLogger({ level: "info", destination: dest }), lines };
|
||||
}
|
||||
|
||||
/** 内存 API Key 仓库 */
|
||||
export function memoryStore(keys: Record<string, { id: string; scopes: string[]; revoked?: boolean }>): ApiKeyStore {
|
||||
const byHash = new Map(Object.entries(keys).map(([k, v]) => [hashApiKey(k), v]));
|
||||
return {
|
||||
async findByHash(h) {
|
||||
const v = byHash.get(h);
|
||||
return v ? { id: v.id, name: v.id, scopes: v.scopes, revokedAt: v.revoked ? new Date() : null } : null;
|
||||
},
|
||||
async touch() {},
|
||||
};
|
||||
}
|
||||
|
||||
export function testApp(opts: { nodeEnv?: "development" | "production"; store?: ApiKeyStore } = {}) {
|
||||
const bus = new EventBus(5);
|
||||
const { logger, lines } = memoryLogger();
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: opts.nodeEnv ?? "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus,
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
...(opts.store ? { apiKeyStore: opts.store } : {}),
|
||||
});
|
||||
return { app, bus, lines };
|
||||
}
|
||||
|
||||
/** 读取 JSON 响应体(测试里不做类型收窄) */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言用
|
||||
export const json = (res: Response): Promise<any> => res.json();
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redact, redactString } from "../src/logger";
|
||||
import { memoryLogger } from "./helpers";
|
||||
|
||||
describe("日志脱敏", () => {
|
||||
it("URL query 中的 passkey/token 被替换", () => {
|
||||
expect(redactString("https://pt.example/rss?passkey=abc123&x=1")).toBe(
|
||||
"https://pt.example/rss?passkey=[REDACTED]&x=1",
|
||||
);
|
||||
expect(redactString("/dl?id=1&token=zzz")).toBe("/dl?id=1&token=[REDACTED]");
|
||||
});
|
||||
|
||||
it("敏感字段整体替换,数值计数保留", () => {
|
||||
expect(redact({ cookie: "a=b", nested: { Authorization: "Bearer x", password: "p" }, inputTokens: 12 })).toEqual({
|
||||
cookie: "[REDACTED]",
|
||||
nested: { Authorization: "[REDACTED]", password: "[REDACTED]" },
|
||||
inputTokens: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("logger 输出不含明文", () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
logger.info({ url: "https://x/rss?passkey=SECRET1", token: "SECRET2" }, "fetch https://y/?token=SECRET3");
|
||||
logger.error({ err: new Error("GET https://x/dl?passkey=SECRET4 failed") }, "boom");
|
||||
const out = lines.join("");
|
||||
for (const s of ["SECRET1", "SECRET2", "SECRET3", "SECRET4"]) expect(out).not.toContain(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventBus } from "../src/http/sse";
|
||||
import { DEV_KEY, testApp } from "./helpers";
|
||||
|
||||
describe("EventBus", () => {
|
||||
it("序号自增并按 after 补读,淘汰后标记 gap", () => {
|
||||
const bus = new EventBus(3);
|
||||
for (let i = 0; i < 5; i++) bus.publish("t", { i });
|
||||
expect(bus.latestSeq).toBe(5);
|
||||
expect(bus.since(3).events.map((e) => e.seq)).toEqual([4, 5]);
|
||||
expect(bus.since(3).gap).toBe(false);
|
||||
expect(bus.since(1).gap).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/events", () => {
|
||||
it("需要鉴权", async () => {
|
||||
const { app } = testApp();
|
||||
expect((await app.request("/api/v1/events")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("补读 after 之后的事件", async () => {
|
||||
const { app, bus } = testApp();
|
||||
bus.publish("a", 1);
|
||||
bus.publish("b", 2);
|
||||
const ac = new AbortController();
|
||||
const res = await app.request("/api/v1/events?after=1", {
|
||||
headers: { Authorization: `Bearer ${DEV_KEY}` },
|
||||
signal: ac.signal,
|
||||
});
|
||||
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
||||
const reader = res.body!.getReader();
|
||||
const { value } = await reader.read();
|
||||
const text = new TextDecoder().decode(value);
|
||||
expect(text).toContain("id: 2");
|
||||
expect(text).toContain("event: b");
|
||||
expect(text).not.toContain("event: a");
|
||||
ac.abort();
|
||||
await reader.cancel();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in new issue
Block a user