- 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]>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
|
|
|
|
// 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2)
|
|
|
|
export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]);
|
|
export type ReconcileKind = z.infer<typeof ReconcileKind>;
|
|
/** read_only:恢复后默认只读对账模式(REQ §8.5) */
|
|
export const ReconcileMode = z.enum(["normal", "read_only"]);
|
|
export type ReconcileMode = z.infer<typeof ReconcileMode>;
|
|
export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
|
|
export type ReconcileStatus = z.infer<typeof ReconcileStatus>;
|
|
|
|
export const ReconcileRun = EntityBase.extend({
|
|
kind: ReconcileKind,
|
|
mode: ReconcileMode,
|
|
status: ReconcileStatus,
|
|
scope: JsonObject,
|
|
/** 中断续扫位置 */
|
|
resumeCursor: JsonObject,
|
|
stats: JsonObject,
|
|
taskId: Id.nullable(),
|
|
error: JsonObject.nullable(),
|
|
startedAt: Timestamp.nullable(),
|
|
finishedAt: Timestamp.nullable(),
|
|
});
|
|
export type ReconcileRun = z.infer<typeof ReconcileRun>;
|
|
export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({
|
|
mode: true,
|
|
scope: true,
|
|
});
|
|
export type ReconcileRunCreate = z.infer<typeof ReconcileRunCreate>;
|
|
export const ReconcileRunUpdate = ReconcileRun.pick({
|
|
status: true,
|
|
resumeCursor: true,
|
|
stats: true,
|
|
taskId: true,
|
|
error: true,
|
|
startedAt: true,
|
|
finishedAt: true,
|
|
}).partial();
|
|
export type ReconcileRunUpdate = z.infer<typeof ReconcileRunUpdate>;
|
|
|
|
export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]);
|
|
export type ServerItemType = z.infer<typeof ServerItemType>;
|
|
/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */
|
|
export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]);
|
|
export type ServerMatchStatus = z.infer<typeof ServerMatchStatus>;
|
|
|
|
/** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */
|
|
export const ServerMapping = EntityBase.extend({
|
|
mediaServerInstanceId: Id,
|
|
serverItemId: z.string().min(1),
|
|
itemType: ServerItemType,
|
|
mediaEntityId: Id.nullable(),
|
|
contentUnitId: Id.nullable(),
|
|
mediaAssetId: Id.nullable(),
|
|
serverPath: z.string().nullable(),
|
|
pathMappingId: Id.nullable(),
|
|
matchStatus: ServerMatchStatus,
|
|
versionInfo: JsonObject,
|
|
evidence: JsonObject,
|
|
observedAt: Timestamp,
|
|
});
|
|
export type ServerMapping = z.infer<typeof ServerMapping>;
|
|
export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({
|
|
mediaEntityId: true,
|
|
contentUnitId: true,
|
|
mediaAssetId: true,
|
|
serverPath: true,
|
|
pathMappingId: true,
|
|
matchStatus: true,
|
|
versionInfo: true,
|
|
evidence: true,
|
|
observedAt: true,
|
|
});
|
|
export type ServerMappingCreate = z.infer<typeof ServerMappingCreate>;
|
|
export const ServerMappingUpdate = ServerMappingCreate.omit({
|
|
mediaServerInstanceId: true,
|
|
serverItemId: true,
|
|
}).partial();
|
|
export type ServerMappingUpdate = z.infer<typeof ServerMappingUpdate>;
|
|
|
|
/** API Key(不含 key_hash) */
|
|
export const ApiKey = EntityBase.extend({
|
|
name: z.string().min(1),
|
|
scopes: z.array(ApiScope),
|
|
revokedAt: Timestamp.nullable(),
|
|
lastUsedAt: Timestamp.nullable(),
|
|
});
|
|
export type ApiKey = z.infer<typeof ApiKey>;
|
|
export const ApiKeyCreate = z.object({
|
|
name: z.string().min(1).max(80),
|
|
scopes: z.array(ApiScope).min(1),
|
|
});
|
|
export type ApiKeyCreate = z.infer<typeof ApiKeyCreate>;
|
|
/** 创建响应:明文 key 只在此返回一次 */
|
|
export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) });
|
|
export type ApiKeyCreated = z.infer<typeof ApiKeyCreated>;
|
|
export const ApiKeyUpdate = z.object({
|
|
name: z.string().min(1).max(80).optional(),
|
|
scopes: z.array(ApiScope).min(1).optional(),
|
|
revoked: z.literal(true).optional(),
|
|
});
|
|
export type ApiKeyUpdate = z.infer<typeof ApiKeyUpdate>;
|
|
|
|
/** 审计事件(只追加) */
|
|
export const AuditEvent = z.object({
|
|
id: Id,
|
|
at: Timestamp,
|
|
actorType: z.string(),
|
|
actorId: z.string().nullable(),
|
|
action: z.string().min(1),
|
|
targetType: z.string().nullable(),
|
|
targetId: z.string().nullable(),
|
|
payload: JsonObject,
|
|
traceId: z.string().nullable(),
|
|
});
|
|
export type AuditEvent = z.infer<typeof AuditEvent>;
|
|
export const AuditEventCreate = AuditEvent.omit({ id: true, at: true })
|
|
.partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true })
|
|
.extend({ actorType: ActorType });
|
|
export type AuditEventCreate = z.infer<typeof AuditEventCreate>;
|
|
|
|
export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]);
|
|
export type OutboxStatus = z.infer<typeof OutboxStatus>;
|
|
/** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */
|
|
export const OutboxEvent = z.object({
|
|
id: Id,
|
|
at: Timestamp,
|
|
type: z.string().min(1),
|
|
payload: JsonObject,
|
|
publishedAt: Timestamp.nullable(),
|
|
idempotencyKey: z.string().nullable(),
|
|
status: OutboxStatus,
|
|
attempts: NonNegInt,
|
|
nextAttemptAt: Timestamp,
|
|
lastError: JsonObject.nullable(),
|
|
});
|
|
export type OutboxEvent = z.infer<typeof OutboxEvent>;
|
|
export const OutboxEventCreate = OutboxEvent.pick({
|
|
type: true,
|
|
payload: true,
|
|
idempotencyKey: true,
|
|
nextAttemptAt: true,
|
|
}).partial({
|
|
payload: true,
|
|
idempotencyKey: true,
|
|
nextAttemptAt: true,
|
|
});
|
|
export type OutboxEventCreate = z.infer<typeof OutboxEventCreate>;
|
|
export const OutboxEventUpdate = OutboxEvent.pick({
|
|
status: true,
|
|
attempts: true,
|
|
nextAttemptAt: true,
|
|
lastError: true,
|
|
publishedAt: true,
|
|
}).partial();
|
|
export type OutboxEventUpdate = z.infer<typeof OutboxEventUpdate>;
|
|
|
|
export const SecretPurpose = z.enum([
|
|
"site_cookie",
|
|
"site_passkey",
|
|
"site_api_key",
|
|
"site_password",
|
|
"feed_url",
|
|
"downloader_password",
|
|
"media_server_api_key",
|
|
"metadata_api_key",
|
|
"llm_api_key",
|
|
"other",
|
|
]);
|
|
export type SecretPurpose = z.infer<typeof SecretPurpose>;
|
|
|
|
/** 加密凭证的元数据视图:密文/nonce 永不出 API */
|
|
export const Secret = EntityBase.extend({
|
|
name: z.string().min(1),
|
|
purpose: SecretPurpose,
|
|
algorithm: z.string(),
|
|
kdf: z.string(),
|
|
kdfParams: JsonObject,
|
|
keyVersion: z.number().int().min(1),
|
|
rotatedAt: Timestamp.nullable(),
|
|
});
|
|
export type Secret = z.infer<typeof Secret>;
|
|
/** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */
|
|
export const SecretCreate = z.object({
|
|
name: z.string().min(1).max(120),
|
|
purpose: SecretPurpose,
|
|
plaintext: z.string().min(1).max(16384),
|
|
});
|
|
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() });
|