Files
UHD_Media_Tool/packages/contracts/src/instances.ts
T
rootandClaude Opus 5.5 ff230e5da7 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]>
2026-09-27 19:59:54 +08:00

196 lines
7.3 KiB
TypeScript

import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
export type InstanceHealth = z.infer<typeof InstanceHealth>;
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
export const ManagementMode = z.enum(["register_only", "managed"]);
export type ManagementMode = z.infer<typeof ManagementMode>;
export const LibraryRoot = EntityBase.extend({
name: z.string().min(1),
mediaKind: LibraryMediaKind,
/** 执行进程可访问的绝对路径 */
localPath: z.string().startsWith("/"),
managementMode: ManagementMode,
enabled: z.boolean(),
sentinelFile: z.string().nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
notes: z.string().nullable(),
});
export type LibraryRoot = z.infer<typeof LibraryRoot>;
export const LibraryRootCreate = LibraryRoot.pick({
name: true,
mediaKind: true,
localPath: true,
managementMode: true,
enabled: true,
sentinelFile: true,
notes: true,
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
export const LibraryRootUpdate = LibraryRootCreate.partial();
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
/** 只写凭证:明文仅在请求中出现,绝不回显 */
const Credential = z.string().min(1).max(4096);
export const DownloaderKind = z.enum(["qbittorrent"]);
export type DownloaderKind = z.infer<typeof DownloaderKind>;
export const DownloaderInstance = EntityBase.extend({
name: z.string().min(1),
kind: DownloaderKind,
baseUrl: z.url(),
username: z.string().nullable(),
secretRef: Id.nullable(),
/** 管理标签名(REQ §8.4) */
managedTag: z.string().min(1),
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
cacheVolumeKey: z.string().nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
export const DownloaderInstanceCreate = DownloaderInstance.pick({
name: true,
kind: true,
baseUrl: true,
username: true,
managedTag: true,
cacheVolumeKey: true,
enabled: true,
settings: true,
})
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
export type MediaServerKind = z.infer<typeof MediaServerKind>;
export const MediaServerInstance = EntityBase.extend({
name: z.string().min(1),
kind: MediaServerKind,
baseUrl: z.url(),
userId: z.string().nullable(),
secretRef: Id.nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
export const MediaServerInstanceCreate = MediaServerInstance.pick({
name: true,
kind: true,
baseUrl: true,
userId: true,
enabled: true,
settings: true,
})
.partial({ kind: true, userId: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
export const PathViewKind = z.enum(["downloader", "media_server"]);
export type PathViewKind = z.infer<typeof PathViewKind>;
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
export const PathMapping = EntityBase.extend({
viewKind: PathViewKind,
downloaderInstanceId: Id.nullable(),
mediaServerInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
remotePrefix: z.string().min(1),
localPrefix: z.string().startsWith("/"),
priority: z.number().int(),
enabled: z.boolean(),
verifyStatus: PathMappingVerifyStatus,
verifiedAt: Timestamp.nullable(),
verifyDetail: JsonObject,
});
export type PathMapping = z.infer<typeof PathMapping>;
const pathMappingOwnerOk = (v: {
viewKind?: PathViewKind;
downloaderInstanceId?: string | null;
mediaServerInstanceId?: string | null;
}) =>
v.viewKind === undefined ||
(v.viewKind === "downloader"
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
const PathMappingInput = PathMapping.omit({
...SERVER_MANAGED,
verifyStatus: true,
verifiedAt: true,
verifyDetail: true,
});
export const PathMappingCreate = PathMappingInput.partial({
downloaderInstanceId: true,
mediaServerInstanceId: true,
libraryRootId: true,
priority: true,
enabled: true,
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
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>;