/** * Configuration Loading * * Loads config from (in order of precedence): * 1. CLI arguments (highest) * 2. Environment variables (JSS_*) * 3. Config file (config.json) * 4. Defaults (lowest) */ import fs from 'fs-extra'; import path from 'path'; /** * Default configuration values */ export const defaults = { // Server port: 4443, host: '0.0.0.0', root: './data', // SSL sslKey: null, sslCert: null, // Features multiuser: true, conneg: false, notifications: false, // Identity Provider idp: false, idpIssuer: null, // Subdomain mode (XSS protection) subdomains: false, baseDomain: null, // Mashlib data browser mashlib: false, mashlibCdn: false, mashlibVersion: '2.0.0', mashlibModule: false, // Git HTTP backend git: false, // Nostr relay nostr: false, nostrPath: '/relay', nostrMaxEvents: 1000, // WebRTC signaling webrtc: false, webrtcPath: '/.webrtc', // Terminal (WebSocket shell access) terminal: false, // Tunnel (decentralized ngrok) tunnel: false, tunnelPath: '/.tunnel', // ActivityPub federation activitypub: false, apUsername: 'me', apDisplayName: null, apSummary: null, apNostrPubkey: null, // Invite-only registration inviteOnly: false, // Single-user mode (personal pod server) singleUser: false, singleUserName: 'me', // Initial IDP password seeded on first single-user pod creation. If // unset and --idp is enabled, the server prompts on a TTY or logs a // warning and continues startup on non-TTY (so the pod is created but // is not yet loggable until a password is set). singleUserPassword: null, // WebID-TLS client certificate authentication webidTls: false, // Storage quota (bytes) - 50MB default defaultQuota: 50 * 1024 * 1024, // Public mode - skip WAC, allow unauthenticated access public: false, // Read-only mode - disable PUT/DELETE/PATCH readOnly: false, // Live reload - inject script to auto-refresh browser on file changes liveReload: false, // HTTP 402 paid access pay: false, payCost: 1, payMempoolUrl: 'https://mempool.space/testnet4', payAddress: null, payToken: null, payRate: 1, payChains: null, // comma-separated chain IDs, e.g. "tbtc3,tbtc4" // MongoDB-backed /db/ route mongo: false, mongoUrl: 'mongodb://localhost:27017', mongoDatabase: 'solid', // Logging logger: true, quiet: false, logLevel: 'info', // Paths configPath: './.jss', }; /** * Map of environment variable names to config keys */ const envMap = { JSS_PORT: 'port', JSS_HOST: 'host', JSS_ROOT: 'root', JSS_SSL_KEY: 'sslKey', JSS_SSL_CERT: 'sslCert', JSS_MULTIUSER: 'multiuser', JSS_CONNEG: 'conneg', JSS_NOTIFICATIONS: 'notifications', JSS_QUIET: 'quiet', JSS_LOG_LEVEL: 'logLevel', JSS_CONFIG_PATH: 'configPath', JSS_IDP: 'idp', JSS_IDP_ISSUER: 'idpIssuer', JSS_SUBDOMAINS: 'subdomains', JSS_BASE_DOMAIN: 'baseDomain', JSS_MASHLIB: 'mashlib', JSS_MASHLIB_CDN: 'mashlibCdn', JSS_MASHLIB_VERSION: 'mashlibVersion', JSS_MASHLIB_MODULE: 'mashlibModule', JSS_GIT: 'git', JSS_NOSTR: 'nostr', JSS_NOSTR_PATH: 'nostrPath', JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents', JSS_WEBRTC: 'webrtc', JSS_WEBRTC_PATH: 'webrtcPath', JSS_TERMINAL: 'terminal', JSS_TUNNEL: 'tunnel', JSS_TUNNEL_PATH: 'tunnelPath', JSS_ACTIVITYPUB: 'activitypub', JSS_AP_USERNAME: 'apUsername', JSS_AP_DISPLAY_NAME: 'apDisplayName', JSS_AP_SUMMARY: 'apSummary', JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey', JSS_INVITE_ONLY: 'inviteOnly', JSS_SINGLE_USER: 'singleUser', JSS_SINGLE_USER_NAME: 'singleUserName', JSS_SINGLE_USER_PASSWORD: 'singleUserPassword', JSS_WEBID_TLS: 'webidTls', JSS_DEFAULT_QUOTA: 'defaultQuota', JSS_PUBLIC: 'public', JSS_READ_ONLY: 'readOnly', JSS_LIVE_RELOAD: 'liveReload', JSS_PAY: 'pay', JSS_PAY_COST: 'payCost', JSS_PAY_MEMPOOL_URL: 'payMempoolUrl', JSS_PAY_ADDRESS: 'payAddress', JSS_PAY_TOKEN: 'payToken', JSS_PAY_RATE: 'payRate', JSS_PAY_CHAINS: 'payChains', JSS_MONGO: 'mongo', JSS_MONGO_URL: 'mongoUrl', JSS_MONGO_DATABASE: 'mongoDatabase', }; /** * Parse a size string like "50MB" or "1GB" to bytes */ export function parseSize(str) { const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i); if (!match) return parseInt(str, 10) || 0; const num = parseFloat(match[1]); const unit = (match[2] || 'B').toUpperCase(); const multipliers = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }; return Math.floor(num * (multipliers[unit] || 1)); } /** * Config keys whose values are genuinely boolean. Only these get the * "true"/"false" string coercion below — otherwise a user-supplied * password (or any other string-valued option) like "true"/"false" * would silently turn into a boolean and break downstream code (e.g. * bcrypt hashing). */ const BOOLEAN_KEYS = new Set([ 'ssl', 'conneg', 'subdomains', 'mashlib', 'mashlibCdn', 'git', 'nostr', 'webrtc', 'terminal', 'tunnel', 'activitypub', 'inviteOnly', 'multiuser', 'singleUser', 'webidTls', 'public', 'readOnly', 'liveReload', 'pay', 'mongo', 'idp', 'notifications', 'logger', 'quiet' ]); /** * Parse a value from environment variable string */ function parseEnvValue(value, key) { if (value === undefined) return undefined; // Boolean values — only for known boolean keys; everything else // stays a string so passwords / tokens / arbitrary text aren't // silently coerced to booleans. if (BOOLEAN_KEYS.has(key)) { if (value.toLowerCase() === 'true') return true; if (value.toLowerCase() === 'false') return false; } // Numeric values for known numeric keys if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) { return parseInt(value, 10); } // Size values (quota) if (key === 'defaultQuota') { return parseSize(value); } return value; } /** * Load configuration from environment variables */ function loadEnvConfig() { const config = {}; for (const [envVar, configKey] of Object.entries(envMap)) { const value = process.env[envVar]; if (value !== undefined) { config[configKey] = parseEnvValue(value, configKey); } } return config; } /** * Load configuration from a JSON file */ async function loadFileConfig(configFile) { if (!configFile) return {}; try { const fullPath = path.resolve(configFile); if (await fs.pathExists(fullPath)) { const content = await fs.readFile(fullPath, 'utf8'); return JSON.parse(content); } } catch (e) { console.error(`Warning: Failed to load config file: ${e.message}`); } return {}; } /** * Merge configuration sources * @param {object} cliOptions - Options from command line * @param {string} configFile - Path to config file (optional) * @returns {Promise} Merged configuration */ export async function loadConfig(cliOptions = {}, configFile = null) { // Load from file first const fileConfig = await loadFileConfig(configFile || cliOptions.config); // Load from environment const envConfig = loadEnvConfig(); // Merge in order: defaults < file < env < cli const config = { ...defaults, ...fileConfig, ...envConfig, ...filterUndefined(cliOptions), }; // Derive additional settings if (config.quiet) { config.logger = false; } // Validate log level const validLevels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace']; if (!validLevels.includes(config.logLevel)) { console.warn(`Invalid log level '${config.logLevel}', falling back to 'info'. Valid levels: ${validLevels.join(', ')}`); config.logLevel = 'info'; } // Mashlib requires content negotiation for Turtle support if (config.mashlib || config.mashlibCdn || config.mashlibModule) { config.conneg = true; } // Validate SSL config if ((config.sslKey && !config.sslCert) || (!config.sslKey && config.sslCert)) { throw new Error('Both --ssl-key and --ssl-cert must be provided together'); } config.ssl = !!(config.sslKey && config.sslCert); return config; } /** * Filter out undefined values from an object */ function filterUndefined(obj) { const result = {}; for (const [key, value] of Object.entries(obj)) { if (value !== undefined) { result[key] = value; } } return result; } /** * Save configuration to a file */ export async function saveConfig(config, configFile) { const toSave = { ...config }; // Remove derived/runtime values delete toSave.ssl; delete toSave.logger; // Never persist secrets to a static config file. The password is // expected to come from --single-user-password or // JSS_SINGLE_USER_PASSWORD at runtime, not be written into .jss/config. delete toSave.singleUserPassword; await fs.ensureDir(path.dirname(configFile)); await fs.writeFile(configFile, JSON.stringify(toSave, null, 2)); } /** * Print configuration (for debugging) */ export function printConfig(config) { console.log('\nConfiguration:'); console.log('─'.repeat(40)); console.log(` Port: ${config.port}`); console.log(` Host: ${config.host}`); console.log(` Root: ${path.resolve(config.root)}`); console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`); console.log(` Multi-user: ${config.multiuser}`); if (config.singleUser) { let details = `${config.singleUserName}`; // Password seeding only runs when --idp is on AND the pod isn't the // root-level case ('/'). Reflect both gates in the printed line so // operators don't see a misleading "missing — login disabled" when // login isn't governed by an IDP password at all. if (config.idp) { if (config.singleUserName === '/' || !config.singleUserName) { details += ' (root pod; password not seeded)'; } else { const pwSource = config.singleUserPassword ? 'provided' : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled'); details += ` (password: ${pwSource})`; } } console.log(` Single-user: ${details}`); } console.log(` Conneg: ${config.conneg}`); console.log(` Notifications: ${config.notifications}`); console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`); console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`); console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : 'disabled'}`); if (config.pay) { console.log(` Pay: ${config.payCost} sat/req`); if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`); } if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`); console.log('─'.repeat(40)); }