• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 25277590437

03 May 2026 11:12AM UTC coverage: 90.19% (+0.03%) from 90.158%
25277590437

push

github

BillChirico
docs: restore wiki home links

10082 of 11816 branches covered (85.32%)

Branch coverage included in aggregate %.

15890 of 16981 relevant lines covered (93.58%)

169.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.72
/src/modules/config.js
1
/**
2
 * Configuration Module
3
 * Loads config from PostgreSQL with config.json as the seed/fallback
4
 * Supports per-guild config overrides merged onto global defaults
5
 */
6

7
import { existsSync, readFileSync } from 'node:fs';
8
import { dirname, join } from 'node:path';
9
import { fileURLToPath } from 'node:url';
10
import { isDeepStrictEqual } from 'node:util';
11
import { DANGEROUS_KEYS } from '../api/utils/dangerousKeys.js';
12
import { getPool } from '../db.js';
13
import { info, error as logError, warn as logWarn } from '../logger.js';
14

15
const __dirname = dirname(fileURLToPath(import.meta.url));
114✔
16
const configPath = join(__dirname, '..', '..', 'config.json');
114✔
17

18
/** Maximum number of guild entries (excluding 'global') kept in configCache */
19
const MAX_GUILD_CACHE_SIZE = 500;
114✔
20
const TRIAGE_OVERRIDE_LOG_LIMIT = 10;
114✔
21

22
/** @type {Array<{path: string, callback: Function}>} Registered change listeners */
23
const listeners = [];
114✔
24

25
/**
26
 * Authoritative per-guild/global overrides loaded from the database.
27
 * Intentionally unbounded: entries here are source-of-truth snapshots that are
28
 * not cheap to rebuild without re-querying PostgreSQL.
29
 * Hot-path memory/performance pressure is handled separately by mergedConfigCache,
30
 * which stores computed global+guild views with LRU eviction.
31
 *
32
 * Expected upper bound: bounded by the number of guilds that have customized
33
 * config via /config set or the PATCH API, which mirrors the distinct guild_id
34
 * rows in the database. Each entry is small (only the override keys, not full
35
 * config). For deployments with >1000 guilds with overrides, consider adding
36
 * a size warning log or lazy-loading from DB on cache miss.
37
 * @type {Map<string, Object>}
38
 */
39
let configCache = new Map();
114✔
40

41
/** @type {Map<string, {generation: number, data: Object}>} Cached merged (global + guild override) config per guild */
42
const mergedConfigCache = new Map();
114✔
43

44
/**
45
 * Monotonically increasing counter bumped every time global config changes
46
 * through setConfigValue, resetConfig, or loadConfig. Used to detect stale
47
 * merged cache entries — if a cached entry's generation doesn't match, it
48
 * is treated as a cache miss and rebuilt from the current global config.
49
 *
50
 * ⚠️ This does NOT detect in-place mutations to the live global config
51
 * reference returned by getConfig() (no args). Such mutations are DEPRECATED
52
 * and should use setConfigValue() instead, which properly increments this
53
 * counter and invalidates the merged cache.
54
 * @type {number}
55
 */
56
let globalConfigGeneration = 0;
114✔
57

58
/** @type {Object|null} Cached config.json contents (loaded once, never invalidated) */
59
let fileConfigCache = null;
114✔
60

61
/**
62
 * Deep merge guild overrides onto global defaults.
63
 * For each key, if both source and target have plain objects, merge recursively.
64
 * Otherwise the source (guild override) value wins.
65
 * @param {Object} target - Cloned global defaults (mutated in place)
66
 * @param {Object} source - Guild overrides
67
 * @returns {Object} The merged target
68
 */
69
function deepMerge(target, source) {
70
  for (const key of Object.keys(source)) {
83✔
71
    if (DANGEROUS_KEYS.has(key)) continue;
97✔
72

73
    if (isPlainObject(target[key]) && isPlainObject(source[key])) {
91✔
74
      deepMerge(target[key], source[key]);
34✔
75
    } else {
76
      target[key] = structuredClone(source[key]);
57✔
77
    }
78
  }
79
  return target;
83✔
80
}
81

82
/**
83
 * Merge a database config section over the matching config.json defaults.
84
 *
85
 * Database rows may be partial when a deployment adds new config.json defaults
86
 * after the row was first seeded. Preserve those new defaults while still
87
 * letting explicit database values (including null) override the file value.
88
 *
89
 * @param {*} defaultValue - Matching value from config.json, if available
90
 * @param {*} dbValue - Value loaded from the database
91
 * @returns {*} Merged config value
92
 */
93
function mergeDbValueWithDefaults(defaultValue, dbValue) {
94
  if (isPlainObject(defaultValue) && isPlainObject(dbValue)) {
15✔
95
    return deepMerge(structuredClone(defaultValue), dbValue);
14✔
96
  }
97

98
  if (isPlainObject(dbValue)) {
1!
99
    return deepMerge({}, dbValue);
1✔
100
  }
101

102
  return structuredClone(dbValue);
×
103
}
104

105
function getGuildTriageModelOverrides(guildRows) {
106
  return guildRows
12✔
107
    .filter((row) => row.key === 'triage' && isPlainObject(row.value))
5✔
108
    .map((row) => ({
1✔
109
      guildId: row.guild_id,
110
      classifyModel:
111
        typeof row.value.classifyModel === 'string' ? row.value.classifyModel : undefined,
1!
112
      respondModel: typeof row.value.respondModel === 'string' ? row.value.respondModel : undefined,
1!
113
    }))
114
    .filter((row) => row.classifyModel || row.respondModel);
1!
115
}
116

117
function logGuildTriageModelOverrides(guildRows) {
118
  const overrides = getGuildTriageModelOverrides(guildRows);
12✔
119
  if (overrides.length === 0) return;
12✔
120

121
  info('Guild triage model overrides loaded', {
1✔
122
    guildCount: overrides.length,
123
    overrides: overrides.slice(0, TRIAGE_OVERRIDE_LOG_LIMIT),
124
    omittedCount: Math.max(0, overrides.length - TRIAGE_OVERRIDE_LOG_LIMIT),
125
  });
126
}
127

