import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { readFile } from 'node:fs/promises'; const here = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(here, '..'); export const CONFIG_FILENAME = '.config.json'; function invalid(filePath, detail) { throw new Error(`Invalid configuration in ${filePath}: ${detail}`); } function requireObject(value, field, filePath) { if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(filePath, `${field} must be an object`); return value; } function requireString(value, field, filePath) { if (typeof value !== 'string' || !value.trim()) invalid(filePath, `${field} must be a non-empty string`); return value.trim(); } function requirePassword(value, field, filePath) { if (typeof value !== 'string') invalid(filePath, `${field} must be a string`); return value; } function isDevelopmentOrigin(origin) { let url; try { url = new URL(origin); } catch { return false; } if (url.protocol !== 'http:') return false; const hostname = url.hostname.toLowerCase(); if (hostname === 'localhost' || hostname === '127.0.0.1') return true; const octets = hostname.split('.').map((part) => Number(part)); if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; return octets[0] === 10 || (octets[0] === 192 && octets[1] === 168) || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31); } function validateConfig(value, filePath) { const root = requireObject(value, 'root', filePath); const mongodb = requireObject(root.mongodb, 'mongodb', filePath); const server = requireObject(root.server, 'server', filePath); const bootstrap = requireObject(root.bootstrap, 'bootstrap', filePath); const qa = requireObject(root.qa, 'qa', filePath); const uri = requireString(mongodb.uri, 'mongodb.uri', filePath); const db = requireString(mongodb.db, 'mongodb.db', filePath); const host = requireString(server.host, 'server.host', filePath); if (!Number.isInteger(server.port) || server.port < 1 || server.port > 65_535) invalid(filePath, 'server.port must be an integer between 1 and 65535'); if (server.mode !== 'development' && server.mode !== 'production') invalid(filePath, 'server.mode must be development or production'); if (!Array.isArray(server.allowedOrigins) || server.allowedOrigins.some((origin) => typeof origin !== 'string' || !origin.trim())) invalid(filePath, 'server.allowedOrigins must be an array of non-empty strings'); const allowedOrigins = server.allowedOrigins.map((origin) => origin.trim()); if (typeof server.secureCookie !== 'boolean') invalid(filePath, 'server.secureCookie must be a boolean'); const bootstrapPassword = requirePassword(bootstrap.password, 'bootstrap.password', filePath); const qaDb = requireString(qa.db, 'qa.db', filePath); const qaPassword = requirePassword(qa.password, 'qa.password', filePath); const production = server.mode === 'production'; return { mongodb: { uri, db }, server: { host, port: server.port, mode: server.mode, allowedOrigins: production ? allowedOrigins.filter((origin) => !isDevelopmentOrigin(origin)) : allowedOrigins, secureCookie: production || server.secureCookie, }, bootstrap: { password: bootstrapPassword }, qa: { db: qaDb, password: qaPassword }, }; } export function resolveConfigPath(configPath = CONFIG_FILENAME, root = projectRoot) { return path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath); } export async function loadConfig(options = {}) { const requestedPath = typeof options === 'string' ? options : options.path || options.filePath || CONFIG_FILENAME; const filePath = resolveConfigPath(requestedPath, typeof options === 'object' ? options.projectRoot || projectRoot : projectRoot); let source; try { source = await readFile(filePath, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') throw new Error(`Configuration file not found at ${filePath}. Copy .config.json.sample to .config.json and set the required values.`); throw new Error(`Unable to read configuration file ${filePath}: ${error?.code || 'unknown error'}`); } let parsed; try { parsed = JSON.parse(source.charCodeAt(0) === 0xFEFF ? source.slice(1) : source); } catch { throw new Error(`Invalid JSON in configuration file ${filePath}; check its JSON syntax.`); } return validateConfig(parsed, filePath); } export { isDevelopmentOrigin, projectRoot };