feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus

- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
  (migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
  hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
  SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
  CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
  sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
  {type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
  to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
  downloader/media-server instances (+test), path-mappings (+verify),
  PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
  seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
  admin APIs against mock qB/Emby, SSE across two Deno subprocesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:59:54 +08:00
1 parent 07db0dac5b
commit ff230e5da7
34 files changed
+4363 -78

No files matched your search

@@ -0,0 +1,243 @@
import {
AdminSetupRequest,
CSRF_COOKIE,
LoginRequest,
LoginResult,
PasswordChangeRequest,
PasswordChangeResult,
SESSION_COOKIE,
SessionInfo,
TotpAction,
TotpActionResult,
TotpStatus,
} from "@umt/contracts";
import type { Context } from "hono";
import { deleteCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
adminActor,
type CreatedSession,
changePassword,
createSession,
loadAdmin,
revokeSession,
sessionInfo,
setupAdmin,
totpBegin,
totpDisable,
totpEnable,
totpStatus,
verifyLogin,
} from "./auth-service.ts";
import { audit, type Def, endpoint } from "./endpoint.ts";
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
const tags = ["auth"];
const OkResult = z.object({ ok: z.literal(true) });
const D = {
setup: {
method: "post",
path: "/auth/setup",
scope: "read",
summary: "首次初始化管理员",
body: AdminSetupRequest,
response: SessionInfo,
tags,
},
login: {
method: "post",
path: "/auth/login",
scope: "read",
summary: "管理员登录",
body: LoginRequest,
response: LoginResult,
tags,
},
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
session: {
method: "get",
path: "/auth/session",
scope: "read",
summary: "当前登录态(免鉴权)",
response: SessionInfo,
tags,
},
password: {
method: "post",
path: "/auth/password",
scope: "admin",
summary: "修改密码(吊销其它会话)",
body: PasswordChangeRequest,
response: PasswordChangeResult,
tags,
},
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
totpPost: {
method: "post",
path: "/auth/totp",
scope: "admin",
summary: "TOTP 绑定/启用/停用",
body: TotpAction,
response: TotpActionResult,
tags,
},
} as const satisfies Record<string, Def>;
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
/**
* 客户端 IP:只有直连对端是回环/私网(即前面有自己的反代)时才信 X-Real-IP / X-Forwarded-For 最后一跳,
* 否则用对端地址——防止客户端伪造头绕过 IP 限速(对应 Caddy trusted_proxies_strict)。
*/
export function clientIp(c: Context<AuthEnv>): string {
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
if (remote && !PRIVATE_IP.test(remote)) return remote;
const xri = c.req.header("x-real-ip")?.trim();
const xff = c.req
.header("x-forwarded-for")
?.split(",")
.map((s) => s.trim())
.filter(Boolean)
.at(-1);
return xri || xff || remote || "unknown";
}
function isHttps(c: Context<AuthEnv>): boolean {
if (new URL(c.req.url).protocol === "https:") return true;
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
if (remote && !PRIVATE_IP.test(remote)) return false;
return c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
}
function issueCookies(c: Context<AuthEnv>, s: { token: string; expiresAt: Date }) {
const secure = isHttps(c);
setCookie(c, SESSION_COOKIE, s.token, { httpOnly: true, sameSite: "Lax", path: "/", secure, expires: s.expiresAt });
setCookie(c, CSRF_COOKIE, deriveCsrfToken(s.token), {
httpOnly: false,
sameSite: "Lax",
path: "/",
secure,
expires: s.expiresAt,
});
}
function clearCookies(c: Context<AuthEnv>) {
deleteCookie(c, SESSION_COOKIE, { path: "/" });
deleteCookie(c, CSRF_COOKIE, { path: "/" });
}
export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
const deps = () => ({ db: ctx.db, logger: ctx.logger, secrets: ctx.secrets });
const meta = (c: Context<AuthEnv>) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null });
const startSession = async (c: Context<AuthEnv>, adminId: string): Promise<CreatedSession> => {
const s = await createSession(ctx.db, adminId, meta(c));
issueCookies(c, s);
return s;
};
endpoint(
app,
D.setup,
async ({ body, c }) => {
const admin = await setupAdmin(deps(), body, clientIp(c));
const s = await startSession(c, admin.id);
const actor = adminActor(admin);
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
ctx.logger.info({ username: admin.username }, "管理员已初始化");
return await sessionInfo(ctx.db, actor, s.expiresAt);
},
{ public: true },
);
endpoint(
app,
D.login,
async ({ body, c }) => {
const r = await verifyLogin(deps(), body, clientIp(c));
if (r.status === "totp_required") return { status: "totp_required" as const };
const s = await startSession(c, r.admin.id);
const actor = adminActor(r.admin);
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
},
{ public: true },
);
endpoint(
app,
D.session,
async ({ c }) => {
const session = c.get("session");
// CSRF cookie 丢了(被清)时顺手补发
if (session) {
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
httpOnly: false,
sameSite: "Lax",
path: "/",
secure: isHttps(c),
expires: session.expiresAt,
});
}
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
},
{ public: true },
);
endpoint(app, D.logout, async ({ c, actor }) => {
const session = c.get("session");
if (session) {
await revokeSession(ctx.db, session.id, "logout");
await audit(ctx, actor, "admin.logout", { type: "admin_session", id: session.id });
}
clearCookies(c);
return { ok: true as const };
});
endpoint(
app,
D.password,
async ({ body, c, actor }) => {
const session = c.get("session");
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
const revoked = await changePassword(deps(), body, {
adminUserId: session.adminUserId,
sessionId: session.id,
ip: clientIp(c),
});
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
return { revokedSessions: revoked };
},
{ middleware: [requireAdminSession()] },
);
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
endpoint(
app,
D.totpPost,
async ({ body, actor }) => {
const admin = await loadAdmin(ctx.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
if (body.action === "begin") {
const r = await totpBegin(deps(), admin);
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
}
if (body.action === "enable") {
await totpEnable(deps(), admin, body.code);
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
} else {
await totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
}
return { status: totpStatus(await loadAdmin(ctx.db)) };
},
{ middleware: [requireAdminSession()] },
);
}
@@ -0,0 +1,337 @@
import { randomBytes } from "node:crypto";
import type { Actor, SessionInfo } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { Logger } from "pino";
import { hashSessionToken } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { SecretStore } from "../types.ts";
import {
base32Encode,
dummyPasswordHash,
hashPassword,
randomToken,
sha256Hex,
verifyPassword,
verifyTotp,
} from "./crypto.ts";
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。
/** 固定会话时长 12 小时,无滑动续期 */
export const SESSION_TTL_MS = 12 * 3600_000;
/** 失败限速窗口与阈值(IP、账号各自独立计数) */
export const LOGIN_WINDOW_MS = 15 * 60_000;
export const MAX_FAILURES_PER_IP = 5;
/** 账号维度阈值取 IP 的 3 倍(同 uhd_doc):等于 IP 阈值时,单个 IP 就能把管理员锁在门外 */
export const MAX_FAILURES_PER_ACCOUNT = 15;
const TOTP_ISSUER = "UHD Media Tool";
export interface AuthDeps {
db: Kysely<DB>;
logger: Logger;
secrets: SecretStore;
}
export interface AdminRow {
id: string;
username: string | null;
password_hash: string | null;
totp_secret_ref: string | null;
totp_pending_secret_ref: string | null;
totp_enabled_at: Date | null;
totp_last_counter: number | null;
}
export async function loadAdmin(db: Kysely<DB>): Promise<AdminRow | null> {
const r = await db
.selectFrom("admin_user")
.select([
"id",
"username",
"password_hash",
"totp_secret_ref",
"totp_pending_secret_ref",
"totp_enabled_at",
"totp_last_counter",
])
.executeTakeFirst();
return r ?? null;
}
export const adminActor = (a: { id: string; username: string | null }): Actor => ({
type: "admin",
id: a.id,
name: a.username ?? "admin",
scopes: ["admin"],
});
/**
* 首次启动:还没有管理员时生成一次性 setup token,只存 sha256,明文打印到日志一次。
* 条件 UPSERT:已初始化(password_hash 非空)的行不会被改写;并发启动时各自覆盖,以最后打印的为准。
* 返回明文 token(已初始化则 null)。
*/
export async function ensureSetupToken(deps: Pick<AuthDeps, "db" | "logger">): Promise<string | null> {
const token = randomToken(24);
const r = await sql<{ id: string }>`
insert into admin_user (singleton, setup_token_hash) values (true, ${sha256Hex(token)})
on conflict (singleton) do update set setup_token_hash = excluded.setup_token_hash
where admin_user.password_hash is null
returning id`.execute(deps.db);
if (!r.rows.length) return null;
// 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里
deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`);
return token;
}
// ── 限速 ──
async function recentFailures(db: Kysely<DB>, col: "ip" | "username", value: string): Promise<number> {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const c = sql.ref(col);
const r = await sql<{ n: number }>`
select count(*)::int as n from login_attempt
where ${c} = ${value} and not success and at > ${since}
and at > coalesce((select max(at) from login_attempt where ${c} = ${value} and success), '-infinity')`.execute(
db,
);
return r.rows[0]?.n ?? 0;
}
/** 已达阈值 → 429;被拦下的请求不记尝试、不写审计(防扫描器撑爆表) */
export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> {
const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]);
if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) {
throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试");
}
}
export async function recordAttempt(
db: Kysely<DB>,
a: { ip: string; username: string; success: boolean; reason?: string },
): Promise<void> {
await db
.insertInto("login_attempt")
.values({ ip: a.ip, username: a.username, success: a.success, reason: a.reason ?? null })
.execute();
}
// ── 会话 ──
export interface CreatedSession {
id: string;
token: string;
expiresAt: Date;
}
export async function createSession(
db: Kysely<DB>,
adminUserId: string,
meta: { ip: string; userAgent: string | null },
): Promise<CreatedSession> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
const row = await db
.insertInto("admin_session")
.values({
admin_user_id: adminUserId,
session_hash: hashSessionToken(token),
ip: meta.ip,
user_agent: meta.userAgent?.slice(0, 400) ?? null,
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
return { id: row.id, token, expiresAt };
}
export async function revokeSession(db: Kysely<DB>, id: string, reason: string): Promise<void> {
await db
.updateTable("admin_session")
.set({ revoked_at: new Date(), revoke_reason: reason })
.where("id", "=", id)
.where("revoked_at", "is", null)
.execute();
}
/** 吊销该管理员除 keepId 以外的全部有效会话,返回吊销条数 */
export async function revokeOtherSessions(
db: Kysely<DB>,
adminUserId: string,
keepId: string | null,
reason: string,
): Promise<number> {
let q = db
.updateTable("admin_session")
.set({ revoked_at: new Date(), revoke_reason: reason })
.where("admin_user_id", "=", adminUserId)
.where("revoked_at", "is", null);
if (keepId) q = q.where("id", "!=", keepId);
const r = await q.executeTakeFirst();
return Number(r.numUpdatedRows);
}
// ── setup / login / 改密 ──
export async function setupAdmin(
deps: AuthDeps,
input: { setupToken: string; username: string; password: string },
ip: string,
): Promise<AdminRow> {
await assertNotRateLimited(deps.db, ip, "(setup)");
const passwordHash = await hashPassword(input.password);
const r = await sql<{ id: string }>`
update admin_user set username = ${input.username}, password_hash = ${passwordHash},
setup_token_hash = null, password_changed_at = now()
where password_hash is null and setup_token_hash = ${sha256Hex(input.setupToken)}
returning id`.execute(deps.db);
if (!r.rows.length) {
const admin = await loadAdmin(deps.db);
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)");
}
const admin = await loadAdmin(deps.db);
if (!admin) throw new Error("admin_user 行丢失");
return admin;
}
export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" };
export async function verifyLogin(
deps: AuthDeps,
input: { username: string; password: string; totpCode?: string | undefined },
ip: string,
): Promise<LoginOutcome> {
await assertNotRateLimited(deps.db, ip, input.username);
const admin = await loadAdmin(deps.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化");
const userOk = admin.username === input.username;
// 用户名不对也跑一次 argon2,避免用时差探测账号
let pwOk = false;
if (userOk) pwOk = await verifyPassword(input.password, admin.password_hash);
else await verifyPassword(input.password, await dummyPasswordHash());
if (!pwOk) {
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" });
throw new ApiException("unauthorized", "用户名或密码错误");
}
if (admin.totp_enabled_at && admin.totp_secret_ref) {
// 密码正确但缺第二步:不计失败
if (!input.totpCode) return { status: "totp_required" };
if (!(await consumeTotp(deps, admin, input.totpCode))) {
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
throw new ApiException("unauthorized", "验证码错误");
}
}
await recordAttempt(deps.db, { ip, username: input.username, success: true });
return { status: "ok", admin };
}
/** 校验并消费 TOTP 计数器(条件更新防并发重放) */
async function consumeTotp(deps: AuthDeps, admin: AdminRow, code: string): Promise<boolean> {
if (!admin.totp_secret_ref) return false;
const secret = await deps.secrets.resolve(admin.totp_secret_ref);
const counter = verifyTotp(secret, code, admin.totp_last_counter);
if (counter === null) return false;
const r = await sql`update admin_user set totp_last_counter = ${counter}
where id = ${admin.id} and (totp_last_counter is null or totp_last_counter < ${counter})`.execute(deps.db);
return Number(r.numAffectedRows ?? 0) > 0;
}
export async function changePassword(
deps: AuthDeps,
input: { currentPassword: string; newPassword: string },
ctx: { adminUserId: string; sessionId: string; ip: string },
): Promise<number> {
const admin = await loadAdmin(deps.db);
if (!admin?.password_hash || admin.id !== ctx.adminUserId) throw new ApiException("unauthorized", "会话无效");
await assertNotRateLimited(deps.db, ctx.ip, admin.username ?? "");
if (!(await verifyPassword(input.currentPassword, admin.password_hash))) {
await recordAttempt(deps.db, {
ip: ctx.ip,
username: admin.username ?? "",
success: false,
reason: "bad_current_password",
});
throw new ApiException("unauthorized", "当前密码错误");
}
const hash = await hashPassword(input.newPassword);
await deps.db
.updateTable("admin_user")
.set({ password_hash: hash, password_changed_at: new Date() })
.where("id", "=", admin.id)
.execute();
return await revokeOtherSessions(deps.db, admin.id, ctx.sessionId, "password_change");
}
// ── TOTP 管理 ──
export function totpStatus(admin: AdminRow | null) {
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref };
}
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> {
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
const secret = base32Encode(randomBytes(20));
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
const old = admin.totp_pending_secret_ref;
await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute();
if (old) await deps.secrets.remove(old).catch(() => {});
const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`);
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
return { uri, secret };
}
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥");
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
const counter = verifyTotp(secret, code, null);
if (counter === null) throw new ApiException("unauthorized", "验证码错误");
const old = admin.totp_secret_ref;
await deps.db
.updateTable("admin_user")
.set({
totp_secret_ref: admin.totp_pending_secret_ref,
totp_pending_secret_ref: null,
totp_enabled_at: new Date(),
totp_last_counter: counter,
})
.where("id", "=", admin.id)
.execute();
if (old) await deps.secrets.remove(old).catch(() => {});
}
export async function totpDisable(
deps: AuthDeps,
admin: AdminRow,
input: { password: string; code: string },
): Promise<void> {
if (!admin.totp_enabled_at || !admin.totp_secret_ref) throw new ApiException("precondition_failed", "TOTP 未启用");
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
throw new ApiException("unauthorized", "密码错误");
}
if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误");
const ref = admin.totp_secret_ref;
await deps.db
.updateTable("admin_user")
.set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null })
.where("id", "=", admin.id)
.execute();
await deps.secrets.remove(ref).catch(() => {});
}
export async function sessionInfo(
db: Kysely<DB>,
actor: Actor | undefined,
expiresAt: Date | null,
): Promise<SessionInfo> {
const admin = await loadAdmin(db);
return {
initialized: !!admin?.password_hash,
authenticated: !!actor,
actor: actor ?? null,
expiresAt: expiresAt?.toISOString() ?? null,
totpEnabled: !!admin?.totp_enabled_at,
};
}
@@ -0,0 +1,112 @@
import { createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
import { argon2id, argon2Verify } from "hash-wasm";
// 认证用密码学原语。argon2id 用 hash-wasm(WASM,纯 JS 装载):
// @node-rs/argon2 / @denosaurs/argon2 都是原生库,需要 --allow-ffi,而编译后的 umt-server 只授予
// net/env/sys/read 权限;hash-wasm 输出标准 PHC 串,与 Bun.password / @node-rs/argon2 互认。
/** OWASP 推荐参数(与 uhd_doc 相同):m=19 MiB, t=2, p=1 */
export const ARGON2_PARAMS = { memorySize: 19_456, iterations: 2, parallelism: 1, hashLength: 32 } as const;
export async function hashPassword(password: string): Promise<string> {
return await argon2id({ password, salt: randomBytes(16), ...ARGON2_PARAMS, outputType: "encoded" });
}
export async function verifyPassword(password: string, digest: string): Promise<boolean> {
try {
return await argon2Verify({ password, hash: digest });
} catch {
return false;
}
}
/** 预先算好的假哈希:账号不存在时也跑一次校验,避免用时差探测账号 */
let dummyHash: Promise<string> | null = null;
export function dummyPasswordHash(): Promise<string> {
dummyHash ??= hashPassword(randomToken(16));
return dummyHash;
}
export const sha256Hex = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
/** 高熵随机 token(base64url) */
export const randomToken = (bytes = 32) => randomBytes(bytes).toString("base64url");
export function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
/** HKDF-SHA256 从主密钥派生 32 字节子密钥 */
export function deriveKey(masterKey: string, salt: Uint8Array, info: string): Buffer {
return Buffer.from(hkdfSync("sha256", Buffer.from(masterKey, "utf8"), salt, Buffer.from(info, "utf8"), 32));
}
// ── TOTP(RFC 6238:SHA1 / 6 位 / 30 秒),纯 node:crypto ──
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Uint8Array): string {
let bits = 0;
let value = 0;
let out = "";
for (const b of buf) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
const clean = s.replace(/=+$/, "").replace(/\s+/g, "").toUpperCase();
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error("invalid base32");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
export const TOTP_STEP_SEC = 30;
export function totpAt(secretB32: string, counter: number): string {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac("sha1", base32Decode(secretB32)).update(msg).digest();
const off = h[h.length - 1]! & 0xf;
const code = ((h.readUInt32BE(off) & 0x7fffffff) % 1_000_000).toString();
return code.padStart(6, "0");
}
export const totpCounter = (nowMs = Date.now()) => Math.floor(nowMs / 1000 / TOTP_STEP_SEC);
/**
* 校验验证码(容忍前后各 1 个步长);返回命中的计数器,调用方需持久化并拒绝 ≤ 已用计数器(防重放)。
*/
export function verifyTotp(
secretB32: string,
code: string,
lastCounter: number | null,
nowMs = Date.now(),
): number | null {
const now = totpCounter(nowMs);
for (const c of [now - 1, now, now + 1]) {
if (lastCounter !== null && c <= lastCounter) continue;
if (safeEqual(totpAt(secretB32, c), code)) return c;
}
return null;
}
@@ -0,0 +1,225 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import {
type Actor,
type ApiScope,
type AuditAction,
apiResponse,
CursorQuery,
cursorPage,
type EndpointDef,
IdParam,
} from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { Context, MiddlewareHandler } from "hono";
import type { Logger } from "pino";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp } from "../types.ts";
// 本模块路由的薄封装:用 contracts 的 EndpointDef(Api.xxx 或本地同形定义)注册 OpenAPI 路由,
// 自动挂 requireScope、校验入参,并按响应 schema parse 出参(剥掉多余列,防止 key_hash 之类泄出)。
export type Def = Omit<EndpointDef, "scope"> & { scope: ApiScope; tags?: string[] };
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
export interface HandlerInput<D extends Def> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
c: Context<AuthEnv>;
actor: Actor;
}
export function endpoint<D extends Def>(
app: ModuleApp,
def: D,
/** 返回值按 def.response parse(剥掉多余列;不符合合同即 500) */
handler: (input: HandlerInput<D>) => Promise<unknown>,
extra: {
middleware?: MiddlewareHandler<AuthEnv>[];
status?: 200 | 201;
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */
public?: boolean;
} = {},
) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const status = extra.status ?? 200;
const route = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: def.tags ?? ["auth-settings"],
middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never,
request,
responses: {
[status]: {
description: def.summary,
content: { "application/json": { schema: apiResponse(def.response) } },
},
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(route, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
c,
actor: c.get("actor"),
} as HandlerInput<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, status);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
/** 标准资源端点定义(与 contracts Api 里 crud() 生成的路径/scope 一致,但保留具体 schema 类型) */
export function crudDefs<R extends z.ZodType, C extends z.ZodType, U extends z.ZodType>(
base: string,
name: string,
s: { row: R; create: C; update: U },
) {
const tags = [base.slice(1)];
const item = `${base}/{id}`;
return {
list: {
method: "get" as const,
path: base,
scope: "read" as const,
summary: `${name}列表`,
query: CursorQuery,
response: cursorPage(s.row),
tags,
},
get: {
method: "get" as const,
path: item,
scope: "read" as const,
summary: `${name}详情`,
params: IdParam,
response: s.row,
tags,
},
create: {
method: "post" as const,
path: base,
scope: "admin" as const,
summary: `新建${name}`,
body: s.create,
response: s.row,
tags,
},
update: {
method: "patch" as const,
path: item,
scope: "admin" as const,
summary: `修改${name}`,
params: IdParam,
body: s.update,
response: s.row,
tags,
},
};
}
// ── 行映射 ──
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
/** DB 行 → API 对象:snake_case → camelCase,Date → ISO 串 */
export function toApi<T = Record<string, unknown>>(row: object): T {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row)) out[camel(k)] = v instanceof Date ? v.toISOString() : v;
return out as T;
}
const snake = (s: string) => s.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
/** API 输入 → DB 列(跳过 undefined;对象/数组值 JSON 化以写 jsonb) */
export function toDb(input: Record<string, unknown>, jsonCols: readonly string[] = []): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v === undefined) continue;
const col = snake(k);
out[col] = jsonCols.includes(col) && v !== null ? JSON.stringify(v) : v;
}
return out;
}
// ── 游标(<时间列> desc, id desc;时间按微秒编码,避免 JS Date 毫秒精度截断导致跳行)──
export interface Cursor {
us: number;
id: string;
}
export const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
export function decodeCursor(s: string | undefined): Cursor | null {
if (!s) return null;
try {
const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as Cursor;
if (!Number.isSafeInteger(v.us) || typeof v.id !== "string" || !v.id) throw new Error();
return v;
} catch {
throw new ApiException("bad_request", "cursor 无效");
}
}
/** select 里加上 `cursorCol("created_at")`,得到 cursor_us 列 */
export const cursorCol = (col: string) =>
sql<number>`(extract(epoch from ${sql.ref(col)}) * 1000000)::bigint`.as("cursor_us");
/** where 条件:(<col>, id) < 游标位置;idType 为 id 列的类型(本地表 uuid,引擎表 text) */
export const afterCursor = (col: string, cur: Cursor, idCol = "id", idType: "uuid" | "text" = "uuid") =>
sql<boolean>`(${sql.ref(col)}, ${sql.ref(idCol)}) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id}::${sql.raw(idType)})`;
/** 取 limit+1 行判断是否还有下一页;去掉 cursor_us 辅助列 */
export function pageOf<R extends { cursor_us: number; id: string }>(rows: R[], limit: number) {
const items = rows.slice(0, limit);
const last = items[items.length - 1];
const nextCursor = rows.length > limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null;
return {
items: items.map(({ cursor_us: _c, ...rest }) => rest as Omit<R, "cursor_us">),
nextCursor,
};
}
// ── 审计 ──
/** contracts ActorType:控制台管理员记为 user */
export const auditActorType = (a: Actor | undefined) => (a?.type === "api_key" ? "api_key" : a ? "user" : "system");
/** 写审计;失败只记日志,绝不影响主流程 */
export async function audit(
deps: { db: Kysely<DB>; logger: Logger },
actor: Actor | undefined,
action: AuditAction,
target: { type?: string; id?: string | null } = {},
payload: Record<string, unknown> = {},
): Promise<void> {
try {
await deps.db
.insertInto("audit_event")
.values({
actor_type: auditActorType(actor),
actor_id: actor?.id ?? null,
action,
target_type: target.type ?? null,
target_id: target.id ?? null,
payload: JSON.stringify(payload),
})
.execute();
} catch (err) {
deps.logger.warn({ err, action }, "审计写入失败");
}
}
/** PG 唯一/外键冲突 → 409 */
export function pgConflict(err: unknown, message: string): never {
const code = (err as { code?: string })?.code;
if (code === "23505" || code === "23503") throw new ApiException("conflict", message);
throw err;
}
@@ -0,0 +1,260 @@
import {
cursorPage,
EngineCancelRequest,
EngineCancelResult,
EngineRun,
EngineRunDetail,
EngineRunQuery,
EngineStats,
} from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
import { redact } from "../../logger.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { audit, type Cursor, type Def, decodeCursor, encodeCursor, endpoint } from "./endpoint.ts";
// 引擎面板只读 API(仅 admin):直查 openworkflow schema(docs/design/openworkflow-dashboard-reference.md)。
// 不返回 config/context;input/output/error/signal data 过敏感字段脱敏;取消走 TaskEngine.cancel(先写业务侧取消标志)。
const tags = ["engine"];
const RunIdParam = z.object({ id: z.string().min(1).max(200) });
const D = {
runs: {
method: "get",
path: "/engine/runs",
scope: "admin",
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
query: EngineRunQuery,
response: cursorPage(EngineRun),
tags,
},
run: {
method: "get",
path: "/engine/runs/{id}",
scope: "admin",
summary: "run 详情(step attempts + signals)",
params: RunIdParam,
response: EngineRunDetail,
tags,
},
stats: {
method: "get",
path: "/engine/stats",
scope: "admin",
summary: "按状态计数 / 最老待处理 / 活跃 worker 快照",
response: EngineStats,
tags,
},
cancel: {
method: "post",
path: "/engine/runs/{id}/cancel",
scope: "admin",
summary: "取消 run",
params: RunIdParam,
body: EngineCancelRequest,
response: EngineCancelResult,
tags,
},
} as const satisfies Record<string, Def>;
const NAMESPACE = "default";
const iso = (d: Date | null) => (d ? d.toISOString() : null);
type RunRow = {
id: string;
workflow_name: string;
version: string | null;
status: string;
attempts: number;
worker_id: string | null;
parent_step_attempt_id: string | null;
available_at: Date | null;
deadline_at: Date | null;
started_at: Date | null;
finished_at: Date | null;
created_at: Date;
updated_at: Date;
cursor_us: number;
};
const runView = (r: RunRow) => ({
id: r.id,
workflowName: r.workflow_name,
version: r.version,
status: r.status,
attempts: r.attempts,
workerId: r.worker_id,
parentStepAttemptId: r.parent_step_attempt_id,
availableAt: iso(r.available_at),
deadlineAt: iso(r.deadline_at),
startedAt: iso(r.started_at),
finishedAt: iso(r.finished_at),
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
});
const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_id, parent_step_attempt_id,
available_at, deadline_at, started_at, finished_at, created_at, updated_at,
(extract(epoch from created_at) * 1000000)::bigint as cursor_us`);
export interface EngineQueries {
listRuns(
q: z.infer<typeof EngineRunQuery>,
): Promise<{ items: ReturnType<typeof runView>[]; nextCursor: string | null }>;
getRun(id: string): Promise<z.input<typeof EngineRunDetail> | null>;
stats(): Promise<z.input<typeof EngineStats>>;
}
/** 直查引擎表;schema 默认 openworkflow(测试可覆盖) */
export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQueries {
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) throw new Error(`非法 schema 名 ${schema}`);
const t = (name: string) => sql.raw(`"${schema}".${name}`);
return {
async listRuns(q) {
const cur: Cursor | null = decodeCursor(q.cursor);
const conds = [sql`namespace_id = ${NAMESPACE}`];
if (q.status) conds.push(sql`status = ${q.status}`);
if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`);
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
if (cur) {
conds.push(sql`(created_at, id) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id})`);
}
const rows = await sql<RunRow>`select ${RUN_COLS} from ${t("workflow_runs")}
where ${sql.join(conds, sql` and `)}
order by created_at desc, id desc limit ${q.limit + 1}`.execute(db);
const items = rows.rows.slice(0, q.limit);
const last = items[items.length - 1];
return {
items: items.map(runView),
nextCursor:
rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null,
};
},
async getRun(id) {
const r = await sql<RunRow & { input: unknown; output: unknown; error: unknown }>`
select ${RUN_COLS}, input, output, error from ${t("workflow_runs")}
where namespace_id = ${NAMESPACE} and id = ${id}`.execute(db);
const run = r.rows[0];
if (!run) return null;
const steps = await sql<{
id: string;
step_name: string;
kind: string;
status: string;
output: unknown;
error: unknown;
wait_signal: string | null;
timeout_at: string | null;
child_workflow_run_id: string | null;
started_at: Date | null;
finished_at: Date | null;
created_at: Date;
}>`select id, step_name, kind, status, output, error,
case when kind = 'signal-wait' then context->>'signal' end as wait_signal,
context->>'timeoutAt' as timeout_at,
child_workflow_run_id, started_at, finished_at, created_at
from ${t("step_attempts")} where namespace_id = ${NAMESPACE} and workflow_run_id = ${id}
order by created_at, id limit 1000`.execute(db);
const signals = await sql<{
id: string;
signal: string;
data: unknown;
step_attempt_id: string;
created_at: Date;
}>`
select id, signal, data, step_attempt_id, created_at from ${t("workflow_signals")}
where namespace_id = ${NAMESPACE} and workflow_run_id = ${id} order by created_at, id limit 1000`.execute(db);
return {
...runView(run),
input: redact(run.input),
output: redact(run.output),
error: redact(run.error),
stepAttempts: steps.rows.map((s) => ({
id: s.id,
stepName: s.step_name,
kind: s.kind,
status: s.status,
output: redact(s.output),
error: redact(s.error),
waitSignal: s.wait_signal,
timeoutAt: s.timeout_at,
childWorkflowRunId: s.child_workflow_run_id,
startedAt: iso(s.started_at),
finishedAt: iso(s.finished_at),
createdAt: s.created_at.toISOString(),
})),
signals: signals.rows.map((s) => ({
id: s.id,
signal: s.signal,
data: redact(s.data),
stepAttemptId: s.step_attempt_id,
createdAt: s.created_at.toISOString(),
})),
};
},
async stats() {
const [counts, oldest, workers] = await Promise.all([
sql<{ status: string; n: number }>`select status, count(*)::int as n from ${t("workflow_runs")}
where namespace_id = ${NAMESPACE} group by status`.execute(db),
sql<{ id: string; workflow_name: string; since: Date }>`
select id, workflow_name, coalesce(available_at, created_at) as since from ${t("workflow_runs")}
where namespace_id = ${NAMESPACE} and status = 'pending'
order by coalesce(available_at, created_at), id limit 1`.execute(db),
sql<{ worker_id: string; running: number; since: Date | null }>`
select worker_id, count(*)::int as running, min(started_at) as since from ${t("workflow_runs")}
where namespace_id = ${NAMESPACE} and status = 'running' and worker_id is not null
group by worker_id order by worker_id`.execute(db),
]);
const byStatus: Record<string, number> = { pending: 0, running: 0, completed: 0, failed: 0, canceled: 0 };
for (const r of counts.rows) byStatus[r.status] = (byStatus[r.status] ?? 0) + r.n;
const o = oldest.rows[0];
return {
byStatus,
oldestPending: o
? {
id: o.id,
workflowName: o.workflow_name,
since: o.since.toISOString(),
ageSec: Math.max(0, (Date.now() - o.since.getTime()) / 1000),
}
: null,
activeWorkers: workers.rows.map((w) => ({ workerId: w.worker_id, running: w.running, since: iso(w.since) })),
};
},
};
}
export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) {
let q: EngineQueries | null = null;
const queries = () => {
q ??= engineQueries(ctx.db, opts.schema);
return q;
};
endpoint(app, D.runs, async ({ query }) => await queries().listRuns(query));
endpoint(app, D.run, async ({ params }) => {
const r = await queries().getRun(params.id);
if (!r) throw new ApiException("not_found", `run ${params.id} 不存在`);
return r;
});
endpoint(app, D.stats, async () => await queries().stats());
endpoint(app, D.cancel, async ({ params, body, actor }) => {
const exists = await queries().getRun(params.id);
if (!exists) throw new ApiException("not_found", `run ${params.id} 不存在`);
const canceled = await ctx.engine.cancel(params.id, body.reason ?? `canceled by ${actor.type}:${actor.name}`);
await audit(
ctx,
actor,
"engine.run_cancel",
{ type: "engine_run", id: params.id },
{
workflowName: exists.workflowName,
canceled,
},
);
return { canceled };
});
}
+55 -8
View File
@@ -1,12 +1,59 @@
import type { ServerModule } from "../types.ts";
import { sql } from "@umt/db";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerAuthRoutes } from "./auth-routes.ts";
import { ensureSetupToken } from "./auth-service.ts";
import { registerEngineRoutes } from "./engine-routes.ts";
import { registerInstanceRoutes } from "./instances-routes.ts";
import { registerKeyRoutes } from "./keys-routes.ts";
import { registerSettingsRoutes } from "./settings-routes.ts";
/**
* W2-A 认证/设置/实例/引擎面板:管理员登录门禁、settings、api_key、secret 加密服务、
* downloader/media_server 实例与 library_root/path_mapping、审计查询、SSE 总线改 PG 通知、引擎面板只读 API、自动化总开关。
* secrets / settings 的实现放在 ./provided.ts(context 从这里取),SSE 总线在 http/sse.ts,鉴权在 http/auth.ts。
* downloader/media_server 实例与 library_root/path_mapping、站点凭证录入、审计查询、引擎面板只读 API。
* secrets / settings 的实现在 ./provided.ts(context 从这里取),SSE 总线在 http/sse*.ts,鉴权中间件在 http/auth.ts。
* 用法见 docs/runbook/auth.md。
*/
export const authSettingsModule: ServerModule = {
name: "auth-settings",
registerRoutes() {},
registerTasks() {},
};
export interface AuthSettingsModuleOptions {
/** 引擎表所在 schema,默认 openworkflow */
engineSchema?: string;
/** api 角色启动时若未初始化管理员则生成并打印 setup token(默认 true;测试关掉自己调 ensureSetupToken) */
setupTokenOnStart?: boolean;
/** 过期会话/登录尝试清理间隔,默认 1 小时 */
cleanupIntervalMs?: number;
}
/** 清理:登录尝试保留 7 天;已过期/已吊销会话保留 30 天(审计追溯) */
export async function cleanupAuthTables(ctx: Pick<ServerContext, "db">): Promise<void> {
await sql`delete from login_attempt where at < now() - interval '7 days'`.execute(ctx.db);
await sql`delete from admin_session
where coalesce(revoked_at, expires_at) < now() - interval '30 days'`.execute(ctx.db);
}
export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}): ServerModule {
let timer: ReturnType<typeof setInterval> | null = null;
return {
name: "auth-settings",
registerRoutes(app, ctx) {
registerAuthRoutes(app, ctx);
registerSettingsRoutes(app, ctx);
registerKeyRoutes(app, ctx);
registerInstanceRoutes(app, ctx);
registerEngineRoutes(app, ctx, opts.engineSchema ? { schema: opts.engineSchema } : {});
if (opts.setupTokenOnStart !== false && ctx.db) {
ensureSetupToken(ctx).catch((err) => ctx.logger.error({ err }, "生成 setup token 失败"));
}
},
registerTasks() {},
start(ctx) {
const run = () => cleanupAuthTables(ctx).catch((err) => ctx.logger.warn({ err }, "认证表清理失败"));
timer = setInterval(run, opts.cleanupIntervalMs ?? 3600_000);
void run();
},
stop() {
if (timer) clearInterval(timer);
timer = null;
},
};
}
export const authSettingsModule: ServerModule = createAuthSettingsModule();
@@ -0,0 +1,557 @@
import { stat } from "node:fs/promises";
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters";
import {
Api,
type AuditAction,
DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
IdParam,
InstanceTestResult,
LibraryRoot,
LibraryRootCreate,
LibraryRootUpdate,
MediaServerInstance,
MediaServerInstanceCreate,
MediaServerInstanceUpdate,
PathMapping,
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
SiteCredentialsPut,
SiteCredentialsResult,
} from "@umt/contracts";
import type { DB } from "@umt/db";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
import { redact, redactString } from "../../logger.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
afterCursor,
audit,
crudDefs,
cursorCol,
type Def,
decodeCursor,
endpoint,
pageOf,
pgConflict,
toApi,
toDb,
} from "./endpoint.ts";
// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试,
// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。
const OkResult = z.object({ ok: z.literal(true) });
const del = (path: string, summary: string, tag: string) => ({
method: "delete" as const,
path,
scope: "admin" as const,
summary,
params: IdParam,
response: OkResult,
tags: [tag],
});
const test = (path: string, summary: string, tag: string) => ({
method: "post" as const,
path,
scope: "admin" as const,
summary,
params: IdParam,
response: InstanceTestResult,
tags: [tag],
});
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const;
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
const LR = crudDefs("/library-roots", "媒体库根", {
row: LibraryRoot,
create: LibraryRootCreate,
update: LibraryRootUpdate,
});
const DL = crudDefs("/downloader-instances", "下载器实例", {
row: DownloaderInstance,
create: DownloaderInstanceCreate,
update: DownloaderInstanceUpdate,
});
const MS = crudDefs("/media-server-instances", "媒体服务器实例", {
row: MediaServerInstance,
create: MediaServerInstanceCreate,
update: MediaServerInstanceUpdate,
});
const PM = crudDefs("/path-mappings", "路径映射", {
row: PathMapping,
create: PathMappingCreate,
update: PathMappingUpdate,
});
export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
const db = () => ctx.db;
/** 通用列表 / 详情 / 删除 */
const list = (table: Table, tag: string, def: Def) =>
endpoint(app, { ...def, tags: [tag] }, async ({ query }) => {
const q0 = query as { cursor?: string; limit: number };
const cur = decodeCursor(q0.cursor);
// biome-ignore lint/suspicious/noExplicitAny: 四张表共用的通用查询
let q = (db() as any).selectFrom(table).selectAll().select(cursorCol("created_at"));
if (cur) q = q.where(afterCursor("created_at", cur));
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(q0.limit + 1)
.execute();
const page = pageOf(rows as { cursor_us: number; id: string }[], q0.limit);
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
});
const getRow = async (table: Table, id: string) => {
// biome-ignore lint/suspicious/noExplicitAny: 同上
const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
return r as Record<string, unknown> & { id: string; secret_ref?: string | null };
};
const get = (table: Table, tag: string, def: Def) =>
endpoint(app, { ...def, tags: [tag] }, async ({ params }) =>
toApi(await getRow(table, (params as { id: string }).id)),
);
const remove = (table: Table, tag: string, path: string, action: AuditAction) =>
endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => {
const id = (params as { id: string }).id;
const row = await getRow(table, id);
// biome-ignore lint/suspicious/noExplicitAny: 同上
await (db() as any)
.deleteFrom(table)
.where("id", "=", id)
.execute()
.catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`));
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
await audit(ctx, actor, action, { type: table, id });
return { ok: true as const };
});
const insert = async (table: Table, values: Record<string, unknown>) =>
// biome-ignore lint/suspicious/noExplicitAny: 同上
(await (db() as any)
.insertInto(table)
.values(values)
.returningAll()
.executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & {
id: string;
};
const update = async (table: Table, id: string, values: Record<string, unknown>) => {
if (!Object.keys(values).length) return await getRow(table, id);
// biome-ignore lint/suspicious/noExplicitAny: 同上
const r = await (db() as any)
.updateTable(table)
.set(values)
.where("id", "=", id)
.returningAll()
.executeTakeFirst()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`));
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
return r as Record<string, unknown> & { id: string };
};
/** 凭证:有旧 ref 就原地替换,否则新建 */
const putCredential = async (oldRef: string | null | undefined, purpose: SecretPurpose, plaintext: string) => {
if (oldRef) {
await ctx.secrets.replace(oldRef, plaintext);
return oldRef;
}
return await ctx.secrets.create({ purpose, plaintext });
};
// ── library_root ──
const sentinel = async (localPath: string, file: string | null | undefined) => {
const name = file || DEFAULT_ROOT_SENTINEL;
try {
const st = await stat(localPath);
if (!st.isDirectory()) throw new Error("不是目录");
await writeRootSentinel(localPath, name);
} catch (e) {
throw new ApiException(
"precondition_failed",
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
);
}
return name;
};
list("library_root", "library-roots", LR.list);
get("library_root", "library-roots", LR.get);
endpoint(app, LR.create, async ({ body, actor }) => {
const file = await sentinel(body.localPath, body.sentinelFile);
const row = await insert("library_root", {
...toDb(body, JSON_COLS),
sentinel_file: file,
health_status: "healthy",
last_health_at: new Date(),
});
await audit(ctx, actor, "library_root.create", { type: "library_root", id: row.id }, { localPath: body.localPath });
return toApi(row);
});
endpoint(app, LR.update, async ({ params, body, actor }) => {
const values = toDb(body, JSON_COLS);
if (body.localPath !== undefined || body.sentinelFile !== undefined) {
const cur = await getRow("library_root", params.id);
values.sentinel_file = await sentinel(
body.localPath ?? String(cur.local_path),
body.sentinelFile ?? (cur.sentinel_file as string | null),
);
}
const row = await update("library_root", params.id, values);
await audit(ctx, actor, "library_root.update", { type: "library_root", id: row.id }, { fields: Object.keys(body) });
return toApi(row);
});
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
endpoint(
app,
test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"),
async ({ params, actor }) => {
const row = await getRow("library_root", params.id);
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
const ok = h.kind === "online";
await update("library_root", row.id, {
health_status: ok ? "healthy" : "offline",
last_health_at: new Date(),
health_detail: JSON.stringify(h),
});
await audit(ctx, actor, "library_root.test", { type: "library_root", id: row.id }, { ok });
return {
ok,
version: null,
message: ok ? null : `offline: ${"reason" in h ? h.reason : "unknown"}`,
healthStatus: ok ? "healthy" : "offline",
};
},
);
// ── downloader_instance ──
list("downloader_instance", "downloader-instances", DL.list);
get("downloader_instance", "downloader-instances", DL.get);
endpoint(app, DL.create, async ({ body, actor }) => {
const { credential, ...rest } = body;
const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
try {
const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
await audit(
ctx,
actor,
"downloader_instance.create",
{ type: "downloader_instance", id: row.id },
{ name: row.name },
);
return toApi(row);
} catch (e) {
if (ref) await ctx.secrets.remove(ref).catch(() => {});
throw e;
}
});
endpoint(app, DL.update, async ({ params, body, actor }) => {
const { credential, ...rest } = body;
const values = toDb(rest, JSON_COLS);
if (credential) {
const cur = await getRow("downloader_instance", params.id);
values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential);
}
const row = await update("downloader_instance", params.id, values);
await audit(
ctx,
actor,
"downloader_instance.update",
{ type: "downloader_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
},
);
return toApi(row);
});
remove("downloader_instance", "downloader-instances", "/downloader-instances/{id}", "downloader_instance.delete");
endpoint(
app,
{
...test("/downloader-instances/{id}/test", "测试下载器连接", "downloader-instances"),
response: InstanceTestResult,
},
async ({ params, actor }) => {
const row = await getRow("downloader_instance", params.id);
const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined;
const username = (row.username as string | null) ?? undefined;
// 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key
const client = new QbClient({
instanceId: row.id,
baseUrl: String(row.base_url),
...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}),
timeoutMs: 10_000,
retries: 0,
});
let result: z.infer<typeof InstanceTestResult>;
try {
const v = await client.version();
result = { ok: true, version: v.app, message: null, healthStatus: "healthy" };
await update("downloader_instance", row.id, {
health_status: "healthy",
last_seen_at: new Date(),
version_info: JSON.stringify({ app: v.app, webApi: v.webApi }),
});
} catch (e) {
result = { ok: false, version: null, message: errMessage(e), healthStatus: "offline" };
await update("downloader_instance", row.id, { health_status: "offline" });
}
await audit(
ctx,
actor,
"downloader_instance.test",
{ type: "downloader_instance", id: row.id },
{ ok: result.ok },
);
return result;
},
);
// ── media_server_instance ──
list("media_server_instance", "media-server-instances", MS.list);
get("media_server_instance", "media-server-instances", MS.get);
endpoint(app, MS.create, async ({ body, actor }) => {
const { credential, ...rest } = body;
const ref = credential
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
: null;
try {
const row = await insert("media_server_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
await audit(
ctx,
actor,
"media_server_instance.create",
{ type: "media_server_instance", id: row.id },
{ name: row.name },
);
return toApi(row);
} catch (e) {
if (ref) await ctx.secrets.remove(ref).catch(() => {});
throw e;
}
});
endpoint(app, MS.update, async ({ params, body, actor }) => {
const { credential, ...rest } = body;
const values = toDb(rest, JSON_COLS);
if (credential) {
const cur = await getRow("media_server_instance", params.id);
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
}
const row = await update("media_server_instance", params.id, values);
await audit(
ctx,
actor,
"media_server_instance.update",
{ type: "media_server_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
},
);
return toApi(row);
});
remove(
"media_server_instance",
"media-server-instances",
"/media-server-instances/{id}",
"media_server_instance.delete",
);
endpoint(
app,
test("/media-server-instances/{id}/test", "测试媒体服务器连接", "media-server-instances"),
async ({ params, actor }) => {
const row = await getRow("media_server_instance", params.id);
let result: z.infer<typeof InstanceTestResult>;
if (!row.secret_ref) {
result = { ok: false, version: null, message: "未配置 API Key", healthStatus: "unknown" };
} else {
const client = new EmbyClient({
instanceId: row.id,
baseUrl: String(row.base_url),
apiKey: await ctx.secrets.resolve(row.secret_ref),
flavor: row.kind === "jellyfin" ? "jellyfin" : "emby",
...(row.user_id ? { userId: String(row.user_id) } : {}),
timeoutMs: 10_000,
retries: 0,
});
const r = await client.ping();
if (r.status === "ok") {
result = {
ok: true,
version: r.value.version ?? null,
message: r.value.serverName ?? null,
healthStatus: "healthy",
};
await update("media_server_instance", row.id, {
health_status: "healthy",
last_seen_at: new Date(),
version_info: JSON.stringify(redact(r.value)),
});
} else {
result = {
ok: false,
version: null,
message: `${r.reason}: ${redactString(r.message)}`,
healthStatus: "offline",
};
await update("media_server_instance", row.id, { health_status: "offline" });
}
}
await audit(
ctx,
actor,
"media_server_instance.test",
{ type: "media_server_instance", id: row.id },
{ ok: result.ok },
);
return result;
},
);
// ── path_mapping ──
list("path_mapping", "path-mappings", PM.list);
get("path_mapping", "path-mappings", PM.get);
endpoint(app, PM.create, async ({ body, actor }) => {
const row = await insert("path_mapping", toDb(body, JSON_COLS));
await audit(
ctx,
actor,
"path_mapping.create",
{ type: "path_mapping", id: row.id },
{
remotePrefix: body.remotePrefix,
localPrefix: body.localPrefix,
},
);
return toApi(row);
});
endpoint(app, PM.update, async ({ params, body, actor }) => {
const values = toDb(body, JSON_COLS);
if (body.localPrefix !== undefined || body.remotePrefix !== undefined) values.verify_status = "unverified";
const row = await update("path_mapping", params.id, values);
await audit(ctx, actor, "path_mapping.update", { type: "path_mapping", id: row.id }, { fields: Object.keys(body) });
return toApi(row);
});
endpoint(app, { ...Api.pathMappings.remove, tags: ["path-mappings"] }, async ({ params, actor }) => {
const r = await db().deleteFrom("path_mapping").where("id", "=", params.id).returning("id").executeTakeFirst();
if (!r) throw new ApiException("not_found", "路径映射不存在");
await audit(ctx, actor, "path_mapping.delete", { type: "path_mapping", id: params.id });
return { ok: true as const };
});
endpoint(app, { ...Api.pathMappings.verify, tags: ["path-mappings"] }, async ({ params, actor }) => {
const row = await getRow("path_mapping", params.id);
let detail: Record<string, unknown>;
let ok = false;
try {
const st = await stat(String(row.local_prefix));
ok = st.isDirectory();
detail = ok ? { localPrefix: "directory" } : { error: "localPrefix 不是目录" };
} catch (e) {
detail = { error: errMessage(e), code: (e as { code?: string })?.code ?? null };
}
const updated = await update("path_mapping", row.id, {
verify_status: ok ? "ok" : "failed",
verified_at: new Date(),
verify_detail: JSON.stringify(detail),
});
await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok });
return toApi(updated);
});
// ── 站点账号凭证 ──
const PURPOSE: Record<SiteCredentialsPut["authKind"], SecretPurpose> = {
cookie: "site_cookie",
passkey: "site_passkey",
api_key: "site_api_key",
password: "site_password",
};
endpoint(
app,
{
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)",
params: IdParam,
body: SiteCredentialsPut,
response: SiteCredentialsResult,
tags: ["sites"],
},
async ({ params, body, actor }) => {
const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential });
let oldRef: string | null = null;
let created = false;
let accountId: string;
try {
accountId = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
.select(["id", "secret_ref", "user_info"])
.where("site_id", "=", params.id)
.where("label", "=", body.label)
.forUpdate()
.executeTakeFirst();
const userInfo = {
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
...(body.username ? { username: body.username } : {}),
};
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
auth_kind: body.authKind,
secret_ref: newRef,
status: "unknown",
user_info: JSON.stringify(userInfo),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
};
if (cur) {
oldRef = cur.secret_ref;
await trx
.updateTable("site_account")
.set(common as never)
.where("id", "=", cur.id)
.execute();
return cur.id;
}
created = true;
const r = await trx
.insertInto("site_account")
.values({ site_id: params.id, label: body.label, ...common } as never)
.returning("id")
.executeTakeFirstOrThrow();
return r.id;
});
} catch (e) {
await ctx.secrets.remove(newRef).catch(() => {});
throw e;
}
if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {});
await audit(
ctx,
actor,
"site_account.credentials_set",
{ type: "site_account", id: accountId },
{
siteId: params.id,
label: body.label,
authKind: body.authKind,
created,
},
);
return {
siteAccountId: accountId,
siteId: params.id,
label: body.label,
authKind: body.authKind,
secretRef: newRef,
created,
};
},
);
}
@@ -0,0 +1,205 @@
import { Api, AuditEvent, AuditEventQuery, cursorPage, IdParam, Secret } from "@umt/contracts";
import { sql } from "@umt/db";
import { z } from "zod";
import { hashApiKey } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { randomToken } from "./crypto.ts";
import {
afterCursor,
audit,
cursorCol,
type Def,
decodeCursor,
endpoint,
pageOf,
pgConflict,
toApi,
} from "./endpoint.ts";
import { createSecretStore } from "./provided.ts";
// API Key 管理(创建时一次性返回明文,只存 sha256)、凭证元数据管理(/secrets,明文只写)、审计查询。
const OkResult = z.object({ ok: z.literal(true) });
const D = {
revokeKey: {
method: "delete",
path: "/api-keys/{id}",
scope: "admin",
summary: "撤销 API Key",
params: IdParam,
response: OkResult,
tags: ["api-keys"],
},
deleteSecret: {
method: "delete",
path: "/secrets/{id}",
scope: "admin",
summary: "删除凭证(被引用时 409)",
params: IdParam,
response: OkResult,
tags: ["secrets"],
},
audit: {
method: "get",
path: "/audit-events",
scope: "admin",
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
query: AuditEventQuery,
response: cursorPage(AuditEvent),
tags: ["audit"],
},
} as const satisfies Record<string, Def>;
const KEY_COLS = ["id", "name", "scopes", "revoked_at", "last_used_at", "created_at", "updated_at"] as const;
const SECRET_COLS = [
"id",
"name",
"purpose",
"algorithm",
"kdf",
"kdf_params",
"key_version",
"rotated_at",
"created_at",
"updated_at",
] as const;
export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
// ── API Key ──
endpoint(app, { ...Api.apiKeys.list, tags: ["api-keys"] }, async ({ query }) => {
const cur = decodeCursor(query.cursor);
let q = ctx.db.selectFrom("api_key").select([...KEY_COLS, cursorCol("created_at")]);
if (cur) q = q.where(afterCursor("created_at", cur));
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const page = pageOf(rows, query.limit);
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
});
endpoint(app, { ...Api.apiKeys.create, tags: ["api-keys"] }, async ({ body, actor }) => {
const plaintextKey = `umt_${randomToken(32)}`;
const row = await ctx.db
.insertInto("api_key")
.values({ name: body.name, key_hash: hashApiKey(plaintextKey), scopes: [...new Set(body.scopes)] })
.returning(KEY_COLS)
.executeTakeFirstOrThrow();
await audit(ctx, actor, "api_key.create", { type: "api_key", id: row.id }, { name: row.name, scopes: row.scopes });
return { apiKey: toApi(row), plaintextKey };
});
endpoint(app, { ...Api.apiKeys.update, tags: ["api-keys"] }, async ({ params, body, actor }) => {
const set: Record<string, unknown> = {};
if (body.name !== undefined) set.name = body.name;
if (body.scopes !== undefined) set.scopes = [...new Set(body.scopes)];
if (body.revoked) set.revoked_at = sql`coalesce(revoked_at, now())`;
if (!Object.keys(set).length) throw new ApiException("bad_request", "没有要修改的字段");
const row = await ctx.db
.updateTable("api_key")
.set(set)
.where("id", "=", params.id)
.returning(KEY_COLS)
.executeTakeFirst();
if (!row) throw new ApiException("not_found", "API Key 不存在");
await audit(
ctx,
actor,
body.revoked ? "api_key.revoke" : "api_key.update",
{ type: "api_key", id: row.id },
{
name: body.name,
scopes: body.scopes,
},
);
return toApi(row);
});
endpoint(app, D.revokeKey, async ({ params, actor }) => {
const r = await ctx.db
.updateTable("api_key")
.set({ revoked_at: sql`coalesce(revoked_at, now())` })
.where("id", "=", params.id)
.returning("id")
.executeTakeFirst();
if (!r) throw new ApiException("not_found", "API Key 不存在");
await audit(ctx, actor, "api_key.revoke", { type: "api_key", id: params.id });
return { ok: true as const };
});
// ── 凭证元数据 ──
const secrets = createSecretStore({ db: ctx.db, masterKey: ctx.config?.SECRET_MASTER_KEY ?? "" });
const secretView = (r: Record<string, unknown>) => Secret.parse(toApi(r));
endpoint(app, { ...Api.secrets.list, tags: ["secrets"] }, async ({ query }) => {
const cur = decodeCursor(query.cursor);
let q = ctx.db.selectFrom("secret").select([...SECRET_COLS, cursorCol("created_at")]);
if (cur) q = q.where(afterCursor("created_at", cur));
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const page = pageOf(rows, query.limit);
return { items: page.items.map(secretView), nextCursor: page.nextCursor };
});
endpoint(app, { ...Api.secrets.create, tags: ["secrets"] }, async ({ body, actor }) => {
const id = await secrets
.create({ purpose: body.purpose, plaintext: body.plaintext, label: body.name })
.catch((e) => pgConflict(e, "凭证名称已存在"));
await audit(ctx, actor, "secret.create", { type: "secret", id }, { name: body.name, purpose: body.purpose });
const row = await ctx.db.selectFrom("secret").select(SECRET_COLS).where("id", "=", id).executeTakeFirstOrThrow();
return secretView(row);
});
endpoint(app, { ...Api.secrets.update, tags: ["secrets"] }, async ({ params, body, actor }) => {
const exists = await secrets.meta(params.id);
if (!exists) throw new ApiException("not_found", "凭证不存在");
await secrets.rotate(params.id, body.plaintext);
await audit(ctx, actor, "secret.rotate", { type: "secret", id: params.id });
const row = await ctx.db
.selectFrom("secret")
.select(SECRET_COLS)
.where("id", "=", params.id)
.executeTakeFirstOrThrow();
return secretView(row);
});
endpoint(app, D.deleteSecret, async ({ params, actor }) => {
const r = await ctx.db
.deleteFrom("secret")
.where("id", "=", params.id)
.returning("id")
.executeTakeFirst()
.catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用"));
if (!r) throw new ApiException("not_found", "凭证不存在");
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
return { ok: true as const };
});
// ── 审计查询 ──
endpoint(app, D.audit, async ({ query }) => {
const cur = decodeCursor(query.cursor);
let q = ctx.db.selectFrom("audit_event").selectAll().select(cursorCol("at"));
if (query.actorType) q = q.where("actor_type", "=", query.actorType);
if (query.actorId) q = q.where("actor_id", "=", query.actorId);
if (query.action) {
q = query.action.endsWith(".*")
? q.where("action", "like", `${query.action.slice(0, -2).replace(/[\\%_]/g, "\\$&")}.%`)
: q.where("action", "=", query.action);
}
if (query.targetType) q = q.where("target_type", "=", query.targetType);
if (query.targetId) q = q.where("target_id", "=", query.targetId);
if (cur) q = q.where(afterCursor("at", cur));
const rows = await q
.orderBy("at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const page = pageOf(rows, query.limit);
return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor };
});
}
+253 -12
View File
@@ -1,21 +1,222 @@
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { Logger } from "pino";
import type { EventBusPort } from "../../http/sse.ts";
import type { SecretStore, SettingsReader } from "../types.ts";
import { deriveKey } from "./crypto.ts";
// auth-settings 模块向 ServerContext 提供的基础服务。W2-A 在这里替换实现,其它模块只用 ctx 上的接口。
// auth-settings 模块向 ServerContext 提供的基础服务:ctx.secrets / ctx.settings。
/** 占位:W2-A 实现 AES-GCM 加密存取(secret 表,SECRET_MASTER_KEY) */
export function createSecretStore(_deps: { db: Kysely<DB>; masterKey: string }): SecretStore {
const notYet = async (): Promise<never> => {
throw new Error("SecretStore 尚未实现(W2-A)");
};
return { resolve: notYet, create: notYet, replace: notYet, remove: notYet };
// ── 密钥服务 ──
export const SECRET_ALGORITHM = "aes-256-gcm";
export const SECRET_KDF = "hkdf-sha256";
/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */
export const SECRET_KEY_VERSION = 1;
/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */
export class SecretError extends Error {
constructor(
readonly kind: "not_found" | "decrypt_failed",
readonly secretRef: string,
) {
super(
kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`,
);
this.name = "SecretError";
}
}
/** settings 表(key → jsonb)按分组合并缺省值;每次读库,不缓存(设置变更立即生效) */
export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }): SettingsReader {
const all = async (): Promise<GlobalSettings> => {
export interface SecretMeta {
id: string;
name: string;
purpose: SecretPurpose;
algorithm: string;
kdf: string;
kdfParams: Record<string, unknown>;
keyVersion: number;
rotatedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/** SecretStore(put/get/rotate/delete = create/resolve/replace/remove)+ 管理面需要的元数据操作 */
export interface SecretService extends SecretStore {
/** 同 replace:换新明文(新盐、新 nonce),记 rotated_at */
rotate(secretRef: string, plaintext: string): Promise<void>;
meta(secretRef: string): Promise<SecretMeta | null>;
}
interface Sealed {
ciphertext: Buffer;
nonce: Buffer;
authTag: Buffer;
kdfParams: { salt: string; info: string };
}
/** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertRef(ref: string) {
if (!UUID_RE.test(ref)) throw new SecretError("not_found", String(ref).slice(0, 64));
}
const aad = (id: string, purpose: string) => Buffer.from(`umt-secret:${id}:${purpose}`, "utf8");
const infoFor = (keyVersion: number) => `umt/secret/v${keyVersion}`;
function seal(masterKey: string, id: string, purpose: string, plaintext: string): Sealed {
const salt = randomBytes(16);
const info = infoFor(SECRET_KEY_VERSION);
const key = deriveKey(masterKey, salt, info);
const nonce = randomBytes(12);
const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce);
cipher.setAAD(aad(id, purpose));
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } };
}
function open(
masterKey: string,
row: { id: string; purpose: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer | null; kdf_params: unknown },
): string {
const params = (row.kdf_params ?? {}) as { salt?: string; info?: string };
if (!params.salt || !params.info || !row.auth_tag) throw new SecretError("decrypt_failed", row.id);
try {
const key = deriveKey(masterKey, Buffer.from(params.salt, "base64"), params.info);
const decipher = createDecipheriv(SECRET_ALGORITHM, key, row.nonce);
decipher.setAAD(aad(row.id, row.purpose));
decipher.setAuthTag(row.auth_tag);
return Buffer.concat([decipher.update(row.ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new SecretError("decrypt_failed", row.id);
}
}
/**
* secret 表 + AES-256-GCM:每条 secret 用随机盐经 HKDF-SHA256 从 SECRET_MASTER_KEY 派生独立密钥,
* 随机 12 字节 nonce,AAD 绑定 (id, purpose)。明文只在返回值里出现,不缓存、不打日志。
*/
export function createSecretStore(deps: { db: Kysely<DB>; masterKey: string }): SecretService {
const { db, masterKey } = deps;
const replace = async (secretRef: string, plaintext: string) => {
assertRef(secretRef);
const row = await db.selectFrom("secret").select(["id", "purpose"]).where("id", "=", secretRef).executeTakeFirst();
if (!row) throw new SecretError("not_found", secretRef);
const s = seal(masterKey, row.id, row.purpose, plaintext);
await db
.updateTable("secret")
.set({
ciphertext: s.ciphertext,
nonce: s.nonce,
auth_tag: s.authTag,
kdf_params: JSON.stringify(s.kdfParams),
key_version: SECRET_KEY_VERSION,
rotated_at: new Date(),
})
.where("id", "=", secretRef)
.execute();
};
return {
async create({ purpose, plaintext, label }) {
const id = randomUUID();
const s = seal(masterKey, id, purpose, plaintext);
await db
.insertInto("secret")
.values({
id,
name: label ?? `${purpose}:${id}`,
purpose,
ciphertext: s.ciphertext,
nonce: s.nonce,
auth_tag: s.authTag,
algorithm: SECRET_ALGORITHM,
kdf: SECRET_KDF,
kdf_params: JSON.stringify(s.kdfParams),
key_version: SECRET_KEY_VERSION,
})
.execute();
return id;
},
async resolve(secretRef) {
assertRef(secretRef);
const row = await db
.selectFrom("secret")
.select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"])
.where("id", "=", secretRef)
.executeTakeFirst();
if (!row) throw new SecretError("not_found", secretRef);
return open(masterKey, row);
},
replace,
rotate: replace,
async remove(secretRef) {
if (!UUID_RE.test(secretRef)) return;
await db.deleteFrom("secret").where("id", "=", secretRef).execute();
},
async meta(secretRef) {
if (!UUID_RE.test(secretRef)) return null;
const r = await db
.selectFrom("secret")
.select([
"id",
"name",
"purpose",
"algorithm",
"kdf",
"kdf_params",
"key_version",
"rotated_at",
"created_at",
"updated_at",
])
.where("id", "=", secretRef)
.executeTakeFirst();
if (!r) return null;
return {
id: r.id,
name: r.name,
purpose: r.purpose as SecretPurpose,
algorithm: r.algorithm,
kdf: r.kdf,
kdfParams: (r.kdf_params ?? {}) as Record<string, unknown>,
keyVersion: r.key_version,
rotatedAt: r.rotated_at,
createdAt: r.created_at,
updatedAt: r.updated_at,
};
},
};
}
// ── 设置读取 ──
/** SSE / 进程间广播的设置变更事件 */
export const SETTINGS_CHANGED_EVENT = "settings.changed";
export interface CachedSettingsReader extends SettingsReader {
/** 丢弃缓存(本进程写库后立即调用;其它进程经 settings.changed 事件失效) */
invalidate(): void;
/** 订阅变更(失效后触发,参数为变更的分组);返回退订函数 */
onChange(fn: (keys: SettingsKey[]) => void): () => void;
/** 绕过缓存读库 */
fresh(): Promise<GlobalSettings>;
}
/**
* settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。
* 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。
*/
export function createSettingsReader(deps: {
db: Kysely<DB>;
logger: Logger;
events?: EventBusPort;
ttlMs?: number;
}): CachedSettingsReader {
const ttl = deps.ttlMs ?? 30_000;
let cache: { at: number; value: Promise<GlobalSettings> } | null = null;
const listeners = new Set<(keys: SettingsKey[]) => void>();
const load = async (): Promise<GlobalSettings> => {
const rows = await deps.db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
@@ -29,5 +230,45 @@ export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }):
}
return out as GlobalSettings;
};
return { all, get: async (key) => (await all())[key] };
const all = (): Promise<GlobalSettings> => {
const now = Date.now();
if (!cache || now - cache.at > ttl) {
const value = load();
cache = { at: now, value };
// 读失败不缓存
value.catch(() => {
if (cache?.value === value) cache = null;
});
}
return cache.value;
};
const invalidate = () => {
cache = null;
};
deps.events?.subscribe((e) => {
if (e.type !== SETTINGS_CHANGED_EVENT) return;
invalidate();
const keys = ((e.payload as { keys?: SettingsKey[] } | null)?.keys ?? []) as SettingsKey[];
for (const l of listeners) {
try {
l(keys);
} catch (err) {
deps.logger.warn({ err }, "settings 变更监听器异常");
}
}
});
return {
all,
get: async (key) => (await all())[key],
invalidate,
fresh: load,
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
@@ -0,0 +1,73 @@
import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SettingsKey } from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { audit, endpoint } from "./endpoint.ts";
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts";
// GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert;
// Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed,
// 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。
const isCached = (s: unknown): s is CachedSettingsReader =>
typeof (s as CachedSettingsReader)?.invalidate === "function";
export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
endpoint(app, { ...Api.settings.get, tags: ["settings"] }, async () => {
if (isCached(ctx.settings)) return await ctx.settings.fresh();
return await ctx.settings.all();
});
endpoint(app, { ...Api.settings.update, tags: ["settings"] }, async ({ body, actor }) => {
const patch = body as GlobalSettingsUpdate;
const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all();
const merged = mergeSettings(current, patch);
const oldRef = current.pi.apiKeySecretRef;
let dropRef: string | null = null;
let newRef: string | null = null;
const apiKey = patch.pi?.apiKey;
if (apiKey === null) {
merged.pi.apiKeySecretRef = null;
dropRef = oldRef;
}
const parsed = GlobalSettings.safeParse(merged);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
throw new ApiException("validation_failed", msg);
}
const next = parsed.data;
if (typeof apiKey === "string") {
if (oldRef) {
await ctx.secrets.replace(oldRef, apiKey);
next.pi.apiKeySecretRef = oldRef;
} else {
newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey });
next.pi.apiKeySecretRef = newRef;
}
}
const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined);
try {
await ctx.db.transaction().execute(async (trx) => {
for (const key of keys) {
await trx
.insertInto("settings")
.values({ key, value: JSON.stringify(next[key]), updated_at: new Date() })
.onConflict((oc) =>
oc.column("key").doUpdateSet({ value: JSON.stringify(next[key]), updated_at: new Date() }),
)
.execute();
}
});
} catch (err) {
if (newRef) await ctx.secrets.remove(newRef).catch(() => {});
throw err;
}
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
if (isCached(ctx.settings)) ctx.settings.invalidate();
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
// 审计里不留明文密钥
const logged: Record<string, unknown> = { ...patch };
if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" };
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
return next;
});
}