128
/**
129
 * Load config.json from disk (used as seed/fallback).
130
 *
131
 * Security note: config.json integrity is a deployment concern — the file is
132
 * read-only at runtime and is not validated beyond JSON parsing. Deployers
133
 * must ensure the file is not writable by untrusted processes.
134
 *
135
 * @returns {Object} Configuration object from file
136
 * @throws {Error} If config.json is missing or unparseable
137
 */
138
export function loadConfigFromFile() {
139
  if (fileConfigCache) return fileConfigCache;
106✔
140

141
  if (!existsSync(configPath)) {
93✔
142
    const err = new Error('config.json not found!');
1✔
143
    err.code = 'CONFIG_NOT_FOUND';
1✔
144
    throw err;
1✔
145
  }
146
  try {
92✔
147
    fileConfigCache = JSON.parse(readFileSync(configPath, 'utf-8'));
92✔
148
    return fileConfigCache;
92✔
149
  } catch (err) {
150
    throw new Error(`Failed to load config.json: ${err.message}`);
1✔
151
  }
152
}
153

154
/**
155
 * Load config from PostgreSQL, seeding from config.json if empty
156
 * Falls back to config.json if database is unavailable
157
 * @returns {Promise<Object>} Global configuration object (for backward compat)
158
 */
159
export async function loadConfig() {
160
  // Clear stale merged cache — configCache is about to be rebuilt, so any
161
  // previously merged guild snapshots are invalid.
162
  mergedConfigCache.clear();
91✔
163
  globalConfigGeneration++;
91✔
164

165
  // Try loading config.json — DB may have valid config even if file is missing
166
  let fileConfig;
167
  try {
91✔
168
    fileConfig = loadConfigFromFile();
91✔
169
  } catch {
170
    fileConfig = null;
×
171
    info('config.json not available, will rely on database for configuration');
×
172
  }
173

174
  try {
91✔
175
    let pool;
176
    try {
91✔
177
      pool = getPool();
91✔
178
    } catch {
179
      // DB not initialized — file config is our only option
180
      if (!fileConfig) {
76!
181
        throw new Error(
×
182
          'No configuration source available: config.json is missing and database is not initialized',
183
        );
184
      }
185
      info('Database not available, using config.json');
76✔
186
      configCache = new Map();
76✔
187
      configCache.set('global', structuredClone(fileConfig));
76✔
188
      return configCache.get('global');
76✔
189
    }
190

191
    // NOTE: This fetches all config rows (all guilds) into memory at startup.
192
    // For large deployments with many guilds, consider lazy-loading guild configs
193
    // on first access or paginating this query. Currently acceptable for <1000 guilds.
194
    const { rows } = await pool.query('SELECT guild_id, key, value FROM config');
15✔
195

196
    // Separate global rows from guild-specific rows.
197
    // Treat rows with missing/undefined guild_id as 'global' (handles unmigrated DBs).
198
    const globalRows = rows.filter((r) => !r.guild_id || r.guild_id === 'global');
20✔
199
    const guildRows = rows.filter((r) => r.guild_id && r.guild_id !== 'global');
20✔
200

201
    if (globalRows.length === 0) {
14✔
202
      if (!fileConfig) {
3!
203
        throw new Error(
×
204
          'No configuration source available: database is empty and config.json is missing',
205
        );
206
      }
207
      // Seed database from config.json inside a transaction
208
      info('No config in database, seeding from config.json');
3✔
209
      const client = await pool.connect();
3✔
210
      try {
3✔
211
        await client.query('BEGIN');
3✔
212
        for (const [key, value] of Object.entries(fileConfig)) {
3✔
213
          await client.query(
5✔
214
            'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
215
            ['global', key, JSON.stringify(value)],
216
          );
217
        }
218
        await client.query('COMMIT');
2✔
219
        info('Config seeded to database');
2✔
220
        configCache = new Map();
2✔
221
        configCache.set('global', structuredClone(fileConfig));
2✔
222

223
        // Load any preexisting guild overrides that were already in the DB.
224
        // Without this, guild rows fetched above would be silently dropped.
225
        for (const row of guildRows) {
2✔
226
          if (DANGEROUS_KEYS.has(row.key)) {
1!
227
            logWarn('Skipping dangerous config key from database', {
×
228
              key: row.key,
229
              guildId: row.guild_id,
230
            });
231
            continue;
×
232
          }
233

234
          if (!configCache.has(row.guild_id)) {
1!
235
            configCache.set(row.guild_id, {});
1✔
236
          }
237
          configCache.get(row.guild_id)[row.key] = row.value;
1✔
238
        }
239
        if (guildRows.length > 0) {
2✔
240
          info('Loaded guild overrides during seed', {
1✔
241
            guildCount: new Set(guildRows.map((r) => r.guild_id)).size,
1✔
242
          });
243
          logGuildTriageModelOverrides(guildRows);
1✔
244
        }
245
      } catch (txErr) {
246
        try {
1✔
247
          await client.query('ROLLBACK');
1✔
248
        } catch {
249
          /* ignore rollback failure */
250
        }
251
        throw txErr;
1✔
252
      } finally {
253
        client.release();
3✔
254
      }
255
    } else {
256
      // Build config map from database rows
257
      configCache = new Map();
11✔
258

259
      // Build global config, using config.json as defaults for partial DB sections.
260
      const globalConfig = fileConfig ? structuredClone(fileConfig) : {};
11!
261
      for (const row of globalRows) {
11✔
262
        if (DANGEROUS_KEYS.has(row.key)) {
15!
263
          logWarn('Skipping dangerous config key from database', {
×
264
            key: row.key,
265
            guildId: row.guild_id,
266
          });
267
          continue;
×
268
        }
269

270
        globalConfig[row.key] = mergeDbValueWithDefaults(globalConfig[row.key], row.value);
15✔
271
      }
272
      configCache.set('global', globalConfig);
11✔
273

274
      // Build per-guild configs (overrides only)
275
      for (const row of guildRows) {
11✔
276
        if (DANGEROUS_KEYS.has(row.key)) {
4!
277
          logWarn('Skipping dangerous config key from database', {
×
278
            key: row.key,
279
            guildId: row.guild_id,
280
          });
281
          continue;
×
282
        }
283

284
        if (!configCache.has(row.guild_id)) {
4!
285
          configCache.set(row.guild_id, {});
4✔
286
        }
287
        configCache.get(row.guild_id)[row.key] = row.value;
4✔
288
      }
289

290
      info('Config loaded from database', {
11✔
291
        globalKeys: globalRows.length,
292
        guildCount: new Set(guildRows.map((r) => r.guild_id)).size,
4✔
293
      });
294
      logGuildTriageModelOverrides(guildRows);
11✔
295
    }
296
  } catch (err) {
297
    if (!fileConfig) {
2!
298
      // No fallback available — re-throw
299
      throw err;
×
300
    }
301
    logError('Failed to load config from database, using config.json', { error: err.message });
2✔
302
    configCache = new Map();
2✔
303
    configCache.set('global', structuredClone(fileConfig));
2✔
304
  }
305

306
  return configCache.get('global');
15✔
307
}
308

