auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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 }