export type NotifyProvider = "discord" | "fabric"; export interface FabricNotifyConfig { centerApiBase: string; apiKey: string; guildNodeId: string; channelId: string; } export interface YonexusServerConfig { followerIdentifiers: string[]; /** Pairing-code delivery provider. Default: "discord" (back-compat). */ notifyProvider: NotifyProvider; /** Required only when notifyProvider === "discord". */ notifyBotToken?: string; /** Required only when notifyProvider === "discord". */ adminUserId?: string; /** Required only when notifyProvider === "fabric". */ fabric?: FabricNotifyConfig; listenHost?: string; listenPort: number; publicWsUrl?: string; } export class YonexusServerConfigError extends Error { readonly issues: string[]; constructor(issues: string[]) { super(`Invalid Yonexus.Server config: ${issues.join("; ")}`); this.name = "YonexusServerConfigError"; this.issues = issues; } } function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function normalizeOptionalString(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function isValidPort(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } function isValidWsUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "ws:" || url.protocol === "wss:"; } catch { return false; } } export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { const source = (raw && typeof raw === "object" ? raw : {}) as Record; const issues: string[] = []; const rawIdentifiers = source.followerIdentifiers; const followerIdentifiers = Array.isArray(rawIdentifiers) ? rawIdentifiers .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) .filter((value) => value.length > 0) : []; if (!Array.isArray(rawIdentifiers)) { issues.push("followerIdentifiers must be an array"); } if (new Set(followerIdentifiers).size !== followerIdentifiers.length) { issues.push("followerIdentifiers must not contain duplicates"); } const rawNotifyProvider = source.notifyProvider; let notifyProvider: NotifyProvider = "discord"; if (rawNotifyProvider === undefined || rawNotifyProvider === null) { notifyProvider = "discord"; } else if (rawNotifyProvider === "discord" || rawNotifyProvider === "fabric") { notifyProvider = rawNotifyProvider; } else { issues.push('notifyProvider must be "discord" or "fabric"'); } // Discord fields — required only for the discord provider. const rawNotifyBotToken = source.notifyBotToken; const rawAdminUserId = source.adminUserId; if (notifyProvider === "discord") { if (!isNonEmptyString(rawNotifyBotToken)) { issues.push('notifyBotToken is required when notifyProvider is "discord"'); } if (!isNonEmptyString(rawAdminUserId)) { issues.push('adminUserId is required when notifyProvider is "discord"'); } } // Fabric fields — required only for the fabric provider. let fabric: FabricNotifyConfig | undefined; if (notifyProvider === "fabric") { const f = (source.fabric && typeof source.fabric === "object" ? source.fabric : {}) as Record; const centerApiBase = normalizeOptionalString(f.centerApiBase); const apiKey = normalizeOptionalString(f.apiKey); const guildNodeId = normalizeOptionalString(f.guildNodeId); const channelId = normalizeOptionalString(f.channelId); if (!centerApiBase) issues.push("fabric.centerApiBase is required when notifyProvider is \"fabric\""); if (!apiKey) issues.push("fabric.apiKey is required when notifyProvider is \"fabric\""); if (!guildNodeId) issues.push("fabric.guildNodeId is required when notifyProvider is \"fabric\""); if (!channelId) issues.push("fabric.channelId is required when notifyProvider is \"fabric\""); if (centerApiBase && apiKey && guildNodeId && channelId) { fabric = { centerApiBase, apiKey, guildNodeId, channelId }; } } const rawListenPort = source.listenPort; if (!isValidPort(rawListenPort)) { issues.push("listenPort must be an integer between 1 and 65535"); } const listenHost = normalizeOptionalString(source.listenHost) ?? "0.0.0.0"; const publicWsUrl = normalizeOptionalString(source.publicWsUrl); if (publicWsUrl !== undefined && !isValidWsUrl(publicWsUrl)) { issues.push("publicWsUrl must be a valid ws:// or wss:// URL when provided"); } if (issues.length > 0) { throw new YonexusServerConfigError(issues); } const listenPort = rawListenPort as number; return { followerIdentifiers, notifyProvider, notifyBotToken: isNonEmptyString(rawNotifyBotToken) ? rawNotifyBotToken.trim() : undefined, adminUserId: isNonEmptyString(rawAdminUserId) ? rawAdminUserId.trim() : undefined, fabric, listenHost, listenPort, publicWsUrl }; }