309
/**
310
 * Get the current config (from cache).
311
 *
312
 * **Return semantics differ by path (intentional):**
313
 * - **Global path** (no guildId or guildId='global'): Returns a LIVE MUTABLE reference
314
 *   to the cached global config object. Mutations are visible to all subsequent callers.
315
 *   This is intentional for backward compatibility — existing code relies on mutating the
316
 *   returned object and having changes propagate.
317
 * - **Guild path** (guildId provided): Returns a deep-cloned merged copy of global defaults
318
 *   + guild overrides. Each call returns a fresh object; mutations do NOT affect the cache.
319
 *   This prevents cross-guild contamination.
320
 *
321
 * **⚠️ IMPORTANT: In-place mutation caveat:**
322
 * Direct mutation of the global config object (e.g. `getConfig().ai.model = "new"`) does
323
 * NOT invalidate `mergedConfigCache` or bump `globalConfigGeneration`. Guild-specific calls
324
 * to `getConfig(guildId)` may return stale merged data that still reflects the old global
325
 * defaults until the merged cache entry expires or is rebuilt. Use `setConfigValue()` for
326
 * proper cache invalidation. This asymmetry is intentional for backward compatibility with
327
 * legacy code that relies on mutating the returned global reference.
328
 *
329
 * @param {string} [guildId] - Guild ID, or omit / 'global' for global defaults
330
 * @returns {Object} Configuration object (live reference for global, cloned copy for guild)
331
 */
332
export function getConfig(guildId) {
333
  if (!guildId || guildId === 'global') {
142✔
334
    // ⚠️ Returns live cache reference — callers must NOT mutate the returned object
335
    return configCache.get('global') || {};
87✔
336
  }
337

338
  // Return clone from cached merged result if available and still valid.
339
  // Entries are stamped with the globalConfigGeneration at merge time —
340
  // if global config changed since then, the entry is stale and must be rebuilt.
341
  const cached = mergedConfigCache.get(guildId);
55✔
342
  if (cached && cached.generation === globalConfigGeneration) {
55✔
343
    // Refresh access order for LRU tracking (Maps preserve insertion order)
344
    mergedConfigCache.delete(guildId);
11✔
345
    mergedConfigCache.set(guildId, cached);
11✔
346
    // Guild path: returns deep clone to prevent cross-guild contamination (see JSDoc above)
347
    return structuredClone(cached.data);
11✔
348
  }
349

350
  const globalConfig = configCache.get('global') || {};
44✔
351
  const guildOverrides = configCache.get(guildId);
142✔
352

353
  if (!guildOverrides) {
142✔
354
    // Cache a reference to global defaults and return a detached clone.
355
    // This avoids an extra clone on cache-miss while preserving isolation for callers.
356
    cacheMergedResult(guildId, globalConfig);
10✔
357
    return structuredClone(globalConfig);
10✔
358
  }
359

360
  const merged = deepMerge(structuredClone(globalConfig), guildOverrides);
34✔
361
  cacheMergedResult(guildId, merged);
34✔
362
  return structuredClone(merged);
34✔
363
}
364

365
/**
366
 * Store a merged config result and enforce the LRU guild cache cap.
367
 * Evicts the least-recently-used guild entries when the cap is exceeded.
368
 * @param {string} guildId - Guild ID
369
 * @param {Object} merged - Merged config object
370
 */
371
function cacheMergedResult(guildId, merged) {
372
  mergedConfigCache.set(guildId, { generation: globalConfigGeneration, data: merged });
44✔
373

374
  // Evict least-recently-used guild entries when cap is exceeded
375
  if (mergedConfigCache.size > MAX_GUILD_CACHE_SIZE) {
44!
376
    const firstKey = mergedConfigCache.keys().next().value;
×
377
    mergedConfigCache.delete(firstKey);
×
378
  }
379
}
380

381
/**
382
 * Traverse a nested object along dot-notation path segments and return the value.
383
 * Returns undefined if any intermediate key is missing.
384
 * @param {Object} obj - Object to traverse
385
 * @param {string[]} pathParts - Path segments
386
 * @returns {*} Value at the path, or undefined
387
 */
388
function getNestedValue(obj, pathParts) {
389
  let current = obj;
110✔
390
  for (const part of pathParts) {
110✔
391
    if (current == null || typeof current !== 'object') return undefined;
112✔
392
    current = current[part];
78✔
393
  }
394
  return current;
76✔
395
}
396

397
/**
398
 * Register a listener for config changes.
399
 * Use exact paths (e.g. "ai.model") or prefix wildcards (e.g. "ai.*").
400
 * @param {string} pathOrPrefix - Dot-notation path or prefix with wildcard
401
 * @param {Function} callback - Called with (newValue, oldValue, fullPath, guildId)
402
 */
