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

+149 -22
View File
@@ -1,11 +1,16 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { ApiScope } from "@umt/contracts";
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { type Actor, type ApiScope, ApiScope as ApiScopeSchema, CSRF_HEADER, SESSION_COOKIE } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { ApiException } from "./errors.ts";
// 鉴权中间件:控制台 cookie 会话(umt_session)与外部 API Key(Bearer)二选一,
// 统一写入 c.var.actor({type:'admin'|'api_key', id, name, scopes});requireScope 对两者一视同仁。
// 旧字段 c.var.principal 保留兼容(kind 区分 api_key / dev_admin / admin_session)。
export interface Principal {
kind: "api_key" | "dev_admin";
kind: "api_key" | "dev_admin" | "admin_session";
id: string;
name: string;
scopes: string[];
@@ -18,13 +23,32 @@ export interface ApiKeyRecord {
revokedAt: Date | null;
}
/** API Key 查询接口,便于测试替换 */
export interface SessionRecord {
id: string;
adminUserId: string;
username: string;
expiresAt: Date;
revokedAt: Date | null;
lastSeenAt: Date;
}
/** API Key / 会话查询接口,便于测试替换(会话方法可选:测试内存仓库可以只实现 API Key) */
export interface ApiKeyStore {
findByHash(hash: string): Promise<ApiKeyRecord | null>;
touch(id: string): Promise<void>;
/** 按 sha256(token) 查会话(未过滤过期/吊销,由中间件判断) */
findSession?(hash: string): Promise<SessionRecord | null>;
/** 节流更新 last_seen_at */
touchSession?(id: string): Promise<void>;
}
export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex");
/** 会话 token 与 API Key 一样只存 sha256(高熵随机串不需要慢哈希) */
export const hashSessionToken = hashApiKey;
/** CSRF 双提交 token:HMAC-SHA256(key=会话原始 token, "umt/csrf/v1") */
export const deriveCsrfToken = (sessionToken: string) =>
createHmac("sha256", sessionToken).update("umt/csrf/v1").digest("base64url");
export function kyselyApiKeyStore(db: Kysely<DB>): ApiKeyStore {
return {
@@ -39,6 +63,26 @@ export function kyselyApiKeyStore(db: Kysely<DB>): ApiKeyStore {
async touch(id) {
await db.updateTable("api_key").set({ last_used_at: new Date() }).where("id", "=", id).execute();
},
async findSession(hash) {
const row = await db
.selectFrom("admin_session as s")
.innerJoin("admin_user as u", "u.id", "s.admin_user_id")
.select(["s.id", "s.admin_user_id", "u.username", "s.expires_at", "s.revoked_at", "s.last_seen_at"])
.where("s.session_hash", "=", hash)
.executeTakeFirst();
if (!row?.username) return null;
return {
id: row.id,
adminUserId: row.admin_user_id,
username: row.username,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
lastSeenAt: row.last_seen_at,
};
},
async touchSession(id) {
await db.updateTable("admin_session").set({ last_seen_at: new Date() }).where("id", "=", id).execute();
},
};
}
@@ -48,7 +92,28 @@ export interface AuthOptions {
devAdminKey?: string | undefined;
}
export type AuthEnv = { Variables: { principal: Principal } };
export interface SessionVar {
id: string;
/** cookie 里的原始 token(用于派生 CSRF、改密时保留当前会话);不要写日志 */
token: string;
adminUserId: string;
expiresAt: Date;
}
export type AuthEnv = {
Variables: {
principal: Principal;
actor: Actor;
/** 仅 cookie 会话调用时存在 */
session: SessionVar | undefined;
};
};
/** 免鉴权路径(相对 /api/v1):登录前就要能访问;带有效 cookie 时仍会挂上 actor */
export const PUBLIC_AUTH_PATHS = ["/auth/setup", "/auth/login", "/auth/session"] as const;
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** last_seen_at 节流写库间隔 */
const TOUCH_INTERVAL_MS = 60_000;
function safeEqual(a: string, b: string) {
const ab = Buffer.from(a);
@@ -56,25 +121,77 @@ function safeEqual(a: string, b: string) {
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];
const toScopes = (s: readonly string[]): ApiScope[] =>
s.filter((x): x is ApiScope => ApiScopeSchema.safeParse(x).success);
if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) {
c.set("principal", { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] });
/**
* /api/v1 鉴权:
* - `Authorization: Bearer <key>`:API Key(开发模式另认 DEV_ADMIN_KEY);
* - 否则 cookie `umt_session`:管理员会话(未过期、未吊销),写方法还要校验 `x-csrf-token` == HMAC 派生值;
* - PUBLIC_AUTH_PATHS 免鉴权(有有效会话时照样挂 actor)。
*/
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
const setActor = (
c: Parameters<MiddlewareHandler<AuthEnv>>[0],
p: Principal,
type: Actor["type"],
session?: SessionVar,
) => {
c.set("principal", p);
c.set("actor", { type, id: p.id, name: p.name, scopes: toScopes(p.scopes) });
c.set("session", session);
};
return async (c, next) => {
const rel = c.req.path.replace(/^\/api\/v1/, "");
const isPublic = (PUBLIC_AUTH_PATHS as readonly string[]).includes(rel);
const header = c.req.header("authorization") ?? "";
if (header) {
const m = /^Bearer\s+(\S+)$/i.exec(header);
if (!m?.[1]) {
if (isPublic) return next();
throw new ApiException("unauthorized", "Authorization 头格式应为 Bearer <key>");
}
const key = m[1];
if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) {
setActor(c, { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] }, "admin");
return next();
}
const rec = opts.store ? await opts.store.findByHash(hashApiKey(key)) : null;
if (!rec || rec.revokedAt) {
if (isPublic) return next();
throw new ApiException("unauthorized", "API Key 无效或已撤销");
}
setActor(c, { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes }, "api_key");
// last_used_at 尽力更新,失败不影响请求
opts.store?.touch(rec.id).catch(() => {});
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();
const token = getCookie(c, SESSION_COOKIE);
if (token && opts.store?.findSession) {
const s = await opts.store.findSession(hashSessionToken(token));
const now = Date.now();
if (s && !s.revokedAt && s.expiresAt.getTime() > now) {
if (!isPublic && WRITE_METHODS.has(c.req.method)) {
const provided = c.req.header(CSRF_HEADER);
if (!provided || !safeEqual(provided, deriveCsrfToken(token))) {
throw new ApiException("forbidden", "CSRF 校验失败,请刷新页面后重试");
}
}
setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", {
id: s.id,
token,
adminUserId: s.adminUserId,
expiresAt: s.expiresAt,
});
if (now - s.lastSeenAt.getTime() > TOUCH_INTERVAL_MS) opts.store.touchSession?.(s.id).catch(() => {});
return next();
}
}
if (isPublic) return next();
throw new ApiException("unauthorized", token ? "会话已过期或已注销,请重新登录" : "需要登录或 Bearer API Key");
};
}
@@ -86,7 +203,7 @@ export function hasScope(granted: readonly string[], required: ApiScope): boolea
return false;
}
/** 要求调用者至少具备其中一个 scope */
/** 要求调用者至少具备其中一个 scope(cookie 会话与 API Key 同样适用) */
export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
return async (c, next) => {
const p = c.get("principal");
@@ -97,3 +214,13 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
return next();
};
}
/** 只允许控制台管理员会话(或开发模式 DEV_ADMIN_KEY),拒绝外部 API Key —— 用于改密、TOTP 等账号操作 */
export function requireAdminSession(): MiddlewareHandler<AuthEnv> {
return async (c, next) => {
const p = c.get("principal");
if (!p) throw new ApiException("unauthorized", "未鉴权");
if (p.kind !== "admin_session") throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
return next();
};
}
+217
View File
@@ -0,0 +1,217 @@
import type { EventEnvelope } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import pg from "pg";
import type { Logger } from "pino";
import type { BusListener, EventBusPort, SinceResult } from "./sse.ts";
/** 默认通知通道 */
export const EVENTS_CHANNEL = "umt_events";
/** 发布时串行化 seq 分配与提交的咨询锁键('umte') */
const PUBLISH_LOCK_KEY = 0x756d7465;
export interface PgEventBusOptions {
db: Kysely<DB>;
/** LISTEN 专用连接(不走连接池) */
connectionString: string;
logger: Logger;
channel?: string;
/** sse_event 保留最近多少条供 ?after= 补读,默认 10000 */
retain?: number;
/** 每发布多少条裁剪一次,默认 200 */
pruneEvery?: number;
/** LISTEN 断线重连退避上限,默认 10s */
maxReconnectMs?: number;
/** 仅测试:LISTEN 连接的 search_path 等 pg 启动参数 */
pgOptions?: string;
}
type Row = { seq: number; type: string; at: Date; payload: unknown };
const toEnvelope = (r: Row): EventEnvelope => ({
seq: Number(r.seq),
type: r.type,
at: r.at.toISOString(),
payload: r.payload,
});
/**
* 多进程 SSE 总线:发布 = 在 sse_event 追加一行(持咨询锁,保证 seq 顺序即提交顺序)+ pg_notify(channel, seq);
* 每个进程一条 LISTEN 连接,收到通知后按 seq 从表里补读并分发给本进程订阅者(包括自己发布的)。
* LISTEN 断线期间的事件在重连后按 lastDispatched 补读,不丢。
*/
export class PgEventBus implements EventBusPort {
private readonly listeners = new Set<BusListener>();
private readonly channel: string;
private readonly retain: number;
private readonly pruneEvery: number;
private client: pg.Client | null = null;
private lastDispatched = 0;
private draining: Promise<void> | null = null;
private drainAgain = false;
private published = 0;
private stopped = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private constructor(private readonly opts: PgEventBusOptions) {
this.channel = opts.channel ?? EVENTS_CHANNEL;
if (!/^[a-z_][a-z0-9_]*$/.test(this.channel)) throw new Error(`非法通道名 ${this.channel}`);
this.retain = opts.retain ?? 10_000;
this.pruneEvery = opts.pruneEvery ?? 200;
}
/** 建总线并开始 LISTEN;从当前最大 seq 之后开始分发 */
static async start(opts: PgEventBusOptions): Promise<PgEventBus> {
const bus = new PgEventBus(opts);
bus.lastDispatched = await bus.maxSeq();
await bus.connect();
return bus;
}
private get log() {
return this.opts.logger;
}
private async maxSeq(): Promise<number> {
const r = await sql<{ m: number | null }>`select max(seq) as m from sse_event`.execute(this.opts.db);
return Number(r.rows[0]?.m ?? 0);
}
private async connect(): Promise<void> {
const client = new pg.Client({
connectionString: this.opts.connectionString,
...(this.opts.pgOptions ? { options: this.opts.pgOptions } : {}),
});
client.on("notification", (msg) => {
if (msg.channel === this.channel) this.scheduleDrain();
});
const onLost = (err?: Error) => {
if (this.client !== client) return;
this.client = null;
client.removeAllListeners("notification");
client.end().catch(() => {});
if (!this.stopped) {
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
this.reconnect(500);
}
};
client.on("error", onLost);
client.on("end", () => onLost());
await client.connect();
await client.query(`listen ${this.channel}`);
this.client = client;
// 断线期间可能漏掉通知:连上后补读一次
this.scheduleDrain();
}
private reconnect(delayMs: number) {
if (this.stopped || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
this.log.info("SSE 总线 LISTEN 已重连");
} catch (err) {
this.log.warn({ err }, "SSE 总线重连失败");
this.reconnect(Math.min(delayMs * 2, this.opts.maxReconnectMs ?? 10_000));
}
}, delayMs);
}
private scheduleDrain() {
if (this.draining) {
this.drainAgain = true;
return;
}
this.draining = (async () => {
try {
do {
this.drainAgain = false;
await this.drainOnce();
} while (this.drainAgain && !this.stopped);
} catch (err) {
this.log.warn({ err }, "SSE 总线补读失败");
} finally {
this.draining = null;
}
})();
}
private async drainOnce() {
for (;;) {
const rows = await sql<Row>`select seq, type, at, payload from sse_event
where seq > ${this.lastDispatched} order by seq limit 500`.execute(this.opts.db);
for (const r of rows.rows) {
const e = toEnvelope(r);
this.lastDispatched = e.seq;
for (const l of this.listeners) {
try {
l(e);
} catch (err) {
this.log.warn({ err, type: e.type }, "SSE 订阅者异常");
}
}
}
if (rows.rows.length < 500) return;
}
}
async publish<P>(type: string, payload: P): Promise<EventEnvelope<P> | null> {
try {
const row = await this.opts.db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(${PUBLISH_LOCK_KEY})`.execute(trx);
const r = await sql<Row>`insert into sse_event (type, payload)
values (${type}, ${JSON.stringify(payload ?? null)}::jsonb) returning seq, type, at, payload`.execute(trx);
const inserted = r.rows[0]!;
// NOTIFY 在提交时才投递
await sql`select pg_notify(${this.channel}, ${String(inserted.seq)})`.execute(trx);
return inserted;
});
this.published += 1;
if (this.published % this.pruneEvery === 0) void this.prune();
return toEnvelope(row) as EventEnvelope<P>;
} catch (err) {
this.log.error({ err, type }, "SSE 事件发布失败");
return null;
}
}
/** 只保留最近 retain 条 */
async prune(): Promise<void> {
try {
await sql`delete from sse_event where seq <= (select max(seq) from sse_event) - ${this.retain}`.execute(
this.opts.db,
);
} catch (err) {
this.log.warn({ err }, "sse_event 裁剪失败");
}
}
async since(after: number, limit = 1000): Promise<SinceResult> {
const [rows, bounds] = await Promise.all([
sql<Row>`select seq, type, at, payload from sse_event where seq > ${after} order by seq limit ${limit}`.execute(
this.opts.db,
),
sql<{ lo: number | null; hi: number | null }>`select min(seq) as lo, max(seq) as hi from sse_event`.execute(
this.opts.db,
),
]);
const lo = bounds.rows[0]?.lo;
const hi = Number(bounds.rows[0]?.hi ?? 0);
// 裁剪过:请求起点早于最老保留条目
const gap = after > 0 && lo != null && after < Number(lo) - 1;
return { events: rows.rows.map(toEnvelope), gap, latestSeq: Math.max(hi, after) };
}
subscribe(l: BusListener): () => void {
this.listeners.add(l);
return () => this.listeners.delete(l);
}
async stop(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
const c = this.client;
this.client = null;
await c?.end().catch(() => {});
await this.draining;
}
}
+50 -22
View File
@@ -3,27 +3,39 @@ import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { ApiException } from "./errors.ts";
type Listener = (e: EventEnvelope) => void;
export type BusListener = (e: EventEnvelope) => void;
/**
* SSE 总线端口。模块与 SSE 路由只依赖这个接口;W2-A 把实现换成 PG NOTIFY + 持久化 seq 时接口不变。
*/
export interface EventBusPort {
readonly latestSeq: number;
publish<P>(type: string, payload: P): EventEnvelope<P>;
/** 取 seq > after 的事件;gap=true 表示起点已不可补读,客户端需重新拉快照 */
since(after: number): { events: EventEnvelope[]; gap: boolean };
subscribe(l: Listener): () => void;
/** since() 结果:gap=true 表示起点已不可补读(被裁剪),客户端需重新拉快照 */
export interface SinceResult {
events: EventEnvelope[];
gap: boolean;
latestSeq: number;
}
/**
* 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。
* 多进程部署时需换成 PG LISTEN/NOTIFY 或 outbox 投影(W2)。
* SSE 总线端口。模块与 SSE 路由只依赖这个接口。
* 生产实现是 PgEventBus(sse-pg.ts:sse_event 表 + LISTEN/NOTIFY,多进程可见);
* EventBus 是进程内实现,只给单元测试与无库场景用。
*/
export interface EventBusPort {
/**
* 发布事件并广播给所有进程的订阅者;resolve 为带 seq 的信封。
* 永不 reject:持久化失败时记日志并返回 null(调用方可以不 await)。
*/
publish<P>(type: string, payload: P): Promise<EventEnvelope<P> | null>;
/** 取 seq > after 的事件(按 seq 升序,最多 limit 条) */
since(after: number, limit?: number): Promise<SinceResult>;
/** 订阅本进程收到的事件(含其它进程发布的);返回退订函数 */
subscribe(l: BusListener): () => void;
}
/**
* 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。仅测试/单进程无库场景使用。
*/
export class EventBus implements EventBusPort {
private seq = 0;
private readonly buffer: EventEnvelope[] = [];
private readonly listeners = new Set<Listener>();
private readonly listeners = new Set<BusListener>();
constructor(private readonly capacity = 1000) {}
@@ -31,7 +43,8 @@ export class EventBus implements EventBusPort {
return this.seq;
}
publish<P>(type: string, payload: P): EventEnvelope<P> {
/** 同步发布(测试便利);publish 是它的异步包装 */
publishSync<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();
@@ -39,19 +52,28 @@ export class EventBus implements EventBusPort {
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 };
publish<P>(type: string, payload: P): Promise<EventEnvelope<P> | null> {
return Promise.resolve(this.publishSync(type, payload));
}
subscribe(l: Listener): () => void {
sinceSync(after: number, limit = 1000): SinceResult {
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).slice(0, limit), gap, latestSeq: this.seq };
}
since(after: number, limit?: number): Promise<SinceResult> {
return Promise.resolve(this.sinceSync(after, limit));
}
subscribe(l: BusListener): () => void {
this.listeners.add(l);
return () => this.listeners.delete(l);
}
}
const SINCE_PAGE = 500;
export interface SseOptions {
bus: EventBusPort;
keepaliveMs?: number;
@@ -79,10 +101,10 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
wake?.();
});
const { events, gap } = bus.since(after);
const { events, gap, latestSeq } = await bus.since(after, SINCE_PAGE);
let last = after;
if (gap) {
await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq: bus.latestSeq }) });
await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq }) });
}
const write = async (e: EventEnvelope) => {
if (e.seq <= last) return;
@@ -90,6 +112,12 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
await stream.writeSSE({ id: String(e.seq), event: e.type, data: JSON.stringify(e) });
};
for (const e of events) await write(e);
// 积压超过一页时继续翻页补读(实时事件已在 queue 里排队,write 按 seq 去重)
let page = events;
while (page.length >= SINCE_PAGE && !closed) {
page = (await bus.since(last, SINCE_PAGE)).events;
for (const e of page) await write(e);
}
const timer = setInterval(() => {
void stream.write(": keepalive\n\n");
@@ -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;
});
}
@@ -0,0 +1,374 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sql } from "@umt/db";
import { createMockEmby, createMockQb, type MockEmby, type MockQb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { DEV_KEY, json } from "../helpers.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
let h: Harness;
let admin: ReturnType<typeof bearer>;
let qb: MockQb;
let emby: MockEmby;
const dir = mkdtempSync(join(tmpdir(), "umt-w2a-"));
beforeAll(async () => {
h = await createHarness({ engine: true });
admin = bearer(h.app, DEV_KEY);
qb = await createMockQb({ username: "qbuser", password: "qb-secret-pass" });
emby = await createMockEmby({ apiKey: "emby-secret-key" });
}, 60_000);
afterAll(async () => {
await qb?.close();
await emby?.close();
await h?.close();
rmSync(dir, { recursive: true, force: true });
});
const noLeak = (secret: string) => {
expect(h.lines.join("\n")).not.toContain(secret);
};
describe("API Key", () => {
it("创建一次性返回明文;列表不含明文/哈希;scope 校验;read key 不能写;撤销后 401", async () => {
expect((await admin("POST", "/api-keys", { name: "bad", scopes: ["root"] })).status).toBe(422);
expect((await admin("POST", "/api-keys", { name: "empty", scopes: [] })).status).toBe(422);
const created = await json(await admin("POST", "/api-keys", { name: "reader", scopes: ["read", "read"] }));
const key: string = created.data.plaintextKey;
expect(key).toMatch(/^umt_/);
expect(created.data.apiKey.scopes).toEqual(["read"]);
const list = await json(await admin("GET", "/api-keys"));
const text = JSON.stringify(list);
expect(text).not.toContain(key);
expect(text).not.toContain("keyHash");
expect(list.data.items[0].name).toBe("reader");
const reader = bearer(h.app, key);
expect((await reader("GET", "/settings")).status).toBe(200);
expect((await reader("PATCH", "/settings", { automation: { enabled: false } })).status).toBe(403);
expect((await reader("GET", "/api-keys")).status).toBe(403);
expect((await reader("GET", "/engine/runs")).status).toBe(403);
const id = created.data.apiKey.id;
expect((await admin("PATCH", `/api-keys/${id}`, { scopes: ["read", "organize.read"] })).status).toBe(200);
expect((await admin("DELETE", `/api-keys/${id}`)).status).toBe(200);
expect((await reader("GET", "/settings")).status).toBe(401);
expect((await admin("DELETE", "/api-keys/00000000-0000-4000-8000-000000000000")).status).toBe(404);
noLeak(key);
});
it("游标分页", async () => {
for (const n of ["k1", "k2", "k3"]) await admin("POST", "/api-keys", { name: n, scopes: ["read"] });
const p1 = await json(await admin("GET", "/api-keys?limit=2"));
expect(p1.data.items).toHaveLength(2);
const p2 = await json(await admin("GET", `/api-keys?limit=2&cursor=${p1.data.nextCursor}`));
const ids = [...p1.data.items, ...p2.data.items].map((x: { id: string }) => x.id);
expect(new Set(ids).size).toBe(ids.length);
expect((await admin("GET", "/api-keys?cursor=garbage")).status).toBe(400);
});
});
describe("设置", () => {
it("GET 缺省值;PATCH 自动化总开关与只读对账;ctx.settings 缓存失效并广播 settings.changed", async () => {
const g = await json(await admin("GET", "/settings"));
expect(g.data.automation).toEqual({ enabled: true, readOnlyReconcile: false });
expect((await h.ctx.settings.get("automation")).enabled).toBe(true);
const events: string[] = [];
const off = h.bus.subscribe((e) => events.push(e.type));
const r = await admin("PATCH", "/settings", { automation: { enabled: false, readOnlyReconcile: true } });
expect(r.status).toBe(200);
off();
expect(events).toContain("settings.changed");
expect(await h.ctx.settings.get("automation")).toEqual({ enabled: false, readOnlyReconcile: true });
// 其它分组不变
expect((await json(await admin("GET", "/settings"))).data.cache.hardThresholdPercent).toBe(90);
});
it("非法合并结果 → 422(恢复水位必须低于硬阈值)", async () => {
const r = await admin("PATCH", "/settings", { cache: { resumePercent: 95 } });
expect(r.status).toBe(422);
expect((await json(r)).error.message).toMatch(/恢复水位/);
});
it("Pi apiKey 只写:存为 secret,响应/审计/日志不含明文;null 清除并删除 secret", async () => {
const plain = "sk-live-super-secret-0001";
const r = await json(await admin("PATCH", "/settings", { pi: { apiKey: plain, model: "claude-x" } }));
const ref = r.data.pi.apiKeySecretRef;
expect(ref).toMatch(/^[0-9a-f-]{36}$/);
expect(JSON.stringify(r)).not.toContain(plain);
expect(await h.ctx.secrets.resolve(ref)).toBe(plain);
// 再改一次:原地轮换,ref 不变
const r2 = await json(await admin("PATCH", "/settings", { pi: { apiKey: `${plain}-2` } }));
expect(r2.data.pi.apiKeySecretRef).toBe(ref);
expect(await h.ctx.secrets.resolve(ref)).toBe(`${plain}-2`);
const audits = await sql<{
payload: unknown;
}>`select payload from audit_event where action = 'settings.update'`.execute(h.db);
expect(JSON.stringify(audits.rows)).not.toContain(plain);
const r3 = await json(await admin("PATCH", "/settings", { pi: { apiKey: null } }));
expect(r3.data.pi.apiKeySecretRef).toBeNull();
await expect(h.ctx.secrets.resolve(ref)).rejects.toThrow(/不存在/);
noLeak(plain);
});
});
describe("凭证", () => {
it("/secrets 只写明文,列表只有元数据;重名 409;轮换;被引用时删除 409", async () => {
const c = await json(
await admin("POST", "/secrets", { name: "tmdb", purpose: "metadata_api_key", plaintext: "tmdb-plain-xyz" }),
);
expect(c.data).toMatchObject({
name: "tmdb",
purpose: "metadata_api_key",
algorithm: "aes-256-gcm",
kdf: "hkdf-sha256",
});
expect(JSON.stringify(c)).not.toContain("tmdb-plain-xyz");
expect((await admin("POST", "/secrets", { name: "tmdb", purpose: "other", plaintext: "y" })).status).toBe(409);
const list = await json(await admin("GET", "/secrets"));
expect(JSON.stringify(list)).not.toMatch(/ciphertext|nonce|authTag|tmdb-plain-xyz/);
const rot = await json(await admin("PATCH", `/secrets/${c.data.id}`, { plaintext: "tmdb-plain-2" }));
expect(rot.data.rotatedAt).toBeTruthy();
expect(await h.ctx.secrets.resolve(c.data.id)).toBe("tmdb-plain-2");
expect((await admin("DELETE", `/secrets/${c.data.id}`)).status).toBe(200);
noLeak("tmdb-plain");
});
});
describe("实例与路径", () => {
it("library_root:登记写哨兵文件;路径不存在 412;test 反映哨兵在线/离线;重名 409", async () => {
const r = await admin("POST", "/library-roots", { name: "电影", localPath: dir, mediaKind: "movie" });
expect(r.status).toBe(200);
const root = (await json(r)).data;
expect(root).toMatchObject({
sentinelFile: ".uhd-root",
healthStatus: "healthy",
managementMode: "register_only",
});
expect(existsSync(join(dir, ".uhd-root"))).toBe(true);
expect((await admin("POST", "/library-roots", { name: "电影", localPath: `${dir}/x` })).status).toBe(412);
expect((await admin("POST", "/library-roots", { name: "电影", localPath: dir })).status).toBe(409);
expect((await json(await admin("POST", `/library-roots/${root.id}/test`))).data.ok).toBe(true);
rmSync(join(dir, ".uhd-root"));
const t = (await json(await admin("POST", `/library-roots/${root.id}/test`))).data;
expect(t).toMatchObject({ ok: false, healthStatus: "offline" });
expect((await json(await admin("GET", `/library-roots/${root.id}`))).data.healthStatus).toBe("offline");
expect((await admin("DELETE", `/library-roots/${root.id}`)).status).toBe(200);
expect((await admin("GET", `/library-roots/${root.id}`)).status).toBe(404);
});
it("downloader_instance:凭证只写 → secretRef;test 用 qB client 测连通;错密码 → ok=false;删除连带删 secret", async () => {
const c = await json(
await admin("POST", "/downloader-instances", {
name: "qb1",
baseUrl: qb.url,
username: "qbuser",
credential: "qb-secret-pass",
}),
);
const inst = c.data;
expect(inst.secretRef).toBeTruthy();
expect(JSON.stringify(c)).not.toContain("qb-secret-pass");
const t = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data;
expect(t).toMatchObject({ ok: true, version: expect.any(String), healthStatus: "healthy" });
const got = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data;
expect(got.healthStatus).toBe("healthy");
expect(got.versionInfo.app).toBeTruthy();
await admin("PATCH", `/downloader-instances/${inst.id}`, { credential: "wrong-pass" });
const again = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data;
expect(again.secretRef).toBe(inst.secretRef);
const bad = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data;
expect(bad).toMatchObject({ ok: false, healthStatus: "offline" });
expect((await admin("DELETE", `/downloader-instances/${inst.id}`)).status).toBe(200);
await expect(h.ctx.secrets.resolve(inst.secretRef)).rejects.toThrow(/不存在/);
noLeak("qb-secret-pass");
});
it("media_server_instance:无 key 时 test 报未配置;有 key 时 ping 成功", async () => {
const a = (await json(await admin("POST", "/media-server-instances", { name: "emby-nokey", baseUrl: emby.url })))
.data;
expect((await json(await admin("POST", `/media-server-instances/${a.id}/test`))).data).toMatchObject({
ok: false,
});
const b = (
await json(
await admin("POST", "/media-server-instances", {
name: "emby",
baseUrl: emby.url,
credential: "emby-secret-key",
}),
)
).data;
const t = (await json(await admin("POST", `/media-server-instances/${b.id}/test`))).data;
expect(t).toMatchObject({ ok: true, healthStatus: "healthy" });
expect((await json(await admin("GET", "/media-server-instances"))).data.items).toHaveLength(2);
});
it("path_mapping:viewKind 与实例不匹配 422;verify 按本地目录可达判定", async () => {
const d = (await json(await admin("POST", "/downloader-instances", { name: "qb2", baseUrl: qb.url }))).data;
expect(
(
await admin("POST", "/path-mappings", {
viewKind: "media_server",
downloaderInstanceId: d.id,
remotePrefix: "/downloads",
localPrefix: dir,
})
).status,
).toBe(422);
const m = (
await json(
await admin("POST", "/path-mappings", {
viewKind: "downloader",
downloaderInstanceId: d.id,
remotePrefix: "/downloads",
localPrefix: dir,
}),
)
).data;
expect(m.verifyStatus).toBe("unverified");
expect((await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data.verifyStatus).toBe("ok");
await admin("PATCH", `/path-mappings/${m.id}`, { localPrefix: `${dir}/missing` });
const v = (await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data;
expect(v).toMatchObject({ verifyStatus: "failed" });
expect(v.verifyDetail.code).toBe("ENOENT");
expect((await admin("DELETE", `/path-mappings/${m.id}`)).status).toBe(200);
});
it("PUT /sites/{id}/credentials:新建/替换账号凭证,不回显,旧 secret 删除;未知站点 404", async () => {
const site = await sql<{ id: string }>`insert into site (key, name, adapter, base_url)
values ('audiences', '观众', 'nexusphp', 'https://audiences.me') returning id`.execute(h.db);
const siteId = site.rows[0]!.id;
const r1 = await json(
await admin("PUT", `/sites/${siteId}/credentials`, {
authKind: "cookie",
credential: "c_secure_uid=1; pass=abc",
}),
);
expect(r1.data).toMatchObject({ created: true, label: "default", authKind: "cookie" });
expect(JSON.stringify(r1)).not.toContain("pass=abc");
expect(await h.ctx.secrets.resolve(r1.data.secretRef)).toBe("c_secure_uid=1; pass=abc");
const r2 = await json(
await admin("PUT", `/sites/${siteId}/credentials`, {
authKind: "password",
credential: "pw-123456",
username: "me",
}),
);
expect(r2.data).toMatchObject({ created: false, siteAccountId: r1.data.siteAccountId });
await expect(h.ctx.secrets.resolve(r1.data.secretRef)).rejects.toThrow(/不存在/);
const acc = await sql<{ auth_kind: string; user_info: { username?: string } }>`
select auth_kind, user_info from site_account where id = ${r1.data.siteAccountId}`.execute(h.db);
expect(acc.rows[0]).toMatchObject({ auth_kind: "password", user_info: { username: "me" } });
expect(
(
await admin("PUT", "/sites/00000000-0000-4000-8000-000000000000/credentials", {
authKind: "cookie",
credential: "x",
})
).status,
).toBe(404);
noLeak("pass=abc");
noLeak("pw-123456");
});
});
describe("审计查询", () => {
it("按 action 前缀 / target / actor 过滤,游标翻页", async () => {
const all = await json(await admin("GET", "/audit-events?action=api_key.*&limit=200"));
expect(all.data.items.length).toBeGreaterThanOrEqual(4);
expect(all.data.items.every((e: { action: string }) => e.action.startsWith("api_key."))).toBe(true);
const byActor = await json(await admin("GET", "/audit-events?actorType=user&actorId=dev-admin&limit=200"));
expect(byActor.data.items.length).toBeGreaterThan(0);
const p1 = await json(await admin("GET", "/audit-events?limit=3"));
const p2 = await json(await admin("GET", `/audit-events?limit=3&cursor=${p1.data.nextCursor}`));
const ids = [...p1.data.items, ...p2.data.items].map((e: { id: string }) => e.id);
expect(new Set(ids).size).toBe(6);
expect(p1.data.items[0].at >= p2.data.items[0].at).toBe(true);
const t = await json(await admin("GET", "/audit-events?targetType=site_account"));
expect(t.data.items.map((e: { action: string }) => e.action)).toEqual([
"site_account.credentials_set",
"site_account.credentials_set",
]);
});
});
describe("引擎面板", () => {
const ow = () => h.engineSchema;
beforeAll(async () => {
const t = (n: string) => sql.raw(`"${ow()}".${n}`);
const run = (id: string, status: string, name: string, at: string, extra = "") =>
sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, context, input, attempts,
worker_id, available_at, started_at, created_at, updated_at)
values ('default', ${id}, ${name}, 'v1', ${status}, '{"retry":1}'::jsonb, '{"secret":"ctx"}'::jsonb,
${JSON.stringify({ url: "https://x/rss?passkey=abc123", password: "p@ss", n: 1, extra })}::jsonb, 1,
${status === "running" ? "worker-a" : null}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)})`.execute(
h.db,
);
await run("r1", "completed", "ingestFeed", "2026-09-01T00:00:00Z");
await run("r2", "failed", "ingestFeed", "2026-09-02T00:00:00Z");
await run("r3", "running", "submitDownload", "2026-09-03T00:00:00Z");
await run("r4", "pending", "submitDownload", "2026-09-04T00:00:00Z");
await sql`insert into ${t("step_attempts")} (namespace_id, id, workflow_run_id, step_name, kind, status, config, context, output,
started_at, created_at, updated_at)
values ('default', 's1', 'r3', 'wait-done', 'signal-wait', 'running', '{}'::jsonb,
'{"kind":"signal-wait","signal":"umt:r3:done","timeoutAt":"2026-09-05T00:00:00.000Z"}'::jsonb, null, now(), now(), now()),
('default', 's0', 'r3', 'login', 'function', 'completed', '{}'::jsonb, null, '{"token":"tkn-1","ok":true}'::jsonb, now(), now() - interval '1 minute', now())`.execute(
h.db,
);
await sql`insert into ${t("workflow_signals")} (namespace_id, id, signal, data, workflow_run_id, step_attempt_id)
values ('default', 'sig1', 'umt:r3:done', '{"cookie":"c=1"}'::jsonb, 'r3', 's1')`.execute(h.db);
});
it("runs 列表:状态/名称/时间筛选 + 游标", async () => {
const all = await json(await admin("GET", "/engine/runs"));
expect(all.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3", "r2", "r1"]);
expect(JSON.stringify(all)).not.toMatch(/config|context|passkey/);
const byName = await json(await admin("GET", "/engine/runs?workflowName=ingestFeed&status=failed"));
expect(byName.data.items.map((r: { id: string }) => r.id)).toEqual(["r2"]);
const ranged = await json(await admin("GET", "/engine/runs?from=2026-09-02T00:00:00Z&to=2026-09-04T00:00:00Z"));
expect(ranged.data.items.map((r: { id: string }) => r.id)).toEqual(["r3", "r2"]);
const p1 = await json(await admin("GET", "/engine/runs?limit=3"));
const p2 = await json(await admin("GET", `/engine/runs?limit=3&cursor=${p1.data.nextCursor}`));
expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]);
expect(p2.data.nextCursor).toBeNull();
expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422);
});
it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => {
const d = (await json(await admin("GET", "/engine/runs/r3"))).data;
expect(d.input).toMatchObject({ password: "[REDACTED]", n: 1 });
expect(d.input.url).not.toContain("abc123");
expect(d.stepAttempts.map((s: { id: string }) => s.id)).toEqual(["s0", "s1"]);
expect(d.stepAttempts[0].output).toEqual({ token: "[REDACTED]", ok: true });
expect(d.stepAttempts[1]).toMatchObject({ waitSignal: "umt:r3:done", timeoutAt: "2026-09-05T00:00:00.000Z" });
expect(d.signals[0]).toMatchObject({ signal: "umt:r3:done", data: { cookie: "[REDACTED]" } });
expect(JSON.stringify(d)).not.toMatch(/"config"|"context"|"retry"|"ctx"/);
expect((await admin("GET", "/engine/runs/nope")).status).toBe(404);
});
it("stats:按状态计数、最老待处理、活跃 worker 快照", async () => {
const s = (await json(await admin("GET", "/engine/stats"))).data;
expect(s.byStatus).toMatchObject({ pending: 1, running: 1, completed: 1, failed: 1, canceled: 0 });
expect(s.oldestPending).toMatchObject({ id: "r4", workflowName: "submitDownload" });
expect(s.activeWorkers).toEqual([{ workerId: "worker-a", running: 1, since: "2026-09-03T00:00:00.000Z" }]);
});
it("cancel 走 TaskEngine.cancel 并审计;未知 run 404", async () => {
const r = await json(await admin("POST", "/engine/runs/r4/cancel", { reason: "测试" }));
expect(r.data).toEqual({ canceled: true });
expect(h.cancel).toHaveBeenCalledWith("r4", "测试");
expect((await admin("POST", "/engine/runs/nope/cancel", {})).status).toBe(404);
const a = await sql<{
n: number;
}>`select count(*)::int as n from audit_event where action = 'engine.run_cancel'`.execute(h.db);
expect(a.rows[0]!.n).toBe(1);
});
});
});
+176
View File
@@ -0,0 +1,176 @@
import { type DB, migrateToLatest } from "@umt/db";
import { migrateEngineSchema } from "@umt/tasks";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { vi } from "vitest";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts";
import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { DEV_KEY, memoryLogger } from "../helpers.ts";
// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const MASTER_KEY = "test-master-key-0123456789";
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
/**
* Kysely 迁移器会反射库里所有 schema 的表;别的测试文件同时 drop schema 时反射会报"schema 不存在"。
* 同一进程内的测试文件用 PG 咨询锁把"迁移 / 删 schema"串行化,另加重试兜底(其它进程的测试也在用同一个库)。
*/
const SCHEMA_LOCK = 0x756d7431;
export async function withSchemaLock<T>(fn: () => Promise<T>): Promise<T> {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
try {
await c.query("select pg_advisory_lock($1)", [SCHEMA_LOCK]);
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= 4 || !/does not exist/.test(String((err as Error)?.message))) throw err;
await new Promise((r) => setTimeout(r, 200 * attempt));
}
}
} finally {
await c.query("select pg_advisory_unlock($1)", [SCHEMA_LOCK]).catch(() => {});
await c.end();
}
}
async function adminQuery(q: string) {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
try {
await c.query(q);
} finally {
await c.end();
}
}
export async function createHarness(opts: { engine?: boolean } = {}) {
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const schema = `w2a_test_${uniq}`;
const engineSchema = `${schema}_ow`;
await adminQuery(`create schema ${schema}`);
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 5, options: `-c search_path=${schema},public` }),
}),
});
await withSchemaLock(async () => {
await migrateToLatest(db, { migrationTableSchema: schema });
if (opts.engine) await migrateEngineSchema(PG_URL, engineSchema);
});
const { logger, lines } = memoryLogger();
const bus = new EventBus(100);
const config = ConfigSchema.parse({
DATABASE_URL: PG_URL,
SECRET_MASTER_KEY: MASTER_KEY,
NODE_ENV: "development",
DEV_ADMIN_KEY: DEV_KEY,
});
const cancel = vi.fn(async (_runId: string, _reason?: string) => true);
const ctx = {
role: "all",
db,
config,
logger,
events: bus,
engine: { cancel } as unknown as ServerContext["engine"],
outbox: createOutboxPort(),
secrets: createSecretStore({ db, masterKey: MASTER_KEY }),
settings: createSettingsReader({ db, logger, events: bus }),
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema });
const app = createApp({ config, logger, bus, db, role: "all", version: "0.0.0-test", modules: [mod], ctx });
return {
db,
app,
ctx,
bus,
lines,
cancel,
schema,
engineSchema,
async close() {
await db.destroy();
await withSchemaLock(async () => {
await adminQuery(`drop schema if exists ${schema} cascade`);
await adminQuery(`drop schema if exists ${engineSchema} cascade`);
});
},
};
}
export type Harness = Awaited<ReturnType<typeof createHarness>>;
/** 极简 cookie jar:记住 Set-Cookie,请求时带上;写方法自动带 CSRF 头 */
export class Browser {
cookies = new Map<string, string>();
constructor(
private readonly app: Harness["app"],
readonly ip = "203.0.113.10",
) {}
absorb(res: Response) {
for (const sc of res.headers.getSetCookie()) {
const [pair] = sc.split(";");
const i = pair!.indexOf("=");
const k = pair!.slice(0, i).trim();
const v = pair!.slice(i + 1).trim();
if (!v || /max-age=0/i.test(sc)) this.cookies.delete(k);
else this.cookies.set(k, decodeURIComponent(v));
}
}
async req(
method: string,
path: string,
body?: unknown,
opts: { csrf?: boolean; headers?: Record<string, string> } = {},
) {
const headers: Record<string, string> = { "x-real-ip": this.ip, ...opts.headers };
if (this.cookies.size)
headers.cookie = [...this.cookies].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("; ");
if (body !== undefined) headers["content-type"] = "application/json";
const csrf = this.cookies.get("umt_csrf");
if (method !== "GET" && opts.csrf !== false && csrf) headers["x-csrf-token"] = csrf;
const res = await this.app.request(`/api/v1${path}`, {
method,
headers,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
this.absorb(res);
return res;
}
}
/** Bearer 调用 */
export function bearer(app: Harness["app"], key: string) {
return (method: string, path: string, body?: unknown) =>
app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
}
@@ -0,0 +1,218 @@
import { sql } from "@umt/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
ensureSetupToken,
MAX_FAILURES_PER_ACCOUNT,
MAX_FAILURES_PER_IP,
} from "../../src/modules/auth-settings/auth-service.ts";
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
import { DEV_KEY, json } from "../helpers.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
const hasPg = await pgAvailable();
const PASSWORD = "correct horse battery";
describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness();
}, 60_000);
afterAll(async () => {
await h?.close();
});
beforeEach(async () => {
await sql`delete from login_attempt`.execute(h.db);
});
const login = (b: Browser, password = PASSWORD, extra: Record<string, unknown> = {}) =>
b.req("POST", "/auth/login", { username: "admin", password, ...extra });
it("未初始化:session 探测免鉴权;setup token 只打印一次到日志;错 token 401;并发初始化只有一个成功", async () => {
const b = new Browser(h.app);
const s0 = await json(await b.req("GET", "/auth/session"));
expect(s0.data).toMatchObject({ initialized: false, authenticated: false, actor: null });
expect((await login(b)).status).toBe(412);
const token = await ensureSetupToken(h.ctx);
expect(token).toBeTruthy();
expect(h.lines.filter((l) => l.includes(token!))).toHaveLength(1);
// 库里只有哈希
const row = await sql<{ setup_token_hash: string }>`select setup_token_hash from admin_user`.execute(h.db);
expect(row.rows[0]!.setup_token_hash).not.toContain(token!);
const bad = await b.req("POST", "/auth/setup", {
setupToken: "x".repeat(32),
username: "admin",
password: PASSWORD,
});
expect(bad.status).toBe(401);
// 密码太短 → 422
expect(
(await b.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: "short" })).status,
).toBe(422);
const [r1, r2] = await Promise.all([
new Browser(h.app, "198.51.100.1").req("POST", "/auth/setup", {
setupToken: token,
username: "admin",
password: PASSWORD,
}),
new Browser(h.app, "198.51.100.2").req("POST", "/auth/setup", {
setupToken: token,
username: "admin",
password: PASSWORD,
}),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 409]);
const ok = r1.status === 200 ? r1 : r2;
const setCookies = ok.headers.getSetCookie();
const sess = setCookies.find((c) => c.startsWith("umt_session="))!;
expect(sess).toMatch(/HttpOnly/i);
expect(sess).toMatch(/SameSite=Lax/i);
expect(setCookies.find((c) => c.startsWith("umt_csrf="))).not.toMatch(/HttpOnly/i);
// 已初始化后不再生成 token
expect(await ensureSetupToken(h.ctx)).toBeNull();
const audits = await sql<{
n: number;
}>`select count(*)::int as n from audit_event where action = 'admin.setup'`.execute(h.db);
expect(audits.rows[0]!.n).toBe(1);
});
it("登录:错密码 401;正确密码下发 cookie;会话可访问 admin 端点;未登录 401", async () => {
const b = new Browser(h.app);
expect((await login(b, "wrong-password-1")).status).toBe(401);
expect((await b.req("GET", "/settings")).status).toBe(401);
const res = await login(b);
expect(res.status).toBe(200);
expect((await json(res)).data).toMatchObject({
status: "ok",
session: { authenticated: true, actor: { type: "admin", name: "admin" } },
});
const s = await json(await b.req("GET", "/auth/session"));
expect(s.data.authenticated).toBe(true);
expect(s.data.expiresAt).toBeTruthy();
expect((await b.req("GET", "/settings")).status).toBe(200);
// whoami 在 cookie 会话下同样可用
expect((await json(await b.req("GET", "/auth/whoami"))).data.kind).toBe("admin_session");
// 库里只存 sha256
const raw = b.cookies.get("umt_session")!;
const rows = await sql<{
n: number;
}>`select count(*)::int as n from admin_session where session_hash = ${raw}`.execute(h.db);
expect(rows.rows[0]!.n).toBe(0);
});
it("CSRF:cookie 会话的写请求缺少或伪造 x-csrf-token → 403;带正确值通过;Bearer 不需要", async () => {
const b = new Browser(h.app);
await login(b);
const patch = { automation: { enabled: true } };
expect((await b.req("PATCH", "/settings", patch, { csrf: false })).status).toBe(403);
expect(
(await b.req("PATCH", "/settings", patch, { csrf: false, headers: { "x-csrf-token": "forged" } })).status,
).toBe(403);
expect((await b.req("PATCH", "/settings", patch)).status).toBe(200);
expect((await bearer(h.app, DEV_KEY)("PATCH", "/settings", patch)).status).toBe(200);
});
it("限速:同 IP 失败 5 次后第 6 次(即使密码正确)→ 429,且被拦请求不落表", async () => {
const b = new Browser(h.app, "203.0.113.77");
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) expect((await login(b, `bad-${i}-password`)).status).toBe(401);
expect((await login(b)).status).toBe(429);
expect((await login(b)).status).toBe(429);
const n = await sql<{ n: number }>`select count(*)::int as n from login_attempt where ip = '203.0.113.77'`.execute(
h.db,
);
expect(n.rows[0]!.n).toBe(MAX_FAILURES_PER_IP);
// 其它 IP 不受影响
expect((await login(new Browser(h.app, "203.0.113.78"))).status).toBe(200);
});
it("限速:同账号跨 IP 累计失败 15 次 → 新 IP 也 429(防轮换 IP)", async () => {
for (let i = 0; i < MAX_FAILURES_PER_ACCOUNT; i++) {
expect((await login(new Browser(h.app, `192.0.2.${i + 1}`), `bad-${i}-pw`)).status).toBe(401);
}
expect((await login(new Browser(h.app, "192.0.2.99"))).status).toBe(429);
});
it("登出吊销会话;过期会话 401", async () => {
const b = new Browser(h.app);
await login(b);
const token = b.cookies.get("umt_session")!;
expect((await b.req("POST", "/auth/logout")).status).toBe(200);
expect(b.cookies.has("umt_session")).toBe(false);
b.cookies.set("umt_session", token);
expect((await b.req("GET", "/settings")).status).toBe(401);
const c = new Browser(h.app);
await login(c);
await sql`update admin_session set expires_at = now() - interval '1 second' where revoked_at is null`.execute(h.db);
expect((await c.req("GET", "/settings")).status).toBe(401);
});
it("改密:校验旧密码,吊销其它会话、保留当前会话;API Key 不能改密", async () => {
const a = new Browser(h.app);
const b = new Browser(h.app);
await login(a);
await login(b);
expect(
(await a.req("POST", "/auth/password", { currentPassword: "nope-nope-nope", newPassword: "another password 1" }))
.status,
).toBe(401);
const r = await a.req("POST", "/auth/password", { currentPassword: PASSWORD, newPassword: "another password 1" });
expect(r.status).toBe(200);
expect((await json(r)).data.revokedSessions).toBeGreaterThanOrEqual(1);
expect((await a.req("GET", "/settings")).status).toBe(200);
expect((await b.req("GET", "/settings")).status).toBe(401);
expect((await login(new Browser(h.app))).status).toBe(401);
expect((await login(new Browser(h.app), "another password 1")).status).toBe(200);
// 改回来,后续用例用 PASSWORD
expect(
(await a.req("POST", "/auth/password", { currentPassword: "another password 1", newPassword: PASSWORD })).status,
).toBe(200);
expect(
(
await bearer(h.app, DEV_KEY)("POST", "/auth/password", {
currentPassword: PASSWORD,
newPassword: "x".repeat(12),
})
).status,
).toBe(403);
const audits = await sql<{
n: number;
}>`select count(*)::int as n from audit_event where action = 'admin.password_change'`.execute(h.db);
expect(audits.rows[0]!.n).toBe(2);
});
it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => {
const a = new Browser(h.app);
await login(a);
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false });
const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data;
expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
const secret: string = begin.secret;
expect((await a.req("POST", "/auth/totp", { action: "enable", code: "000000" })).status).toBe(401);
const code0 = totpAt(secret, totpCounter());
expect((await a.req("POST", "/auth/totp", { action: "enable", code: code0 })).status).toBe(200);
const b = new Browser(h.app, "203.0.113.200");
const step1 = await json(await login(b));
expect(step1.data).toEqual({ status: "totp_required" });
expect(b.cookies.has("umt_session")).toBe(false);
// 同一时间窗的码已在 enable 时消费 → 重放失败;下一个窗口的码可用
expect((await login(b, PASSWORD, { totpCode: code0 })).status).toBe(401);
const next = totpAt(secret, totpCounter() + 1);
expect((await login(b, PASSWORD, { totpCode: next })).status).toBe(200);
expect((await login(new Browser(h.app, "203.0.113.201"), PASSWORD, { totpCode: next })).status).toBe(401);
const fails = await sql<{
n: number;
}>`select count(*)::int as n from login_attempt where reason = 'bad_totp'`.execute(h.db);
expect(fails.rows[0]!.n).toBe(2);
// 停用需要密码 + 新验证码(计数器已到 now+1,用 now+2 不在窗口内 → 这里直接把计数器回拨模拟时间流逝)
await sql`update admin_user set totp_last_counter = totp_last_counter - 5`.execute(h.db);
const code = totpAt(secret, totpCounter());
expect((await a.req("POST", "/auth/totp", { action: "disable", password: "bad-password", code })).status).toBe(401);
expect((await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code })).status).toBe(200);
expect((await json(await login(new Browser(h.app, "203.0.113.202")))).data.status).toBe("ok");
});
});
@@ -0,0 +1,97 @@
import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
base32Decode,
base32Encode,
hashPassword,
totpAt,
verifyPassword,
verifyTotp,
} from "../../src/modules/auth-settings/crypto.ts";
import { createSecretStore, SecretError } from "../../src/modules/auth-settings/provided.ts";
import { createHarness, type Harness, MASTER_KEY, pgAvailable } from "./harness.ts";
describe("密码学原语", () => {
it("argon2id(hash-wasm)输出标准 PHC 串,校验正反", async () => {
const h = await hashPassword("pässwörd-123");
expect(h).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/);
expect(await verifyPassword("pässwörd-123", h)).toBe(true);
expect(await verifyPassword("password-123", h)).toBe(false);
expect(await verifyPassword("x", "not-a-hash")).toBe(false);
});
it("TOTP:RFC 6238 测试向量(SHA1)+ 防重放", () => {
const secret = base32Encode(Buffer.from("12345678901234567890"));
expect(base32Decode(secret).toString()).toBe("12345678901234567890");
// RFC 6238 附录 B:T=59s → 94287082(8 位),6 位取后 6 位
expect(totpAt(secret, 1)).toBe("287082");
expect(totpAt(secret, Math.floor(1111111109 / 30))).toBe("081804");
const now = 1_700_000_000_000;
const c = Math.floor(now / 30_000);
expect(verifyTotp(secret, totpAt(secret, c), null, now)).toBe(c);
expect(verifyTotp(secret, totpAt(secret, c - 1), null, now)).toBe(c - 1);
expect(verifyTotp(secret, totpAt(secret, c), c, now)).toBeNull();
expect(verifyTotp(secret, totpAt(secret, c + 3), null, now)).toBeNull();
});
});
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("SecretStore(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness();
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("create/resolve/replace(rotate)/remove 往返;库里无明文;每次加密盐与 nonce 不同", async () => {
const s = h.ctx.secrets;
const ref = await s.create({ purpose: "site_cookie", plaintext: "uid=1; pass=topsecret" });
expect(await s.resolve(ref)).toBe("uid=1; pass=topsecret");
const raw = await sql<{ ciphertext: Buffer; nonce: Buffer; kdf_params: { salt: string } }>`
select ciphertext, nonce, kdf_params from secret where id = ${ref}`.execute(h.db);
const before = raw.rows[0]!;
expect(before.ciphertext.toString("utf8")).not.toContain("topsecret");
await s.replace(ref, "uid=1; pass=rotated");
expect(await s.resolve(ref)).toBe("uid=1; pass=rotated");
const after = (
await sql<{ nonce: Buffer; kdf_params: { salt: string }; rotated_at: Date | null }>`
select nonce, kdf_params, rotated_at from secret where id = ${ref}`.execute(h.db)
).rows[0]!;
expect(after.nonce.equals(before.nonce)).toBe(false);
expect(after.kdf_params.salt).not.toBe(before.kdf_params.salt);
expect(after.rotated_at).toBeTruthy();
await s.remove(ref);
await expect(s.resolve(ref)).rejects.toBeInstanceOf(SecretError);
});
it("主密钥不对 / 密文挪到别的行 / 改用途 → 解密失败,错误信息不含明文", async () => {
const s = h.ctx.secrets;
const a = await s.create({ purpose: "site_api_key", plaintext: "alpha-plain" });
const b = await s.create({ purpose: "site_api_key", plaintext: "bravo-plain" });
const other = createSecretStore({ db: h.db, masterKey: `${MASTER_KEY}-other` });
await expect(other.resolve(a)).rejects.toThrow(/解密失败/);
await sql`update secret set ciphertext = (select ciphertext from secret where id = ${a}),
nonce = (select nonce from secret where id = ${a}), auth_tag = (select auth_tag from secret where id = ${a}),
kdf_params = (select kdf_params from secret where id = ${a}) where id = ${b}`.execute(h.db);
const err = await s.resolve(b).catch((e: Error) => e);
expect(err).toBeInstanceOf(SecretError);
expect(String((err as Error).message)).not.toMatch(/alpha|bravo/);
await sql`update secret set purpose = 'other' where id = ${a}`.execute(h.db);
await expect(s.resolve(a)).rejects.toThrow(/解密失败/);
await expect(s.resolve("not-a-uuid")).rejects.toThrow(/不存在/);
});
it("resolve 出的明文经 logger 记录时也不会以敏感字段名泄漏;SecretError 进日志只有 ref", async () => {
const ref = await h.ctx.secrets.create({ purpose: "downloader_password", plaintext: "qb-pass-777" });
const password = await h.ctx.secrets.resolve(ref);
h.ctx.logger.info({ password, secretRef: ref }, "连接下载器");
const err = await h.ctx.secrets.resolve("00000000-0000-4000-8000-000000000000").catch((e: Error) => e);
h.ctx.logger.error({ err }, "取凭证失败");
const out = h.lines.join("\n");
expect(out).not.toContain("qb-pass-777");
expect(out).toContain("[REDACTED]");
});
});
@@ -0,0 +1,147 @@
import { type ChildProcess, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createApp } from "../../src/http/app.ts";
import { PgEventBus } from "../../src/http/sse-pg.ts";
import { DEV_KEY } from "../helpers.ts";
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
// SSE 多进程:两个 Deno 子进程(订阅者 / 发布者)通过 sse_event + LISTEN/NOTIFY 互通;
// 再验证 API 进程的 GET /events 能推送另一个进程发布的事件,以及 ?after= 补读与裁剪后的 resync。
const hasPg = await pgAvailable();
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
function proc(args: string[]) {
const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], {
cwd: SERVER_DIR,
env: { ...process.env, DATABASE_URL: PG_URL },
stdio: ["ignore", "pipe", "pipe"],
});
const lines: string[] = [];
let buf = "";
let err = "";
child.stdout!.on("data", (d: Buffer) => {
buf += d.toString();
const parts = buf.split("\n");
buf = parts.pop() ?? "";
lines.push(...parts.filter(Boolean));
});
child.stderr!.on("data", (d: Buffer) => {
err += d.toString();
});
const exited = new Promise<number>((resolve) => child.on("exit", (code) => resolve(code ?? -1)));
return { child, lines, exited, stderr: () => err };
}
async function until(cond: () => boolean, ms: number, what: string) {
const deadline = Date.now() + ms;
while (!cond()) {
if (Date.now() > deadline) throw new Error(`等待超时:${what}`);
await new Promise((r) => setTimeout(r, 50));
}
}
describe.skipIf(!hasPg)("PgEventBus 多进程(真实 PG)", () => {
let h: Harness;
let bus: PgEventBus;
const channel = `umt_events_t${process.pid}${Math.random().toString(36).slice(2, 6)}`;
const children: ChildProcess[] = [];
beforeAll(async () => {
h = await createHarness();
bus = await PgEventBus.start({
db: h.db,
connectionString: PG_URL,
logger: h.ctx.logger,
channel,
retain: 30,
pruneEvery: 1000,
});
}, 60_000);
afterAll(async () => {
for (const c of children) c.kill();
await bus?.stop();
await h?.close();
});
it("子进程 A 订阅、子进程 B 发布:A 按 seq 顺序收齐;本进程也能补读", async () => {
const sub = proc(["sub", h.schema, channel, "20"]);
children.push(sub.child);
await until(() => sub.lines.includes("READY"), 30_000, "订阅进程就绪");
const seenHere: number[] = [];
const off = bus.subscribe((e) => seenHere.push(e.seq));
const pub = proc(["pub", h.schema, channel, "20"]);
children.push(pub.child);
expect(await pub.exited).toBe(0);
expect(await sub.exited).toBe(0);
const events = sub.lines.filter((l) => l.startsWith("{")).map((l) => JSON.parse(l));
expect(events).toHaveLength(20);
expect(events.map((e) => e.payload.i)).toEqual([...Array(20).keys()]);
const seqs = events.map((e) => e.seq as number);
expect([...seqs].sort((a, b) => a - b)).toEqual(seqs);
expect(new Set(events.map((e) => e.payload.pid)).size).toBe(1);
await until(() => seenHere.length >= 20, 5_000, "本进程收到");
off();
const since = await bus.since(seqs[9]!);
expect(since.events.map((e) => e.seq)).toEqual(seqs.slice(10));
expect(since.gap).toBe(false);
expect(since.latestSeq).toBe(seqs[19]);
}, 60_000);
it("API 进程的 GET /events 推送其它进程发布的事件;裁剪后旧游标收到 resync", async () => {
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger: h.ctx.logger,
bus,
role: "api",
version: "t",
});
const { latestSeq } = await bus.since(0);
const ac = new AbortController();
const res = await app.request(`/api/v1/events?after=${latestSeq}`, {
headers: { authorization: `Bearer ${DEV_KEY}` },
signal: ac.signal,
});
const reader = res.body!.getReader();
let text = "";
const reading = (async () => {
for (;;) {
const { value, done } = await reader.read();
if (done) return;
text += new TextDecoder().decode(value);
}
})();
const pub = proc(["pub", h.schema, channel, "3"]);
children.push(pub.child);
expect(await pub.exited).toBe(0);
await until(() => (text.match(/event: test\.ping/g) ?? []).length >= 3, 10_000, "SSE 推送");
ac.abort();
await reader.cancel().catch(() => {});
await reading.catch(() => {});
// 裁剪到只剩 30 条后,after=1 已不可补读 → resync
const more = proc(["pub", h.schema, channel, "40"]);
children.push(more.child);
expect(await more.exited).toBe(0);
await bus.prune();
const s = await bus.since(1);
expect(s.gap).toBe(true);
expect(s.events.length).toBe(30);
const ac2 = new AbortController();
const res2 = await app.request("/api/v1/events?after=1", {
headers: { authorization: `Bearer ${DEV_KEY}` },
signal: ac2.signal,
});
const r2 = res2.body!.getReader();
const first = new TextDecoder().decode((await r2.read()).value);
expect(first).toContain("event: system.resync");
ac2.abort();
await r2.cancel().catch(() => {});
}, 60_000);
it("发布失败不抛(返回 null)", async () => {
// BigInt 无法 JSON 序列化 → 发布内部出错
await expect(bus.publish("x", { n: 1n })).resolves.toBeNull();
});
});
+10 -7
View File
@@ -10,6 +10,7 @@ import { type Bootstrapped, bootstrap } from "../src/context.ts";
import { EventBus } from "../src/http/sse.ts";
import type { ServerModule } from "../src/modules/types.ts";
import { type RunningWorker, startWorker } from "../src/worker/index.ts";
import { withSchemaLock } from "./auth-settings/harness.ts";
import { memoryLogger } from "./helpers.ts";
// 真实 PG:独立业务 schema + 独立引擎 schema,验证 bootstrap → worker → outbox → 模块任务 的整条装配。
@@ -71,7 +72,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
pool: new pg.Pool({ connectionString: url, max: 5, options: `-c search_path=${biz},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: biz });
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: biz }));
await db
.insertInto("settings")
.values({ key: "cache", value: JSON.stringify({ hardThresholdPercent: 95, resumePercent: 90 }) })
@@ -98,18 +99,20 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
await worker?.stop();
await booted?.engine.stop();
await db?.destroy();
const admin = new pg.Client({ connectionString: url });
await admin.connect();
await admin.query(`drop schema if exists ${biz} cascade`);
await admin.query(`drop schema if exists ${ow} cascade`);
await admin.end();
await withSchemaLock(async () => {
const admin = new pg.Client({ connectionString: url });
await admin.connect();
await admin.query(`drop schema if exists ${biz} cascade`);
await admin.query(`drop schema if exists ${ow} cascade`);
await admin.end();
});
}, 60_000);
it("settings 读取合并缺省值", async () => {
const cache = await booted.ctx.settings.get("cache");
expect(cache).toMatchObject({ hardThresholdPercent: 95, resumePercent: 90, checkIntervalSec: 60 });
expect((await booted.ctx.settings.all()).automation.enabled).toBe(true);
await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/W2-A/);
await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/不存在/);
});
it("业务事务内 emit → 中继路由 → worker 执行模块任务;同幂等键只投一次;非法 payload 拒写", async () => {
+43
View File
@@ -0,0 +1,43 @@
// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。
// 用法:deno run -A test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
import type { DB } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { PgEventBus } from "../../src/http/sse-pg.ts";
import { createLogger } from "../../src/logger.ts";
const [mode, schema, channel, nRaw] = Deno.args;
const n = Number(nRaw);
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
}),
});
const bus = await PgEventBus.start({
db,
connectionString: url,
logger: createLogger({ level: "warn" }),
channel: channel!,
});
if (mode === "sub") {
let got = 0;
const done = new Promise<void>((resolve) => {
bus.subscribe((e) => {
console.log(JSON.stringify(e));
if (++got >= n) resolve();
});
});
console.log("READY");
const timeout = setTimeout(() => {
console.error("timeout");
Deno.exit(3);
}, 20_000);
await done;
clearTimeout(timeout);
} else {
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid });
}
await bus.stop();
await db.destroy();
+6 -6
View File
@@ -5,11 +5,11 @@ import { DEV_KEY, testApp } from "./helpers.ts";
describe("EventBus", () => {
it("序号自增并按 after 补读,淘汰后标记 gap", () => {
const bus = new EventBus(3);
for (let i = 0; i < 5; i++) bus.publish("t", { i });
for (let i = 0; i < 5; i++) bus.publishSync("t", { i });
expect(bus.latestSeq).toBe(5);
expect(bus.since(3).events.map((e) => e.seq)).toEqual([4, 5]);
expect(bus.since(3).gap).toBe(false);
expect(bus.since(1).gap).toBe(true);
expect(bus.sinceSync(3).events.map((e) => e.seq)).toEqual([4, 5]);
expect(bus.sinceSync(3).gap).toBe(false);
expect(bus.sinceSync(1).gap).toBe(true);
});
});
@@ -21,8 +21,8 @@ describe("GET /api/v1/events", () => {
it("补读 after 之后的事件", async () => {
const { app, bus } = testApp();
bus.publish("a", 1);
bus.publish("b", 2);
bus.publishSync("a", 1);
bus.publishSync("b", 2);
const ac = new AbortController();
const res = await app.request("/api/v1/events?after=1", {
headers: { Authorization: `Bearer ${DEV_KEY}` },