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

+71
View File
@@ -0,0 +1,71 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl } from "../src/migration-helpers.ts";
// 管理员登录门禁(任务书 §3.1-14;docs/design/auth-reference.md):
// admin_user 单行(singleton 唯一)、admin_session(只存 sha256(token),软删除吊销)、
// login_attempt(IP + 账号双维度限速落表,多进程/重启一致);另补 audit_event 的 actor/action 查询索引。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"admin_user",
`
singleton boolean not null default true check (singleton),
username text,
password_hash text,
setup_token_hash text,
password_changed_at timestamptz,
totp_secret_ref uuid references secret (id) on delete set null,
totp_pending_secret_ref uuid references secret (id) on delete set null,
totp_enabled_at timestamptz,
totp_last_counter bigint,
constraint admin_user_singleton_key unique (singleton),
constraint admin_user_initialized_check check ((username is null) = (password_hash is null))`,
)}
${entityTable(
"admin_session",
`
admin_user_id uuid not null references admin_user (id) on delete cascade,
session_hash text not null,
ip text,
user_agent text,
last_seen_at timestamptz not null default now(),
expires_at timestamptz not null,
revoked_at timestamptz,
revoke_reason text,
constraint admin_session_hash_key unique (session_hash)`,
)}
create index admin_session_active_idx on admin_session (admin_user_id) where revoked_at is null;
create table login_attempt (
id bigserial primary key,
at timestamptz not null default now(),
ip text not null,
username text not null,
success boolean not null,
reason text
);
create index login_attempt_ip_idx on login_attempt (ip, at) where not success;
create index login_attempt_username_idx on login_attempt (username, at) where not success;
create index audit_event_actor_idx on audit_event (actor_type, actor_id, at);
create index audit_event_action_idx on audit_event (action, at);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
drop index if exists audit_event_action_idx;
drop index if exists audit_event_actor_idx;
${dropTables(["login_attempt", "admin_session", "admin_user"])}
`,
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Kysely } from "kysely";
import { dropTables, execDdl } from "../src/migration-helpers.ts";
// SSE 多进程总线(W2-A):事件先落 sse_event(seq 全局单调,发布时持咨询锁保证提交顺序 = seq 顺序),
// 再 pg_notify('umt_events', seq);各进程 LISTEN 后按 seq 补读并推给本进程的 SSE 连接。
// 只保留最近 N 条供 ?after= 补读,由发布方定期裁剪。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create table sse_event (
seq bigserial primary key,
type text not null,
at timestamptz not null default now(),
payload jsonb not null default 'null'::jsonb
);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["sse_event"]));
}
+61
View File
@@ -234,6 +234,35 @@ export interface AcquisitionSlotTable {
reserved_plan_id: string | null;
}
export interface AdminSessionTable {
id: Generated<string>;
admin_user_id: string;
session_hash: string;
ip: string | null;
user_agent: string | null;
last_seen_at: GeneratedTimestamptz;
expires_at: Timestamptz;
revoked_at: Timestamptz | null;
revoke_reason: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface AdminUserTable {
id: Generated<string>;
singleton: Generated<boolean>;
username: string | null;
password_hash: string | null;
setup_token_hash: string | null;
password_changed_at: Timestamptz | null;
totp_secret_ref: string | null;
totp_pending_secret_ref: string | null;
totp_enabled_at: Timestamptz | null;
totp_last_counter: number | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface AgentRunTable {
id: Generated<string>;
task_id: string | null;
@@ -576,6 +605,15 @@ export interface LibraryRootTable {
updated_at: GeneratedTimestamptz;
}
export interface LoginAttemptTable {
id: Generated<number>;
at: GeneratedTimestamptz;
ip: string;
username: string;
success: boolean;
reason: string | null;
}
export interface MediaAssetTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -923,6 +961,13 @@ export interface SourceReleaseTable {
updated_at: GeneratedTimestamptz;
}
export interface SseEventTable {
seq: Generated<number>;
type: string;
at: GeneratedTimestamptz;
payload: Generated<Json>;
}
export interface SubscriptionTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -1019,6 +1064,8 @@ export interface TitleAliasTable {
export interface DB {
acquisition_plan: AcquisitionPlanTable;
acquisition_slot: AcquisitionSlotTable;
admin_session: AdminSessionTable;
admin_user: AdminUserTable;
agent_run: AgentRunTable;
agent_run_event: AgentRunEventTable;
api_key: ApiKeyTable;
@@ -1037,6 +1084,7 @@ export interface DB {
feed_source: FeedSourceTable;
identity_revision: IdentityRevisionTable;
library_root: LibraryRootTable;
login_attempt: LoginAttemptTable;
media_asset: MediaAssetTable;
media_entity: MediaEntityTable;
media_server_instance: MediaServerInstanceTable;
@@ -1056,6 +1104,7 @@ export interface DB {
site: SiteTable;
site_account: SiteAccountTable;
source_release: SourceReleaseTable;
sse_event: SseEventTable;
subscription: SubscriptionTable;
task: TaskTable;
tasks_fact: TasksFactTable;
@@ -1070,6 +1119,12 @@ export type AcquisitionPlanUpdate = Updateable<AcquisitionPlanTable>;
export type AcquisitionSlotRow = Selectable<AcquisitionSlotTable>;
export type NewAcquisitionSlot = Insertable<AcquisitionSlotTable>;
export type AcquisitionSlotUpdate = Updateable<AcquisitionSlotTable>;
export type AdminSessionRow = Selectable<AdminSessionTable>;
export type NewAdminSession = Insertable<AdminSessionTable>;
export type AdminSessionUpdate = Updateable<AdminSessionTable>;
export type AdminUserRow = Selectable<AdminUserTable>;
export type NewAdminUser = Insertable<AdminUserTable>;
export type AdminUserUpdate = Updateable<AdminUserTable>;
export type AgentRunRow = Selectable<AgentRunTable>;
export type NewAgentRun = Insertable<AgentRunTable>;
export type AgentRunUpdate = Updateable<AgentRunTable>;
@@ -1124,6 +1179,9 @@ export type IdentityRevisionUpdate = Updateable<IdentityRevisionTable>;
export type LibraryRootRow = Selectable<LibraryRootTable>;
export type NewLibraryRoot = Insertable<LibraryRootTable>;
export type LibraryRootUpdate = Updateable<LibraryRootTable>;
export type LoginAttemptRow = Selectable<LoginAttemptTable>;
export type NewLoginAttempt = Insertable<LoginAttemptTable>;
export type LoginAttemptUpdate = Updateable<LoginAttemptTable>;
export type MediaAssetRow = Selectable<MediaAssetTable>;
export type NewMediaAsset = Insertable<MediaAssetTable>;
export type MediaAssetUpdate = Updateable<MediaAssetTable>;
@@ -1181,6 +1239,9 @@ export type SiteAccountUpdate = Updateable<SiteAccountTable>;
export type SourceReleaseRow = Selectable<SourceReleaseTable>;
export type NewSourceRelease = Insertable<SourceReleaseTable>;
export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
export type SseEventRow = Selectable<SseEventTable>;
export type NewSseEvent = Insertable<SseEventTable>;
export type SseEventUpdate = Updateable<SseEventTable>;
export type SubscriptionRow = Selectable<SubscriptionTable>;
export type NewSubscription = Insertable<SubscriptionTable>;
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
+7
View File
@@ -68,6 +68,11 @@ const EXPECTED_TABLES = [
"tasks_fact",
"tasks_op_ledger",
"agent_run_event",
// 登录门禁(0015)与 SSE 多进程总线(0016)
"admin_user",
"admin_session",
"login_attempt",
"sse_event",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
@@ -79,6 +84,8 @@ const NON_ENTITY_TABLES = [
"tasks_fact",
"tasks_op_ledger",
"agent_run_event",
"login_attempt",
"sse_event",
];
describe.skipIf(skip)("schema migrations (real PG)", () => {