403
/**
404
 * Get all guild IDs that have config entries (including 'global').
405
 * Used by webhook notifier to fire bot-level events to all guilds.
406
 *
407
 * @returns {string[]} Array of guild IDs (excluding 'global')
408
 */
409
export function getAllGuildIds() {
410
  return [...configCache.keys()].filter((id) => id !== 'global');
×
411
}
412

413
export function onConfigChange(pathOrPrefix, callback) {
414
  listeners.push({ path: pathOrPrefix, callback });
33✔
415
}
416

417
/**
418
 * Remove a previously registered config change listener.
419
 * @param {string} pathOrPrefix - Same path used in onConfigChange
420
 * @param {Function} callback - Same callback reference used in onConfigChange
421
 */
422
export function offConfigChange(pathOrPrefix, callback) {
423
  const idx = listeners.findIndex((l) => l.path === pathOrPrefix && l.callback === callback);
4✔
424
  if (idx !== -1) listeners.splice(idx, 1);
3!
425
}
426

427
/**
428
 * Remove all registered config change listeners.
429
 */
430
export function clearConfigListeners() {
431
  listeners.length = 0;
48✔
432
}
433

434
/**
435
 * Emit config change events to matching listeners.
436
 * Matches exact paths and prefix wildcards (e.g. "ai.*" matches "ai.model").
437
 * @param {string} fullPath - The full dot-notation path that changed
438
 * @param {*} newValue - The new value
439
 * @param {*} oldValue - The previous value
440
 * @param {string} guildId - The guild ID that changed ('global' for global)
441
 */
442
async function emitConfigChangeEvents(fullPath, newValue, oldValue, guildId) {
443
  for (const listener of [...listeners]) {
113✔
444
    const isExact = listener.path === fullPath;
34✔
445
    const isPrefix =
446
      !isExact &&
34✔
447
      listener.path.endsWith('.*') &&
448
      fullPath.startsWith(listener.path.replace(/\.\*$/, '.'));
449
    const isWildcard = listener.path === '*';
34✔
450
    if (isExact || isPrefix || isWildcard) {
34✔
451
      try {
29✔
452
        const result = listener.callback(newValue, oldValue, fullPath, guildId);
29✔
453
        if (result && typeof result.then === 'function') {
29✔
454
          await result.catch((err) => {
1✔
455
            logWarn('Async config change listener error', {
1✔
456
              path: fullPath,
457
              error: String(err?.message || err),
1!
458
            });
459
          });
460
        }
461
      } catch (err) {
462
        logError('Config change listener error', {
1✔
463
          path: fullPath,
464
          error: String(err?.message || err),
1!
465
        });
466
      }
467
    }
468
  }
469
}
470

471
/**
472
 * Clone a value for safe event payload emission.
473
 * @param {*} value - Value to clone when object-like
474
 * @returns {*}
475
 */
476
function cloneForEvent(value) {
477
  return value !== null && typeof value === 'object' ? structuredClone(value) : value;
138!
478
}
479

480
/**
481
 * Check whether a config path likely contains sensitive material.
482
 * @param {string} path - Dot-notation config path
483
 * @returns {boolean} `true` when the path should have its value redacted in logs
484
 */
485
function isSensitiveConfigPath(path) {
486
  return path.split('.').some((segment) => /apikey|token|secret|password|key/i.test(segment));
×
487
}
488

489
/**
490
 * Collect leaf values from an object into a dot-notation map.
491
 * Plain-object leaves are flattened; arrays and primitives are treated as terminal values.
492
 * @param {*} value - Root value
493
 * @param {string} prefix - Current dot-notation prefix
494
 * @param {Map<string, *>} out - Output map
495
 */
496
function collectLeafValues(value, prefix, out) {
497
  if (isPlainObject(value)) {
233✔
498
    for (const key of Object.keys(value)) {
95✔
499
      if (DANGEROUS_KEYS.has(key)) continue;
200✔
500
      const path = prefix ? `${prefix}.${key}` : key;
197✔
501
      collectLeafValues(value[key], path, out);
200✔
502
    }
503
    return;
95✔
504
  }
505

506
  if (prefix) {
138!
507
    out.set(prefix, cloneForEvent(value));
138✔
508
  }
509
}
510

511
/**
512
 * Build path-level changed leaf events for a reset scope.
513
 * @param {Object} beforeConfig - Effective config before reset
514
 * @param {Object} afterConfig - Effective config after reset
515
 * @param {string|undefined} scopePath - Optional section path scope
516
 * @returns {Array<{path: string, newValue: *, oldValue: *}>}
517
 */
518
function getChangedLeafEvents(beforeConfig, afterConfig, scopePath) {
519
  const scopeParts = scopePath ? scopePath.split('.') : [];
18✔
520
  const beforeScoped = scopePath ? getNestedValue(beforeConfig, scopeParts) : beforeConfig;
18✔
521
  const afterScoped = scopePath ? getNestedValue(afterConfig, scopeParts) : afterConfig;
18✔
522

523
  const beforeLeaves = new Map();
18✔
524
  const afterLeaves = new Map();
18✔
525

526
  if (beforeScoped !== undefined) {
18!
527
    collectLeafValues(beforeScoped, scopePath || '', beforeLeaves);
18✔
528
  }
529
  if (afterScoped !== undefined) {
18!
530
    collectLeafValues(afterScoped, scopePath || '', afterLeaves);
18✔
531
  }
532

533
  const allPaths = new Set([...beforeLeaves.keys(), ...afterLeaves.keys()]);
18✔
534
  const changed = [];
18✔
535

536
  for (const path of allPaths) {
18✔
537
    const oldValue = beforeLeaves.has(path) ? beforeLeaves.get(path) : undefined;
74✔
538
    const newValue = afterLeaves.has(path) ? afterLeaves.get(path) : undefined;
74✔
539
    if (!isDeepStrictEqual(oldValue, newValue)) {
74✔
540
      changed.push({ path, newValue, oldValue });
25✔
541
    }
542
  }
543

544
  return changed;
18✔
545
}
546

