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

+112
View File
@@ -192,3 +192,115 @@ export const SecretCreate = z.object({
export type SecretCreate = z.infer<typeof SecretCreate>;
export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) });
export type SecretUpdate = z.infer<typeof SecretUpdate>;
/** GET /audit-events 过滤:按 actor / action / target;游标分页(新 → 旧) */
export const AuditEventQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
actorType: z.string().max(40).optional(),
actorId: z.string().max(200).optional(),
/** 精确匹配;以 `.*` 结尾时按前缀匹配(如 `admin.*`) */
action: z.string().max(80).optional(),
targetType: z.string().max(80).optional(),
targetId: z.string().max(200).optional(),
});
export type AuditEventQuery = z.infer<typeof AuditEventQuery>;
// ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ──
/** 引擎 run 状态(含 legacy sleeping/succeeded) */
export const EngineRunStatus = z.enum([
"pending",
"running",
"sleeping",
"succeeded",
"completed",
"failed",
"canceled",
]);
export type EngineRunStatus = z.infer<typeof EngineRunStatus>;
export const EngineStepKind = z.enum(["function", "sleep", "workflow", "signal-send", "signal-wait"]);
export type EngineStepKind = z.infer<typeof EngineStepKind>;
/** 引擎 ID 是 openworkflow 自己生成的文本 ID(非本地 UUID) */
const EngineId = z.string().min(1).max(200);
export const EngineRun = z.object({
id: EngineId,
workflowName: z.string(),
version: z.string().nullable(),
status: z.string(),
attempts: z.number().int(),
workerId: z.string().nullable(),
parentStepAttemptId: z.string().nullable(),
availableAt: Timestamp.nullable(),
deadlineAt: Timestamp.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type EngineRun = z.infer<typeof EngineRun>;
export const EngineRunQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: EngineRunStatus.optional(),
workflowName: z.string().max(200).optional(),
/** created_at 下界(含) */
from: Timestamp.optional(),
/** created_at 上界(不含) */
to: Timestamp.optional(),
});
export type EngineRunQuery = z.infer<typeof EngineRunQuery>;
/** step attempt:不含 config;context 只露 signal-wait 的信号名与超时(排查卡等待用) */
export const EngineStepAttempt = z.object({
id: EngineId,
stepName: z.string(),
kind: z.string(),
status: z.string(),
output: z.unknown(),
error: z.unknown(),
waitSignal: z.string().nullable(),
timeoutAt: z.string().nullable(),
childWorkflowRunId: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
createdAt: Timestamp,
});
export type EngineStepAttempt = z.infer<typeof EngineStepAttempt>;
export const EngineSignal = z.object({
id: EngineId,
signal: z.string(),
data: z.unknown(),
stepAttemptId: z.string(),
createdAt: Timestamp,
});
export type EngineSignal = z.infer<typeof EngineSignal>;
/** run 详情:input/output/error 经敏感字段脱敏;不返回 config/context */
export const EngineRunDetail = EngineRun.extend({
input: z.unknown(),
output: z.unknown(),
error: z.unknown(),
stepAttempts: z.array(EngineStepAttempt),
signals: z.array(EngineSignal),
});
export type EngineRunDetail = z.infer<typeof EngineRunDetail>;
export const EngineStats = z.object({
/** 全表按状态计数(不受列表筛选影响) */
byStatus: z.record(z.string(), z.number().int().nonnegative()),
/** 最老的待处理 run(pending,按 available_at/created_at) */
oldestPending: z
.object({ id: EngineId, workflowName: z.string(), since: Timestamp, ageSec: z.number().nonnegative() })
.nullable(),
/** 当前持有租约的 worker 快照(非权威注册表:引擎没有 worker 表) */
activeWorkers: z.array(z.object({ workerId: z.string(), running: z.number().int(), since: Timestamp.nullable() })),
});
export type EngineStats = z.infer<typeof EngineStats>;
export const EngineCancelRequest = z.object({ reason: z.string().max(500).optional() });
export const EngineCancelResult = z.object({ canceled: z.boolean() });