// Loads the same config.toml + .env the Flask dashboard uses (see config.py). import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseToml } from "smol-toml"; export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); // [section, key, ENV_VAR, type]. Keep in sync with ENV_OVERRIDES in config.py. const ENV_OVERRIDES = [ ["web", "host", "WEB_HOST", "string"], ["web", "port", "WEB_PORT", "int"], ["web", "dashboard_port", "DASHBOARD_PORT", "int"], ["server", "host", "TS_HOST", "string"], ["server", "webquery_port", "TS_WEBQUERY_PORT", "int"], ["server", "server_id", "TS_SERVER_ID", "string"], ["server", "voice_port", "TS_VOICE_PORT", "int"], ["server", "voice_port", "TS_PORT", "int"], // legacy name from the old .env.template ["server", "ts_domain", "TS_DOMAIN", "string"], ["voice", "enabled", "VOICE_ENABLED", "bool"], ["voice", "trust_proxy", "VOICE_TRUST_PROXY", "bool"], ]; function envValue(raw, kind) { const value = raw.trim(); if (kind === "bool") return ["1", "true", "yes", "on"].includes(value.toLowerCase()); if (kind === "int") { if (!/^\d+$/.test(value)) throw new Error("not an integer"); return Number(value); } return value; } function toInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) { const n = Number(value); return Number.isInteger(n) && n >= min && n <= max ? n : fallback; } export function loadConfig(env = process.env) { const envFile = resolve(ROOT_DIR, ".env"); // Like python-dotenv's load_dotenv(): existing variables are not overridden. if (env === process.env && existsSync(envFile)) process.loadEnvFile(envFile); const configFile = env.TS6_CONFIG_FILE || resolve(ROOT_DIR, "config.toml"); let raw = {}; if (existsSync(configFile)) raw = parseToml(readFileSync(configFile, "utf8")); else console.warn(`[config] ${configFile} not found, using defaults`); const section = (name) => ({ ...(raw[name] ?? {}) }); const web = section("web"); const server = section("server"); const voice = section("voice"); const sections = { web, server, voice }; const applied = new Set(); for (const [name, key, envName, kind] of ENV_OVERRIDES) { const value = env[envName]; const id = `${name}.${key}`; if (value === undefined || value.trim() === "" || applied.has(id)) continue; try { sections[name][key] = envValue(value, kind); applied.add(id); } catch { console.warn(`[config] ignoring invalid ${envName}=${JSON.stringify(value)}`); } } const allowedOrigins = Array.isArray(voice.allowed_origins) ? voice.allowed_origins.filter((o) => typeof o === "string" && o).map((o) => o.replace(/\/+$/, "").toLowerCase()) : []; return { web: { host: String(web.host ?? "0.0.0.0"), port: toInt(web.port, 5000, 1, 65535), dashboardPort: toInt(web.dashboard_port, 5001, 1, 65535), }, server: { host: String(server.host ?? "").trim(), voicePort: toInt(server.voice_port, 9987, 1, 65535), // Secret: only ever read from the environment / .env. password: env.TS_SERVER_PASSWORD ?? "", }, voice: { enabled: voice.enabled !== false, maxSessions: toInt(voice.max_sessions, 8, 1, 1000), maxSessionsPerIp: toInt(voice.max_sessions_per_ip, 2, 1, 1000), maxFailedLogins: toInt(voice.max_failed_logins, 5, 1, 1000), lockoutMs: toInt(voice.lockout_minutes, 10, 1, 24 * 60) * 60_000, nicknamePrefix: String(voice.nickname_prefix ?? ""), defaultChannel: String(voice.default_channel ?? ""), identitySecurityLevel: toInt(voice.identity_security_level, 8, 0, 40), trustProxy: voice.trust_proxy === true, allowedOrigins, }, }; }