547
/**
548
 * Set a config value using dot notation (e.g., "ai.model" or "welcome.enabled")
549
 * Persists to database and updates in-memory cache
550
 * @param {string} path - Dot-notation path (e.g., "ai.model")
551
 * @param {*} value - Value to set (automatically parsed from string)
552
 * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults
553
 * @returns {Promise<Object>} Updated section config
554
 */
555
export async function setConfigValue(path, value, guildId = 'global') {
93✔
556
  const parts = path.split('.');
93✔
557
  if (parts.length < 2) {
93✔
558
    throw new Error('Path must include section and key (e.g., "ai.model")');
1✔
559
  }
560

561
  // Reject dangerous keys to prevent prototype pollution
562
  validatePathSegments(parts);
92✔
563

564
  const section = parts[0];
92✔
565
  const nestedParts = parts.slice(1);
92✔
566
  const parsedVal = parseValue(value);
92✔
567

568
  // Get the current guild entry from cache (or empty object for new guild)
569
  const guildConfig = configCache.get(guildId) || {};
92✔
570

571
  // Deep clone the section for the INSERT case (new section that doesn't exist yet)
572
  const sectionClone = structuredClone(guildConfig[section] || {});
93✔
573
  setNestedValue(sectionClone, nestedParts, parsedVal);
93✔
574

575
  // Write to database first, then update cache.
576
  // Uses a transaction with row lock to prevent concurrent writes from clobbering.
577
  // Reads the current row, applies the change in JS (handles arbitrary nesting),
578
  // then writes back — safe because the row is locked for the duration.
579
  let dbPersisted = false;
93✔
580

581
  // Separate pool acquisition from transaction work so we can distinguish
582
  // "DB not configured" (graceful fallback) from real transaction errors (must surface).
583
  let pool;
584
  try {
93✔
585
    pool = getPool();
93✔
586
  } catch {
587
    // DB not initialized — skip persistence, fall through to in-memory update
588
    logWarn('Database not initialized for config write — updating in-memory only');
86✔
589
  }
590

591
  if (pool) {
89✔
592
    const client = await pool.connect();
3✔
593
    try {
3✔
594
      await client.query('BEGIN');
3✔
595
      // Lock the row (or prepare for INSERT if missing)
596
      const { rows } = await client.query(
3✔
597
        'SELECT value FROM config WHERE guild_id = $1 AND key = $2 FOR UPDATE',
598
        [guildId, section],
599
      );
600

601
      if (rows.length > 0) {
3✔
602
        // Row exists — merge change into the live DB value
603
        const dbSection = rows[0].value;
2✔
604
        setNestedValue(dbSection, nestedParts, parsedVal);
2✔
605

606
        await client.query(
2✔
607
          'UPDATE config SET value = $1, updated_at = NOW() WHERE guild_id = $2 AND key = $3',
608
          [JSON.stringify(dbSection), guildId, section],
609
        );
610
      } else {
611
        // New section — use ON CONFLICT to handle concurrent inserts safely
612
        await client.query(
1✔
613
          'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
614
          [guildId, section, JSON.stringify(sectionClone)],
615
        );
616
      }
617
      await client.query('COMMIT');
2✔
618
      dbPersisted = true;
2✔
619
    } catch (txErr) {
620
      try {
1✔
621
        await client.query('ROLLBACK');
1✔
622
      } catch {
623
        /* ignore rollback failure */
624
      }
625
      throw txErr;
1✔
626
    } finally {
627
      client.release();
3✔
628
    }
629
  }
630

631
  // Ensure guild entry exists in cache
632
  if (!configCache.has(guildId)) {
88✔
633
    configCache.set(guildId, {});
29✔
634
  }
635
  const cacheEntry = configCache.get(guildId);
88✔
636

637
  // Note: oldValue is captured from the guild's override cache, not the effective (merged) value.
638
  // This means listeners see the previous override value (or undefined if no prior override existed),
639
  // not the previous merged value that getConfig(guildId) would have returned.
640
  const rawOld = getNestedValue(cacheEntry[section], nestedParts);
88✔
641
  const oldValue = rawOld !== null && typeof rawOld === 'object' ? structuredClone(rawOld) : rawOld;
88✔
642

643
  // Update in-memory cache (mutate in-place for reference propagation)
644
  if (
93✔
645
    !cacheEntry[section] ||
205✔
646
    typeof cacheEntry[section] !== 'object' ||
647
    Array.isArray(cacheEntry[section])
648
  ) {
649
    cacheEntry[section] = {};
32✔
650
  }
651
  setNestedValue(cacheEntry[section], nestedParts, parsedVal);
88✔
652

653
  // Invalidate merged config cache for this guild (will be rebuilt on next getConfig)
654
  // When global config changes, ALL merged entries are stale (they depend on global)
655
  if (guildId === 'global') {
88✔
656
    mergedConfigCache.clear();
55✔
657
    globalConfigGeneration++;
55✔
658
  } else {
659
    mergedConfigCache.delete(guildId);
33✔
660
  }
661

662
  info('Config updated', { path, value: parsedVal, guildId, persisted: dbPersisted });
88✔
663
  await emitConfigChangeEvents(path, parsedVal, oldValue, guildId);
88✔
664
  return cacheEntry[section];
88✔
665
}
666

667
/**
668
 * Update multiple configuration values atomically.
669
 * @param {Array<{path: string, value: any}>} patches - Array of patches
670
 * @param {string} [guildId='global'] - Guild ID to update, or 'global'
671
 * @returns {Promise<void>}
672
 */
