const path = require("node:path"); class ConfigError extends Error { constructor(message, field) { super(message); this.name = "ConfigError"; this.field = field; } } function requiredString(env, name) { const value = env[name]?.trim(); if (!value) { throw new ConfigError(`${name} is required`, name); } return value; } function optionalString(env, name) { const value = env[name]?.trim(); return value || null; } function integer(env, name, defaultValue, { min, max } = {}) { const raw = env[name]; if (raw === undefined || raw === "") { return defaultValue; } if (!/^-?\d+$/.test(raw)) { throw new ConfigError(`${name} must be an integer`, name); } const value = Number(raw); if (!Number.isSafeInteger(value)) { throw new ConfigError(`${name} must be a safe integer`, name); } if (min !== undefined && value < min) { throw new ConfigError(`${name} must be at least ${min}`, name); } if (max !== undefined && value > max) { throw new ConfigError(`${name} must be at most ${max}`, name); } return value; } function boolean(env, name, defaultValue) { const raw = env[name]; if (raw === undefined || raw === "") { return defaultValue; } const normalized = raw.trim().toLowerCase(); if (["true", "1", "yes", "on"].includes(normalized)) { return true; } if (["false", "0", "no", "off"].includes(normalized)) { return false; } throw new ConfigError(`${name} must be a boolean`, name); } function url(env, name, { required = false, protocols } = {}) { const raw = required ? requiredString(env, name) : optionalString(env, name); if (raw === null) { return null; } let parsed; try { parsed = new URL(raw); } catch { throw new ConfigError(`${name} must be a valid URL`, name); } if (protocols && !protocols.includes(parsed.protocol)) { throw new ConfigError( `${name} must use one of these protocols: ${protocols.join(", ")}`, name, ); } return parsed.toString().replace(/\/$/, ""); } function bucketName(env) { const value = requiredString(env, "S3_BUCKET"); if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) { throw new ConfigError( "S3_BUCKET must be a valid lowercase S3 bucket name", "S3_BUCKET", ); } return value; } function nodeEnvironment(env) { const value = env.NODE_ENV?.trim() || "development"; if (!["development", "test", "production"].includes(value)) { throw new ConfigError( "NODE_ENV must be development, test, or production", "NODE_ENV", ); } return value; } function timeZone(env) { const value = requiredString(env, "APP_TIMEZONE"); try { new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); } catch { throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE"); } return value; } function storageCredential(env, name, environment) { const value = optionalString(env, name); if (environment === "production" && value === null) { throw new ConfigError(`${name} is required in production`, name); } return value; } function loadConfig(env = process.env) { const environment = nodeEnvironment(env); const databasePath = requiredString(env, "DATABASE_PATH"); if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) { throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH"); } return Object.freeze({ environment, host: env.HOST?.trim() || "0.0.0.0", port: integer(env, "PORT", 7532, { min: 0, max: 65535 }), timeZone: timeZone(env), shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, { min: 1_000, max: 60_000, }), trustProxy: boolean(env, "TRUST_PROXY", true), database: Object.freeze({ path: databasePath, busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, { min: 0, max: 60_000, }), }), storage: Object.freeze({ endpoint: url(env, "S3_ENDPOINT", { required: true, protocols: ["http:", "https:"], }), region: env.S3_REGION?.trim() || "us-east-1", bucket: bucketName(env), accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment), secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment), forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true), healthProbeIntervalMs: integer( env, "S3_HEALTH_PROBE_INTERVAL_MS", 30_000, { min: 1_000, max: 300_000 }, ), connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, { min: 250, max: 60_000, }), requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, { min: 1_000, max: 120_000, }), maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }), }), processing: Object.freeze({ tempDirectory: (() => { const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads"; if (!path.isAbsolute(value)) { throw new ConfigError( "UPLOAD_TEMP_DIRECTORY must be an absolute path", "UPLOAD_TEMP_DIRECTORY", ); } return value; })(), maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, { min: 1, max: 1024 * 1024 * 1024, }), maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }), maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, { min: 1, max: 500_000_000, }), }), mqtt: Object.freeze({ url: url(env, "MQTT_URL", { protocols: ["mqtt:", "mqtts:", "ws:", "wss:"], }), username: optionalString(env, "MQTT_USERNAME"), password: optionalString(env, "MQTT_PASSWORD"), }), notifications: Object.freeze({ homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", { protocols: ["http:", "https:"], }), homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"), }), }); } function redactedConfig(config) { return { environment: config.environment, host: config.host, port: config.port, timeZone: config.timeZone, trustProxy: config.trustProxy, database: { configured: Boolean(config.database.path), }, storage: { configured: Boolean( config.storage.endpoint && config.storage.bucket && config.storage.accessKeyId && config.storage.secretAccessKey, ), region: config.storage.region, bucket: config.storage.bucket, forcePathStyle: config.storage.forcePathStyle, healthProbeIntervalMs: config.storage.healthProbeIntervalMs, connectionTimeoutMs: config.storage.connectionTimeoutMs, requestTimeoutMs: config.storage.requestTimeoutMs, maxAttempts: config.storage.maxAttempts, }, processing: { tempDirectoryConfigured: Boolean(config.processing.tempDirectory), maxFileBytes: config.processing.maxFileBytes, maxFiles: config.processing.maxFiles, maxInputPixels: config.processing.maxInputPixels, }, mqtt: { configured: Boolean(config.mqtt.url), }, notifications: { configured: Boolean(config.notifications.homeAssistantWebhookUrl), }, }; } module.exports = { ConfigError, loadConfig, redactedConfig, };