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,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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user