673
export async function setMultipleConfigValues(patches, guildId = 'global') {
×
674
  if (!Array.isArray(patches) || patches.length === 0) return;
×
675

676
  for (const patch of patches) {
×
677
    if (typeof patch.path !== 'string' || patch.path.trim() === '') {
×
678
      throw new Error('Path must be a non-empty string');
×
679
    }
680
    const parts = patch.path.split('.');
×
681
    if (parts.length < 2) {
×
682
      throw new Error('Path must include section and key (e.g., "ai.model")');
×
683
    }
684
    validatePathSegments(parts);
×
685
  }
686

687
  const sectionsToUpdate = new Set();
×
688
  const patchesBySection = new Map();
×
689
  for (const patch of patches) {
×
690
    const parts = patch.path.split('.');
×
691
    const section = parts[0];
×
692
    sectionsToUpdate.add(section);
×
693
    if (!patchesBySection.has(section)) {
×
694
      patchesBySection.set(section, []);
×
695
    }
696
    patchesBySection.get(section).push(patch);
×
697
  }
698

699
  let dbPersisted = false;
×
700
  let pool;
701
  try {
×
702
    pool = getPool();
×
703
  } catch {
704
    throw new Error('Database unavailable for bulk config write');
×
705
  }
706

707
  if (!pool) {
×
708
    throw new Error('Database unavailable for bulk config write');
×
709
  }
710

711
  const client = await pool.connect();
×
712
  const persistedSections = new Map();
×
713
  try {
×
714
    await client.query('BEGIN');
×
715

716
    const sectionList = Array.from(sectionsToUpdate).sort((a, b) => a.localeCompare(b));
×
717

718
    for (const section of sectionList) {
×
719
      const guildConfig = configCache.get(guildId) || {};
×
720
      const sectionClone = structuredClone(guildConfig[section] || {});
×
721

722
      const { rows } = await client.query(
×
723
        'SELECT value FROM config WHERE guild_id = $1 AND key = $2 FOR UPDATE',
724
        [guildId, section],
725
      );
726

727
      const dbSection = rows.length > 0 ? rows[0].value : sectionClone;
×
728

729
      for (const patch of patchesBySection.get(section)) {
×
730
        const parts = patch.path.split('.');
×
731
        const nestedParts = parts.slice(1);
×
732
        const parsedVal = parseValue(patch.value);
×
733
        // API callers validate patch.path via validateConfigPatchBody, which gates
734
        // the top-level key against SAFE_CONFIG_KEYS before any property write.
735
        setNestedValue(dbSection, nestedParts, parsedVal);
×
736
      }
737

738
      persistedSections.set(section, structuredClone(dbSection));
×
739

740
      if (rows.length > 0) {
×
741
        await client.query(
×
742
          'UPDATE config SET value = $1, updated_at = NOW() WHERE guild_id = $2 AND key = $3',
743
          [JSON.stringify(dbSection), guildId, section],
744
        );
745
      } else {
746
        await client.query(
×
747
          'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
748
          [guildId, section, JSON.stringify(dbSection)],
749
        );
750
      }
751
    }
752

753
    await client.query('COMMIT');
×
754
    dbPersisted = true;
×
755
  } catch (txErr) {
756
    try {
×
757
      await client.query('ROLLBACK');
×
758
    } catch {
759
      /* ignore rollback failure */
760
    }
761
    throw txErr;
×
762
  } finally {
763
    client.release();
×
764
  }
765

766
  if (!configCache.has(guildId)) {
×
767
    configCache.set(guildId, {});
×
768
  }
769
  const cacheEntry = configCache.get(guildId);
×
770
  const previousSections = new Map(
×
771
    Array.from(persistedSections.keys(), (section) => [
×
772
      section,
773
      cacheEntry[section] === undefined ? undefined : structuredClone(cacheEntry[section]),
×
774
    ]),
775
  );
776

777
  if (guildId === 'global') {
×
778
    mergedConfigCache.clear();
×
779
    globalConfigGeneration++;
×
780
  } else {
781
    mergedConfigCache.delete(guildId);
×
782
  }
783

784
  for (const [section, dbSection] of persistedSections) {
×
785
    cacheEntry[section] = structuredClone(dbSection);
×
786
  }
787

788
  for (const patch of patches) {
×
789
    const parts = patch.path.split('.');
×
790
    const section = parts[0];
×
791
    const nestedParts = parts.slice(1);
×
792

793
    const rawOld = getNestedValue(previousSections.get(section), nestedParts);
×
794
    const oldValue =
795
      rawOld !== null && typeof rawOld === 'object' ? structuredClone(rawOld) : rawOld;
×
796
    const rawNew = getNestedValue(cacheEntry[section], nestedParts);
×
797
    const newValue =
798
      rawNew !== null && typeof rawNew === 'object' ? structuredClone(rawNew) : rawNew;
×
799

800
    info('Config updated (bulk)', {
×
801
      path: patch.path,
802
      value: isSensitiveConfigPath(patch.path) ? '***' : newValue,
×
803
      guildId,
804
      persisted: dbPersisted,
805
    });
806
    await emitConfigChangeEvents(patch.path, newValue, oldValue, guildId);
×
807
  }
808
}
809

810
/**
811
 * Reset a config section to defaults.
812
 * For global: resets to config.json defaults.
813
 * For guild: deletes guild overrides (falls back to global).
814
 * @param {string} [section] - Section to reset, or all if omitted
815
 * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults
816
 * @returns {Promise<Object>} Reset config (global config object for global, or remaining guild overrides)
817
 */
