config.js (4103B)
1 // src/lib/config.js - configuration loading for the auth server 2 // 3 // Resolution order, lowest precedence first: 4 // 1. built-in defaults (see DEFAULTS) 5 // 2. YAML file, from ./auth.yaml when present 6 // 3. environment variables (see ENV_MAP) 7 8 import fs from 'node:fs'; 9 import YAML from 'yaml'; 10 11 const SUPPORTED_DIALECTS = ['sqlite']; 12 13 const DEFAULTS = { 14 server: { 15 host: '0.0.0.0', 16 port: 3000, 17 public_url: 'http://localhost:3000', 18 }, 19 database: { 20 dialect: 'sqlite', 21 path: './data/system.sqlite', 22 realms: './data/realms', 23 }, 24 }; 25 26 // [environment variable, dotted config path, parser] 27 const ENV_MAP = [ 28 ['AUTH_HOST', 'server.host', String], 29 ['AUTH_PORT', 'server.port', toInt], 30 ['AUTH_PUBLIC_URL', 'server.public_url', String], 31 ['AUTH_DATABASE_DIALECT', 'database.dialect', lower], 32 ['AUTH_DATABASE_PATH', 'database.path', String], 33 ['AUTH_REALMS_PATH', 'database.realms', String], 34 ]; 35 36 function lower(v) { 37 return String(v).trim().toLowerCase(); 38 } 39 40 function toInt(v) { 41 const n = parseInt(v, 10); 42 if (!Number.isFinite(n)) throw new Error(`expected an integer, got ${JSON.stringify(v)}`); 43 return n; 44 } 45 46 function isPlainObject(v) { 47 return v !== null && typeof v === 'object' && !Array.isArray(v); 48 } 49 50 function deepMerge(base, overlay) { 51 const out = Array.isArray(base) ? [...base] : { ...base }; 52 for (const [k, v] of Object.entries(overlay || {})) { 53 if (v === undefined) continue; 54 out[k] = isPlainObject(v) && isPlainObject(base?.[k]) ? deepMerge(base[k], v) : v; 55 } 56 return out; 57 } 58 59 function setPath(obj, dotted, value) { 60 const keys = dotted.split('.'); 61 const last = keys.pop(); 62 let cur = obj; 63 for (const k of keys) { 64 if (!isPlainObject(cur[k])) cur[k] = {}; 65 cur = cur[k]; 66 } 67 cur[last] = value; 68 } 69 70 function deepFreeze(obj) { 71 for (const v of Object.values(obj)) { 72 if (isPlainObject(v) || Array.isArray(v)) deepFreeze(v); 73 } 74 return Object.freeze(obj); 75 } 76 77 function validate(cfg) { 78 const errors = []; 79 80 const port = cfg.server.port; 81 if (!Number.isInteger(port) || port < 0 || port > 65535) { 82 errors.push(`server.port must be between 0 and 65535, got ${port}`); 83 } 84 85 try { 86 const publicUrl = new URL(cfg.server.public_url); 87 if (publicUrl.protocol !== 'http:' && publicUrl.protocol !== 'https:') { 88 errors.push('server.public_url must use http or https'); 89 } 90 } catch { 91 errors.push('server.public_url must be an absolute URL'); 92 } 93 94 if (!SUPPORTED_DIALECTS.includes(cfg.database.dialect)) { 95 errors.push( 96 `database.dialect must be one of ${SUPPORTED_DIALECTS.join(', ')}, got ${JSON.stringify(cfg.database.dialect)}` 97 ); 98 } 99 100 if (cfg.database.dialect === 'sqlite') { 101 if (!cfg.database.path) { 102 errors.push('database.path is required for sqlite (set database.path or AUTH_DATABASE_PATH)'); 103 } 104 if (!cfg.database.realms) { 105 errors.push('database.realms is required for sqlite (set database.realms or AUTH_REALMS_PATH)'); 106 } 107 } 108 109 110 111 if (errors.length > 0) { 112 throw new Error(`invalid configuration:\n - ${errors.join('\n - ')}`); 113 } 114 } 115 116 export function loadConfig(explicitPath) { 117 const file = explicitPath || 'auth.yaml'; 118 let cfg = structuredClone(DEFAULTS); 119 let source = null; 120 121 if (fs.existsSync(file)) { 122 const text = fs.readFileSync(file, 'utf8'); 123 let parsed; 124 try { 125 parsed = YAML.parse(text) || {}; 126 } catch (e) { 127 throw new Error(`failed to parse config file ${file}: ${e.message}`); 128 } 129 if (!isPlainObject(parsed)) { 130 throw new Error(`config file ${file} must contain a YAML mapping at the top level`); 131 } 132 cfg = deepMerge(cfg, parsed); 133 source = file; 134 } else if (explicitPath) { 135 throw new Error(`config file not found: ${file}`); 136 } 137 138 for (const [env, dotted, parse] of ENV_MAP) { 139 const raw = process.env[env]; 140 if (raw === undefined || raw === '') continue; 141 try { 142 setPath(cfg, dotted, parse(raw)); 143 } catch (e) { 144 throw new Error(`invalid value for ${env}: ${e.message}`); 145 } 146 } 147 148 cfg.source = source; 149 150 validate(cfg); 151 152 return deepFreeze(cfg); 153 }