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:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { z } from "zod";
|
||||
import { ApiScope, Timestamp } from "./common.ts";
|
||||
|
||||
// 管理员登录门禁(任务书 §3.1-14,做法见 docs/design/auth-reference.md):
|
||||
// 单管理员账号密码 + 服务端会话(cookie umt_session)+ HMAC 派生 CSRF 双提交 + 可选 TOTP;
|
||||
// 外部程序走 API Key(Bearer)。两种调用者在服务端统一为 Actor。
|
||||
|
||||
/** 会话 cookie 名(HttpOnly,SameSite=Lax) */
|
||||
export const SESSION_COOKIE = "umt_session";
|
||||
/** CSRF cookie 名(非 HttpOnly,前端读出后放进 CSRF_HEADER) */
|
||||
export const CSRF_COOKIE = "umt_csrf";
|
||||
/** 写方法(POST/PUT/PATCH/DELETE)用 cookie 会话调用时必须带的请求头 */
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** 统一调用者:控制台会话(admin)或外部 API Key */
|
||||
export const ActorKind = z.enum(["admin", "api_key"]);
|
||||
export type ActorKind = z.infer<typeof ActorKind>;
|
||||
export const Actor = z.object({
|
||||
type: ActorKind,
|
||||
/** admin:admin_user.id(开发模式 DEV_ADMIN_KEY 为 "dev-admin");api_key:api_key.id */
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
scopes: z.array(ApiScope),
|
||||
});
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
const Username = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9_.@-]+$/, "用户名只能包含字母、数字与 _ . @ -");
|
||||
/** 密码:长度 10–256(argon2id 存储,不限制字符集) */
|
||||
export const Password = z.string().min(10, "密码至少 10 个字符").max(256);
|
||||
|
||||
/** POST /auth/setup:首次初始化管理员(setup token 在首次启动时打印到服务端日志一次) */
|
||||
export const AdminSetupRequest = z.object({
|
||||
setupToken: z.string().min(16).max(256),
|
||||
username: Username,
|
||||
password: Password,
|
||||
});
|
||||
export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
|
||||
|
||||
/** 6 位 TOTP 码 */
|
||||
export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字");
|
||||
|
||||
/** POST /auth/login */
|
||||
export const LoginRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
/** 开启 TOTP 后第二步提交 */
|
||||
totpCode: TotpCode.optional(),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||||
|
||||
/** GET /auth/session(免鉴权,SPA 探测登录态与是否需要初始化) */
|
||||
export const SessionInfo = z.object({
|
||||
initialized: z.boolean(),
|
||||
authenticated: z.boolean(),
|
||||
actor: Actor.nullable(),
|
||||
/** 仅 cookie 会话有值 */
|
||||
expiresAt: Timestamp.nullable(),
|
||||
totpEnabled: z.boolean(),
|
||||
});
|
||||
export type SessionInfo = z.infer<typeof SessionInfo>;
|
||||
|
||||
/**
|
||||
* POST /auth/login 结果:ok=登录成功(cookie 已下发);totp_required=密码正确但需第二步验证码
|
||||
* (不计入失败次数,前端保留用户名/密码并追加验证码重新提交)
|
||||
*/
|
||||
export const LoginResult = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("ok"), session: SessionInfo }),
|
||||
z.object({ status: z.literal("totp_required") }),
|
||||
]);
|
||||
export type LoginResult = z.infer<typeof LoginResult>;
|
||||
|
||||
/** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */
|
||||
export const PasswordChangeRequest = z.object({
|
||||
currentPassword: z.string().min(1).max(256),
|
||||
newPassword: Password,
|
||||
});
|
||||
export type PasswordChangeRequest = z.infer<typeof PasswordChangeRequest>;
|
||||
export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() });
|
||||
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
|
||||
|
||||
/** GET /auth/totp */
|
||||
export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() });
|
||||
export type TotpStatus = z.infer<typeof TotpStatus>;
|
||||
/**
|
||||
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认;
|
||||
* disable 需要当前密码 + 验证码
|
||||
*/
|
||||
export const TotpAction = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("begin") }),
|
||||
z.object({ action: z.literal("enable"), code: TotpCode }),
|
||||
z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: TotpCode }),
|
||||
]);
|
||||
export type TotpAction = z.infer<typeof TotpAction>;
|
||||
export const TotpActionResult = z.object({
|
||||
status: TotpStatus,
|
||||
/** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */
|
||||
otpauthUri: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
});
|
||||
export type TotpActionResult = z.infer<typeof TotpActionResult>;
|
||||
|
||||
/**
|
||||
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
|
||||
* 新增动作只在这里加。
|
||||
*/
|
||||
export const AuditAction = z.enum([
|
||||
"admin.setup",
|
||||
"admin.login_success",
|
||||
"admin.logout",
|
||||
"admin.password_change",
|
||||
"admin.totp_enable",
|
||||
"admin.totp_disable",
|
||||
"api_key.create",
|
||||
"api_key.update",
|
||||
"api_key.revoke",
|
||||
"settings.update",
|
||||
"secret.create",
|
||||
"secret.rotate",
|
||||
"secret.delete",
|
||||
"library_root.create",
|
||||
"library_root.update",
|
||||
"library_root.delete",
|
||||
"library_root.test",
|
||||
"downloader_instance.create",
|
||||
"downloader_instance.update",
|
||||
"downloader_instance.delete",
|
||||
"downloader_instance.test",
|
||||
"media_server_instance.create",
|
||||
"media_server_instance.update",
|
||||
"media_server_instance.delete",
|
||||
"media_server_instance.test",
|
||||
"path_mapping.create",
|
||||
"path_mapping.update",
|
||||
"path_mapping.delete",
|
||||
"path_mapping.verify",
|
||||
"site_account.credentials_set",
|
||||
"engine.run_cancel",
|
||||
]);
|
||||
export type AuditAction = z.infer<typeof AuditAction>;
|
||||
@@ -2,6 +2,7 @@ export * from "./agent.ts";
|
||||
export * from "./agent-events.ts";
|
||||
export * from "./api.ts";
|
||||
export * from "./assets.ts";
|
||||
export * from "./auth.ts";
|
||||
export * from "./capabilities-io.ts";
|
||||
export * from "./capability.ts";
|
||||
export * from "./common.ts";
|
||||
|
||||
@@ -159,3 +159,37 @@ export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOw
|
||||
message: "viewKind 与实例 ID 不匹配",
|
||||
});
|
||||
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
|
||||
|
||||
/** POST /{downloader,media-server}-instances/{id}/test 与 /library-roots/{id}/test 结果 */
|
||||
export const InstanceTestResult = z.object({
|
||||
ok: z.boolean(),
|
||||
version: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
healthStatus: InstanceHealth,
|
||||
});
|
||||
export type InstanceTestResult = z.infer<typeof InstanceTestResult>;
|
||||
|
||||
/**
|
||||
* PUT /sites/{id}/credentials:站点账号凭证录入(cookie / passkey / apiKey / 密码)。
|
||||
* 按 (siteId, label) 建立或更新 site_account;明文加密为 secret 后丢弃,响应不回显。
|
||||
*/
|
||||
export const SiteCredentialsPut = z.object({
|
||||
label: z.string().min(1).max(80).default("default"),
|
||||
authKind: z.enum(["cookie", "passkey", "api_key", "password"]),
|
||||
/** 只写明文 */
|
||||
credential: Credential,
|
||||
/** authKind=password 时的登录名(存入 user_info.username) */
|
||||
username: z.string().max(200).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type SiteCredentialsPut = z.infer<typeof SiteCredentialsPut>;
|
||||
export const SiteCredentialsResult = z.object({
|
||||
siteAccountId: Id,
|
||||
siteId: Id,
|
||||
label: z.string(),
|
||||
authKind: z.string(),
|
||||
secretRef: Id,
|
||||
/** true:新建账号;false:替换已有凭证 */
|
||||
created: z.boolean(),
|
||||
});
|
||||
export type SiteCredentialsResult = z.infer<typeof SiteCredentialsResult>;
|
||||
@@ -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() });
|
||||
@@ -82,7 +82,16 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
};
|
||||
|
||||
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
|
||||
const INTERNAL_TABLES = ["TasksRunMeta", "TasksFact", "TasksOpLedger"];
|
||||
const INTERNAL_TABLES = [
|
||||
"TasksRunMeta",
|
||||
"TasksFact",
|
||||
"TasksOpLedger",
|
||||
// W2-A 登录门禁与 SSE 总线的服务端内部表:经 contracts/auth.ts 的专用 DTO 暴露,不做 Row 对照
|
||||
"AdminUser",
|
||||
"AdminSession",
|
||||
"LoginAttempt",
|
||||
"SseEvent",
|
||||
];
|
||||
const INTERNAL_ENUMS = ["TasksOpLedgerStatus"];
|
||||
|
||||
/** DB 枚举类型名 → contracts 枚举 */
|
||||
|
||||
@@ -32,6 +32,7 @@ const STRING_CANDIDATES = [
|
||||
"CatalogLookupInput",
|
||||
"catalog.lookup",
|
||||
"测试 Test",
|
||||
"123456",
|
||||
];
|
||||
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
||||
|
||||
@@ -92,6 +93,7 @@ const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, u
|
||||
AssetLocationCreate: (v) => v,
|
||||
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
|
||||
GlobalSettings: () => C.DEFAULT_SETTINGS,
|
||||
EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }),
|
||||
TypedEventEnvelope: () => ({
|
||||
seq: 1,
|
||||
at: "2026-09-27T08:00:00Z",
|
||||
|
||||
@@ -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"])}
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -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"]));
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in new issue
Block a user