818
export async function resetConfig(section, guildId = 'global') {
19✔
819
  // Guild reset — just delete overrides
820
  if (guildId !== 'global') {
19✔
821
    const beforeEffective = getConfig(guildId);
7✔
822

823
    let pool = null;
7✔
824
    try {
7✔
825
      pool = getPool();
7✔
826
    } catch {
827
      logWarn('Database unavailable for config reset — updating in-memory only');
7✔
828
    }
829

830
    if (pool) {
7!
831
      try {
×
832
        if (section) {
×
833
          await pool.query('DELETE FROM config WHERE guild_id = $1 AND key = $2', [
×
834
            guildId,
835
            section,
836
          ]);
837
        } else {
838
          await pool.query('DELETE FROM config WHERE guild_id = $1', [guildId]);
×
839
        }
840
      } catch (err) {
841
        logError('Database error during guild config reset — updating in-memory only', {
×
842
          guildId,
843
          section,
844
          error: err.message,
845
        });
846
      }
847
    }
848

849
    const guildConfig = configCache.get(guildId);
7✔
850
    if (guildConfig) {
7!
851
      if (section) {
7✔
852
        delete guildConfig[section];
5✔
853
      } else {
854
        configCache.delete(guildId);
2✔
855
      }
856
    }
857

858
    mergedConfigCache.delete(guildId);
7✔
859

860
    const afterEffective = getConfig(guildId);
7✔
861
    const changedEvents = getChangedLeafEvents(beforeEffective, afterEffective, section);
7✔
862
    for (const { path, newValue, oldValue } of changedEvents) {
7✔
863
      await emitConfigChangeEvents(path, newValue, oldValue, guildId);
9✔
864
    }
865

866
    info('Guild config reset', { guildId, section: section || 'all' });
7✔
867
    return section ? configCache.get(guildId) || {} : {};
7!
868
  }
869

870
  // Global reset — same logic as before, resets to config.json defaults
871
  let fileConfig;
872
  try {
12✔
873
    fileConfig = loadConfigFromFile();
12✔
874
  } catch {
875
    throw new Error(
×
876
      'Cannot reset configuration: config.json is not available. ' +
877
        'Reset requires the default config file as a baseline.',
878
    );
879
  }
880

881
  let pool = null;
12✔
882
  try {
12✔
883
    pool = getPool();
12✔
884
  } catch {
885
    logWarn('Database unavailable for config reset — updating in-memory only');
10✔
886
  }
887

888
  const globalConfig = configCache.get('global') || {};
12!
889
  const beforeGlobal = structuredClone(globalConfig);
19✔
890

891
  if (section) {
19✔
892
    if (!fileConfig[section]) {
7✔
893
      throw new Error(`Section '${section}' not found in config.json defaults`);
1✔
894
    }
895

896
    if (pool) {
6✔
897
      try {
1✔
898
        await pool.query(
1✔
899
          'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
900
          ['global', section, JSON.stringify(fileConfig[section])],
901
        );
902
      } catch (err) {
903
        logError('Database error during section reset — updating in-memory only', {
×
904
          section,
905
          error: err.message,
906
        });
907
      }
908
    }
909

910
    // Mutate in-place so references stay valid (deep clone to avoid shared refs)
911
    const sectionData = globalConfig[section];
6✔
912
    if (sectionData && typeof sectionData === 'object' && !Array.isArray(sectionData)) {
6✔
913
      for (const key of Object.keys(sectionData)) delete sectionData[key];
14✔
914
      Object.assign(sectionData, structuredClone(fileConfig[section]));
5✔
915
    } else {
916
      globalConfig[section] = isPlainObject(fileConfig[section])
1!
917
        ? structuredClone(fileConfig[section])
918
        : fileConfig[section];
919
    }
920
    info('Config section reset', { section });
6✔
921
  } else {
922
    // Reset all inside a transaction
923
    if (pool) {
5✔
924
      const client = await pool.connect();
1✔
925
      try {
1✔
926
        await client.query('BEGIN');
1✔
927
        for (const [key, value] of Object.entries(fileConfig)) {
1✔
928
          await client.query(
2✔
929
            'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
930
            ['global', key, JSON.stringify(value)],
931
          );
932
        }
933
        // Remove stale global keys that exist in DB but not in config.json
934
        const fileKeys = Object.keys(fileConfig);
1✔
935
        if (fileKeys.length > 0) {
1!
936
          await client.query('DELETE FROM config WHERE guild_id = $1 AND key != ALL($2::text[])', [
1✔
937
            'global',
938
            fileKeys,
939
          ]);
940

941
          // Warn about orphaned per-guild rows that reference keys no longer in global defaults
942
          const orphanResult = await client.query(
1✔
943
            'SELECT DISTINCT guild_id, key FROM config WHERE guild_id != $1 AND key != ALL($2::text[])',
944
            ['global', fileKeys],
945
          );
946
          if (orphanResult.rows?.length > 0) {
1!
947
            const orphanSummary = orphanResult.rows.map((r) => `${r.guild_id}:${r.key}`).join(', ');
×
948
            logWarn('Orphaned per-guild config rows reference keys no longer in global defaults', {
×
949
              orphanedEntries: orphanSummary,
950
              count: orphanResult.rows.length,
951
            });
952
          }
953
        }
954
        await client.query('COMMIT');
1✔
955
      } catch (txErr) {
956
        try {
×
957
          await client.query('ROLLBACK');
×
958
        } catch {
959
          /* ignore rollback failure */
960
        }
961
        logError('Database error during full config reset — updating in-memory only', {
×
962
          error: txErr.message,
963
        });
964
      } finally {
965
        client.release();
1✔
966
      }
967
    }
968

969
    // Mutate in-place and remove stale keys from cache (deep clone to avoid shared refs)
970
    for (const key of Object.keys(globalConfig)) {
5✔
971
      if (!(key in fileConfig)) {
13✔
972
        delete globalConfig[key];
1✔
973
      }
974
    }
975
    for (const [key, value] of Object.entries(fileConfig)) {
5✔
976
      if (globalConfig[key] && isPlainObject(globalConfig[key]) && isPlainObject(value)) {
12✔
977
        for (const k of Object.keys(globalConfig[key])) delete globalConfig[key][k];
19✔
978
        Object.assign(globalConfig[key], structuredClone(value));
11✔
979
      } else {
980
        globalConfig[key] = isPlainObject(value) ? structuredClone(value) : value;
1!
981
      }
982
    }
983
    info('All config reset to defaults');
5✔
984
  }
985

986
  // Global config changed — all guild merged entries are stale
987
  mergedConfigCache.clear();
11✔
988
  globalConfigGeneration++;
11✔
989

990
  const changedEvents = getChangedLeafEvents(beforeGlobal, globalConfig, section);
11✔
991
  for (const { path, newValue, oldValue } of changedEvents) {
11✔
992
    await emitConfigChangeEvents(path, newValue, oldValue, 'global');
16✔
993
  }
994

995
  return globalConfig;
11✔
996
}
997

