UNPKG

propertiesmanager

Version:

Powerful Node.js configuration management with multi-environment support, CLI/env overrides, multi-file composition, hot reload, and security features

246 lines (221 loc) 9.09 kB
/** * propertiesmanager - Configuration management module * * Loads configuration from config/default.json with support for: * - Multiple environments (production/dev/test) * - Command-line parameter overrides via minimist * - Environment variable overrides (PM_* prefix) * - Multi-file configuration composition (default.json, local.json, secrets.json) * - Configuration hot reload with event notification * - Configurable logging with LOG_LEVEL environment variable (debug/info/warn/error) * - JSON5 format support (comments, trailing commas, etc.) * - Prototype pollution protection * - Type conversion for null/undefined values * * @module propertiesmanager * @see {@link https://github.com/aromanino/propertiesmanager} */ var requireJSON5 = require('require-json5'); var fs = require('fs'); var EventEmitter = require('events'); var configEvents = new EventEmitter(); // Utility to deep merge objects function deepMerge(target, source) { for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } deepMerge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } // Convert string literals 'null' or 'undefined' to actual null/undefined values function convertNullUndefined(value) { if (value === 'null') return null; if (value === 'undefined') return undefined; return value; } // Load configuration file with error handling try { var config = requireJSON5("config/default.json"); // Merge config/local.json if present try { var localConfig = requireJSON5("config/local.json"); config = deepMerge(config, localConfig); } catch(e) {} // Merge config/secrets.json if present try { var secretsConfig = requireJSON5("config/secrets.json"); config = deepMerge(config, secretsConfig); } catch(e) {} } catch(err) { console.error('ERROR: config/default.json not found or invalid.'); console.error('Please create config/default.json in your application root directory.'); console.error('See https://github.com/aromanino/propertiesmanager for documentation.'); process.exit(1); } // Logging level support (debug/info/warn/error) var LOG_LEVEL = process.env.LOG_LEVEL || 'info'; function log(level, ...args) { const levels = ['error', 'warn', 'info', 'debug']; if (levels.indexOf(level) <= levels.indexOf(LOG_LEVEL)) { const prefix = 'propertiesmanager'; if (level === 'error') console.error(prefix, ...args); else if (level === 'warn') console.warn(prefix, ...args); else if (level === 'info') console.info(prefix, ...args); else console.log(prefix, ...args); } } var async=require('async'); var _=require('underscore'); var argv = require('minimist')(process.argv.slice(2)); // Environment variable prefix for overrides var ENV_PREFIX = 'PM_'; // Configuration object and environment key var conf; var key; // Select configuration based on NODE_ENV environment variable // Fallback to production if requested environment is not defined switch (process.env['NODE_ENV']) { case 'dev': conf = config.dev || config.production; key = config.dev ? 'dev' : 'production'; break; case 'test': key = config.test ? 'test' : 'production'; conf = config.test || config.production; break; default: conf = config.production; key = 'production'; break; } // Remove minimist's internal '_' array (contains non-flag arguments) delete argv["_"]; // Security: Remove dangerous prototype pollution keys // Prevents attacks via --__proto__.polluted=true, --constructor.polluted=true, etc. delete argv["__proto__"]; delete argv["constructor"]; delete argv["prototype"]; // Export configuration immediately with base config // Changes from command-line processing will be reflected automatically (by reference) exports.conf = conf; // Expose event emitter for config reload exports.configEvents = configEvents; // Process each configuration property asynchronously // Apply command-line overrides if provided via --key=value syntax async.eachOf(conf, function(param, index, callback) { log('debug', 'Processing key:', index); // Security check: Block prototype pollution attempts if (index === '__proto__' || index === 'constructor' || index === 'prototype') { callback(); return; } // 1. Check for environment variable override (PM_KEY) var envKey = ENV_PREFIX + index.toUpperCase(); var envValue = process.env[envKey]; if (envValue !== undefined && envValue !== '') { setValueAndKey(convertNullUndefined(envValue), conf, index, function (err) { callback(); }); return; } // 2. Check for command-line override if (argv[index] !== undefined && argv[index] !== '') { setValueAndKey(convertNullUndefined(argv[index]), conf, index, function (err) { callback(); }); return; } // 3. No override, continue to next property callback(); }, function(err) { // Error handling for async processing if (err) { log('error', 'ERROR processing configuration:', err); process.exit(1); } config[key] = conf; // Hot reload: watch config file and reload on change (disabled by default, enable via ENABLE_CONFIG_WATCH env var) var configPath = "config/default.json"; var enableWatch = process.env.ENABLE_CONFIG_WATCH === 'true'; if (enableWatch && fs.existsSync(configPath)) { fs.watch(configPath, { persistent: false }, function (eventType) { if (eventType === 'change') { try { var newConfig = requireJSON5(configPath); var newConf = newConfig[key] || newConfig.production; Object.assign(conf, newConf); configEvents.emit('reload', conf); log('info', 'Configuration hot-reloaded for environment:', key); } catch (err) { log('error', 'Error reloading config:', err); } } }); log('debug', 'Config file watcher enabled for:', configPath); } else if (!enableWatch) { log('debug', 'Config file watcher disabled (set ENABLE_CONFIG_WATCH=true to enable)'); } log('info', 'Configuration loaded successfully for environment:', key); // conf is already exported by reference, so changes are reflected automatically }); /** * Recursively sets a value in the configuration object * * Handles both simple values and nested objects using dot notation. * Only updates properties that already exist in the configuration (no property injection). * * @param {*} argvTmp - Value to set (can be any type including object) * @param {Object} currentObj - Current object in the configuration tree * @param {string} currentKey - Key to set in the current object * @param {Function} callbackEnd - Callback function to call when done * * @example * // Simple value: --port=8080 * setValueAndKey(8080, config, 'port', callback); * * // Nested object: --server.port=8080 * setValueAndKey({port: 8080}, config, 'server', callback); */ function setValueAndKey(argvTmp, currentObj, currentKey, callbackEnd) { // Security check: Block prototype pollution attempts at any nesting level if (currentKey === '__proto__' || currentKey === 'constructor' || currentKey === 'prototype') { callbackEnd(null); return; } // Handle simple values (strings, numbers, booleans, null, undefined) if ((typeof argvTmp !== "object") || argvTmp === null) { // Only update if property already exists (no property injection) if (_.has(currentObj, currentKey)) { currentObj[currentKey] = argvTmp; } callbackEnd(null); } else { // Handle nested objects - recursively process each property var keys = _.keys(argvTmp); async.eachOf(keys, function(value, key, callback) { // Security check: Block prototype pollution in nested properties if (value === '__proto__' || value === 'constructor' || value === 'prototype') { callback(); return; } // Only process if the parent object exists if (currentObj[currentKey]) { // Recursively process nested property with null/undefined conversion setValueAndKey(convertNullUndefined(argvTmp[value]), currentObj[currentKey], value, function (err) { callback(); }); } else { // Parent doesn't exist, skip this property callback(); } }, function (err) { callbackEnd(null); }); } }