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

+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");