998
/**
999
 * Validate that no path segment is a prototype-pollution vector.
1000
 * @param {string[]} segments - Path segments to check
1001
 * @throws {Error} If any segment is a dangerous key
1002
 */
1003
function validatePathSegments(segments) {
1004
  for (const segment of segments) {
92✔
1005
    if (DANGEROUS_KEYS.has(segment)) {
189✔
1006
      throw new Error(`Invalid config path: '${segment}' is a reserved key and cannot be used`);
3✔
1007
    }
1008
  }
1009
}
1010

1011
/**
1012
 * Traverse a nested object along dot-notation path segments, creating
1013
 * intermediate objects as needed, and set the leaf value.
1014
 * @param {Object} root - Object to traverse
1015
 * @param {string[]} pathParts - Path segments (excluding the root key)
1016
 * @param {*} value - Value to set at the leaf
1017
 */
1018
function setNestedValue(root, pathParts, value) {
1019
  if (pathParts.length === 0) {
179!
1020
    throw new Error('setNestedValue requires at least one path segment');
×
1021
  }
1022
  let current = root;
179✔
1023
  for (let i = 0; i < pathParts.length - 1; i++) {
179✔
1024
    // Defensive: reject prototype-pollution keys even for internal callers
1025
    if (DANGEROUS_KEYS.has(pathParts[i])) {
12!
1026
      throw new Error(`Invalid config path segment: '${pathParts[i]}' is a reserved key`);
×
1027
    }
1028
    if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object') {
12!
1029
      current[pathParts[i]] = {};
12✔
1030
    } else if (Array.isArray(current[pathParts[i]])) {
×
1031
      // Keep arrays intact when the next path segment is a valid numeric index;
1032
      // otherwise replace with a plain object (legacy behaviour for non-numeric keys).
1033
      if (!/^\d+$/.test(pathParts[i + 1])) {
×
1034
        current[pathParts[i]] = {};
×
1035
      }
1036
    }
1037
    current = current[pathParts[i]];
12✔
1038
  }
1039
  const leafKey = pathParts[pathParts.length - 1];
179✔
1040
  if (DANGEROUS_KEYS.has(leafKey)) {
179!
1041
    throw new Error(`Invalid config path segment: '${leafKey}' is a reserved key`);
×
1042
  }
1043
  current[leafKey] = value;
179✔
1044
}
1045

1046
/**
1047
 * Check if a value is a plain object (not null, not array)
1048
 * @param {*} val - Value to check
1049
 * @returns {boolean} True if plain object
1050
 */
1051
function isPlainObject(val) {
1052
  return typeof val === 'object' && val !== null && !Array.isArray(val);
414✔
1053
}
1054

1055
/**
1056
 * Parse a string value into its appropriate JS type.
1057
 *
1058
 * Coercion rules:
1059
 * - "true" / "false" → boolean
1060
 * - "null" → null
1061
 * - Numeric strings → number (unless beyond Number.MAX_SAFE_INTEGER)
1062
 * - JSON arrays/objects → parsed value
1063
 * - Everything else → kept as-is string
1064
 *
1065
 * To force a literal string (e.g. the word "true"), wrap it in JSON quotes:
1066
 *   "\"true\"" → parsed by JSON.parse into the string "true"
1067
 *
1068
 * @param {string} value - String value to parse
1069
 * @returns {*} Parsed value
1070
 */
1071
function isAsciiDigit(char) {
1072
  return char >= '0' && char <= '9';
130✔
1073
}
1074

1075
function isDecimalNumberLiteral(value) {
1076
  if (!value) return false;
84!
1077

1078
  let index = value[0] === '-' ? 1 : 0;
84✔
1079
  let digits = 0;
84✔
1080

1081
  while (index < value.length && isAsciiDigit(value[index])) {
84✔
1082
    digits += 1;
56✔
1083
    index += 1;
56✔
1084
  }
1085

1086
  if (value[index] === '.') {
84✔
1087
    index += 1;
5✔
1088
    while (index < value.length && isAsciiDigit(value[index])) {
5✔
1089
      digits += 1;
4✔
1090
      index += 1;
4✔
1091
    }
1092
  }
1093

1094
  return digits > 0 && index === value.length;
84✔
1095
}
1096

1097
function parseValue(value) {
1098
  if (typeof value !== 'string') return value;
89✔
1099

1100
  // Booleans
1101
  if (value === 'true') return true;
88✔
1102
  if (value === 'false') return false;
87✔
1103

1104
  // Null
1105
  if (value === 'null') return null;
85✔
1106

1107
  // Numbers (keep as string if beyond safe integer range to avoid precision loss)
1108
  // Matches: 123, -123, 1.5, -1.5, 1., .5, -.5
1109
  if (isDecimalNumberLiteral(value)) {
84✔
1110
    const num = Number(value);
18✔
1111
    if (!Number.isFinite(num)) return value;
18!
1112
    if (!value.includes('.') && !Number.isSafeInteger(num)) return value;
18✔
1113
    return num;
17✔
1114
  }
1115

1116
  // JSON strings (e.g. "\"true\"" → force literal string "true"), arrays, and objects
1117
  if (
66✔
1118
    (value.startsWith('"') && value.endsWith('"')) ||
205✔
1119
    (value.startsWith('[') && value.endsWith(']')) ||
1120
    (value.startsWith('{') && value.endsWith('}'))
1121
  ) {
1122
    try {
9✔
1123
      return JSON.parse(value);
9✔
1124
    } catch {
1125
      return value;
×
1126
    }
1127
  }
1128

1129
  // Plain string
1130
  return value;
57✔
1131
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc