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:
umtandClaude Fable 5.1 committed 2026-09-27 17:36:26 +08:00
commit b22c440139
138 files changed
+14455

No files matched your search

+114
View File
@@ -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>;
+99
View File
@@ -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();
};
}
+15
View File
@@ -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 } });
+46
View File
@@ -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;
}
+18
View File
@@ -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" }));
}
+106
View File
@@ -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;
}