From 3ec57ce19910aefcf04531912c11fc0d20887a5a Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:03:28 +0000 Subject: [PATCH] feat: add server config validation --- plugin/core/config.ts | 104 ++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 3 ++ 2 files changed, 107 insertions(+) create mode 100644 plugin/core/config.ts diff --git a/plugin/core/config.ts b/plugin/core/config.ts new file mode 100644 index 0000000..7f352ca --- /dev/null +++ b/plugin/core/config.ts @@ -0,0 +1,104 @@ +export interface YonexusServerConfig { + followerIdentifiers: string[]; + notifyBotToken: string; + adminUserId: string; + 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 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 as Record | null; + 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) || followerIdentifiers.length === 0) { + issues.push("followerIdentifiers must contain at least one non-empty identifier"); + } + + if (new Set(followerIdentifiers).size !== followerIdentifiers.length) { + issues.push("followerIdentifiers must not contain duplicates"); + } + + const notifyBotToken = source?.notifyBotToken; + if (!isNonEmptyString(notifyBotToken)) { + issues.push("notifyBotToken is required"); + } + + const adminUserId = source?.adminUserId; + if (!isNonEmptyString(adminUserId)) { + issues.push("adminUserId is required"); + } + + const listenPort = source?.listenPort; + if (!isValidPort(listenPort)) { + 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); + } + + return { + followerIdentifiers, + notifyBotToken: notifyBotToken.trim(), + adminUserId: adminUserId.trim(), + listenHost, + listenPort, + publicWsUrl + }; +} diff --git a/plugin/index.ts b/plugin/index.ts index 4da1049..476eca3 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,3 +1,6 @@ +export { validateYonexusServerConfig, YonexusServerConfigError } from "./core/config.js"; +export type { YonexusServerConfig } from "./core/config.js"; + export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server"; readonly version: string;