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

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 hits per line

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

86.92
/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));
99✔
16
const configPath = join(__dirname, '..', '..', 'config.json');
99✔
17

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

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

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

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

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

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

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

72
    if (isPlainObject(target[key]) && isPlainObject(source[key])) {
67✔
73
      deepMerge(target[key], source[key]);
34✔
74
    } else {
75
      target[key] = structuredClone(source[key]);
33✔
76
    }
77
  }
78
  return target;
68✔
79
}
80

81
/**
82
 * Load config.json from disk (used as seed/fallback).
83
 *
84
 * Security note: config.json integrity is a deployment concern — the file is
85
 * read-only at runtime and is not validated beyond JSON parsing. Deployers
86
 * must ensure the file is not writable by untrusted processes.
87
 *
88
 * @returns {Object} Configuration object from file
89
 * @throws {Error} If config.json is missing or unparseable
90
 */
91
export function loadConfigFromFile() {
92
  if (fileConfigCache) return fileConfigCache;
94✔
93

94
  if (!existsSync(configPath)) {
81✔
95
    const err = new Error('config.json not found!');
1✔
96
    err.code = 'CONFIG_NOT_FOUND';
1✔
97
    throw err;
1✔
98
  }
99
  try {
80✔
100
    fileConfigCache = JSON.parse(readFileSync(configPath, 'utf-8'));
80✔
101
    return fileConfigCache;
80✔
102
  } catch (err) {
103
    throw new Error(`Failed to load config.json: ${err.message}`);
1✔
104
  }
105
}
106

107
/**
108
 * Load config from PostgreSQL, seeding from config.json if empty
109
 * Falls back to config.json if database is unavailable
110
 * @returns {Promise<Object>} Global configuration object (for backward compat)
111
 */
112
export async function loadConfig() {
113
  // Clear stale merged cache — configCache is about to be rebuilt, so any
114
  // previously merged guild snapshots are invalid.
115
  mergedConfigCache.clear();
79✔
116
  globalConfigGeneration++;
79✔
117

118
  // Try loading config.json — DB may have valid config even if file is missing
119
  let fileConfig;
120
  try {
79✔
121
    fileConfig = loadConfigFromFile();
79✔
122
  } catch {
123
    fileConfig = null;
×
UNCOV
124
    info('config.json not available, will rely on database for configuration');
×
125
  }
126

127
  try {
79✔
128
    let pool;
129
    try {
79✔
130
      pool = getPool();
79✔
131
    } catch {
132
      // DB not initialized — file config is our only option
133
      if (!fileConfig) {
66!
UNCOV
134
        throw new Error(
×
135
          'No configuration source available: config.json is missing and database is not initialized',
136
        );
137
      }
138
      info('Database not available, using config.json');
66✔
139
      configCache = new Map();
66✔
140
      configCache.set('global', structuredClone(fileConfig));
66✔
141
      return configCache.get('global');
66✔
142
    }
143

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

149
    // Separate global rows from guild-specific rows.
150
    // Treat rows with missing/undefined guild_id as 'global' (handles unmigrated DBs).
151
    const globalRows = rows.filter((r) => !r.guild_id || r.guild_id === 'global');
17✔
152
    const guildRows = rows.filter((r) => r.guild_id && r.guild_id !== 'global');
17✔
153

154
    if (globalRows.length === 0) {
12✔
155
      if (!fileConfig) {
3!
UNCOV
156
        throw new Error(
×
157
          'No configuration source available: database is empty and config.json is missing',
158
        );
159
      }
160
      // Seed database from config.json inside a transaction
161
      info('No config in database, seeding from config.json');
3✔
162
      const client = await pool.connect();
3✔
163
      try {
3✔
164
        await client.query('BEGIN');
3✔
165
        for (const [key, value] of Object.entries(fileConfig)) {
3✔
166
          await client.query(
5✔
167
            'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
168
            ['global', key, JSON.stringify(value)],
169
          );
170
        }
171
        await client.query('COMMIT');
2✔
172
        info('Config seeded to database');
2✔
173
        configCache = new Map();
2✔
174
        configCache.set('global', structuredClone(fileConfig));
2✔
175

176
        // Load any preexisting guild overrides that were already in the DB.
177
        // Without this, guild rows fetched above would be silently dropped.
178
        for (const row of guildRows) {
2✔
179
          if (DANGEROUS_KEYS.has(row.key)) {
1!
UNCOV
180
            logWarn('Skipping dangerous config key from database', {
×
181
              key: row.key,
182
              guildId: row.guild_id,
183
            });
UNCOV
184
            continue;
×
185
          }
186

187
          if (!configCache.has(row.guild_id)) {
1!
188
            configCache.set(row.guild_id, {});
1✔
189
          }
190
          configCache.get(row.guild_id)[row.key] = row.value;
1✔
191
        }
192
        if (guildRows.length > 0) {
2✔
193
          info('Loaded guild overrides during seed', {
1✔
194
            guildCount: new Set(guildRows.map((r) => r.guild_id)).size,
1✔
195
          });
196
        }
197
      } catch (txErr) {
198
        try {
1✔
199
          await client.query('ROLLBACK');
1✔
200
        } catch {
201
          /* ignore rollback failure */
202
        }
203
        throw txErr;
1✔
204
      } finally {
205
        client.release();
3✔
206
      }
207
    } else {
208
      // Build config map from database rows
209
      configCache = new Map();
9✔
210

211
      // Build global config
212
      const globalConfig = {};
9✔
213
      for (const row of globalRows) {
9✔
214
        if (DANGEROUS_KEYS.has(row.key)) {
13!
UNCOV
215
          logWarn('Skipping dangerous config key from database', {
×
216
            key: row.key,
217
            guildId: row.guild_id,
218
          });
UNCOV
219
          continue;
×
220
        }
221

222
        globalConfig[row.key] = row.value;
13✔
223
      }
224
      configCache.set('global', globalConfig);
9✔
225

226
      // Build per-guild configs (overrides only)
227
      for (const row of guildRows) {
9✔
228
        if (DANGEROUS_KEYS.has(row.key)) {
3!
UNCOV
229
          logWarn('Skipping dangerous config key from database', {
×
230
            key: row.key,
231
            guildId: row.guild_id,
232
          });
UNCOV
233
          continue;
×
234
        }
235

236
        if (!configCache.has(row.guild_id)) {
3!
237
          configCache.set(row.guild_id, {});
3✔
238
        }
239
        configCache.get(row.guild_id)[row.key] = row.value;
3✔
240
      }
241

242
      info('Config loaded from database', {
9✔
243
        globalKeys: globalRows.length,
244
        guildCount: new Set(guildRows.map((r) => r.guild_id)).size,
3✔
245
      });
246
    }
247
  } catch (err) {
248
    if (!fileConfig) {
2!
249
      // No fallback available — re-throw
UNCOV
250
      throw err;
×
251
    }
252
    logError('Failed to load config from database, using config.json', { error: err.message });
2✔
253
    configCache = new Map();
2✔
254
    configCache.set('global', structuredClone(fileConfig));
2✔
255
  }
256

257
  return configCache.get('global');
13✔
258
}
259

260
/**
261
 * Get the current config (from cache).
262
 *
263
 * **Return semantics differ by path (intentional):**
264
 * - **Global path** (no guildId or guildId='global'): Returns a LIVE MUTABLE reference
265
 *   to the cached global config object. Mutations are visible to all subsequent callers.
266
 *   This is intentional for backward compatibility — existing code relies on mutating the
267
 *   returned object and having changes propagate.
268
 * - **Guild path** (guildId provided): Returns a deep-cloned merged copy of global defaults
269
 *   + guild overrides. Each call returns a fresh object; mutations do NOT affect the cache.
270
 *   This prevents cross-guild contamination.
271
 *
272
 * **⚠️ IMPORTANT: In-place mutation caveat:**
273
 * Direct mutation of the global config object (e.g. `getConfig().ai.model = "new"`) does
274
 * NOT invalidate `mergedConfigCache` or bump `globalConfigGeneration`. Guild-specific calls
275
 * to `getConfig(guildId)` may return stale merged data that still reflects the old global
276
 * defaults until the merged cache entry expires or is rebuilt. Use `setConfigValue()` for
277
 * proper cache invalidation. This asymmetry is intentional for backward compatibility with
278
 * legacy code that relies on mutating the returned global reference.
279
 *
280
 * @param {string} [guildId] - Guild ID, or omit / 'global' for global defaults
281
 * @returns {Object} Configuration object (live reference for global, cloned copy for guild)
282
 */
283
export function getConfig(guildId) {
284
  if (!guildId || guildId === 'global') {
118✔
285
    // ⚠️ Returns live cache reference — callers must NOT mutate the returned object
286
    return configCache.get('global') || {};
68✔
287
  }
288

289
  // Return clone from cached merged result if available and still valid.
290
  // Entries are stamped with the globalConfigGeneration at merge time —
291
  // if global config changed since then, the entry is stale and must be rebuilt.
292
  const cached = mergedConfigCache.get(guildId);
50✔
293
  if (cached && cached.generation === globalConfigGeneration) {
50✔
294
    // Refresh access order for LRU tracking (Maps preserve insertion order)
295
    mergedConfigCache.delete(guildId);
7✔
296
    mergedConfigCache.set(guildId, cached);
7✔
297
    // Guild path: returns deep clone to prevent cross-guild contamination (see JSDoc above)
298
    return structuredClone(cached.data);
7✔
299
  }
300

301
  const globalConfig = configCache.get('global') || {};
43!
302
  const guildOverrides = configCache.get(guildId);
118✔
303

304
  if (!guildOverrides) {
118✔
305
    // Cache a reference to global defaults and return a detached clone.
306
    // This avoids an extra clone on cache-miss while preserving isolation for callers.
307
    cacheMergedResult(guildId, globalConfig);
9✔
308
    return structuredClone(globalConfig);
9✔
309
  }
310

311
  const merged = deepMerge(structuredClone(globalConfig), guildOverrides);
34✔
312
  cacheMergedResult(guildId, merged);
34✔
313
  return structuredClone(merged);
34✔
314
}
315

316
/**
317
 * Store a merged config result and enforce the LRU guild cache cap.
318
 * Evicts the least-recently-used guild entries when the cap is exceeded.
319
 * @param {string} guildId - Guild ID
320
 * @param {Object} merged - Merged config object
321
 */
322
function cacheMergedResult(guildId, merged) {
323
  mergedConfigCache.set(guildId, { generation: globalConfigGeneration, data: merged });
43✔
324

325
  // Evict least-recently-used guild entries when cap is exceeded
326
  if (mergedConfigCache.size > MAX_GUILD_CACHE_SIZE) {
43!
327
    const firstKey = mergedConfigCache.keys().next().value;
×
UNCOV
328
    mergedConfigCache.delete(firstKey);
×
329
  }
330
}
331

332
/**
333
 * Traverse a nested object along dot-notation path segments and return the value.
334
 * Returns undefined if any intermediate key is missing.
335
 * @param {Object} obj - Object to traverse
336
 * @param {string[]} pathParts - Path segments
337
 * @returns {*} Value at the path, or undefined
338
 */
339
function getNestedValue(obj, pathParts) {
340
  let current = obj;
100✔
341
  for (const part of pathParts) {
100✔
342
    if (current == null || typeof current !== 'object') return undefined;
103✔
343
    current = current[part];
69✔
344
  }
345
  return current;
66✔
346
}
347

348
/**
349
 * Register a listener for config changes.
350
 * Use exact paths (e.g. "ai.model") or prefix wildcards (e.g. "ai.*").
351
 * @param {string} pathOrPrefix - Dot-notation path or prefix with wildcard
352
 * @param {Function} callback - Called with (newValue, oldValue, fullPath, guildId)
353
 */
354
/**
355
 * Get all guild IDs that have config entries (including 'global').
356
 * Used by webhook notifier to fire bot-level events to all guilds.
357
 *
358
 * @returns {string[]} Array of guild IDs (excluding 'global')
359
 */
360
export function getAllGuildIds() {
UNCOV
361
  return [...configCache.keys()].filter((id) => id !== 'global');
×
362
}
363

364
export function onConfigChange(pathOrPrefix, callback) {
365
  listeners.push({ path: pathOrPrefix, callback });
33✔
366
}
367

368
/**
369
 * Remove a previously registered config change listener.
370
 * @param {string} pathOrPrefix - Same path used in onConfigChange
371
 * @param {Function} callback - Same callback reference used in onConfigChange
372
 */
373
export function offConfigChange(pathOrPrefix, callback) {
374
  const idx = listeners.findIndex((l) => l.path === pathOrPrefix && l.callback === callback);
4✔
375
  if (idx !== -1) listeners.splice(idx, 1);
3!
376
}
377

378
/**
379
 * Remove all registered config change listeners.
380
 */
381
export function clearConfigListeners() {
382
  listeners.length = 0;
48✔
383
}
384

385
/**
386
 * Emit config change events to matching listeners.
387
 * Matches exact paths and prefix wildcards (e.g. "ai.*" matches "ai.model").
388
 * @param {string} fullPath - The full dot-notation path that changed
389
 * @param {*} newValue - The new value
390
 * @param {*} oldValue - The previous value
391
 * @param {string} guildId - The guild ID that changed ('global' for global)
392
 */
393
async function emitConfigChangeEvents(fullPath, newValue, oldValue, guildId) {
394
  for (const listener of [...listeners]) {
103✔
395
    const isExact = listener.path === fullPath;
34✔
396
    const isPrefix =
397
      !isExact &&
34✔
398
      listener.path.endsWith('.*') &&
399
      fullPath.startsWith(listener.path.replace(/\.\*$/, '.'));
400
    const isWildcard = listener.path === '*';
34✔
401
    if (isExact || isPrefix || isWildcard) {
34✔
402
      try {
29✔
403
        const result = listener.callback(newValue, oldValue, fullPath, guildId);
29✔
404
        if (result && typeof result.then === 'function') {
29✔
405
          await result.catch((err) => {
1✔
406
            logWarn('Async config change listener error', {
1✔
407
              path: fullPath,
408
              error: String(err?.message || err),
1!
409
            });
410
          });
411
        }
412
      } catch (err) {
413
        logError('Config change listener error', {
1✔
414
          path: fullPath,
415
          error: String(err?.message || err),
1!
416
        });
417
      }
418
    }
419
  }
420
}
421

422
/**
423
 * Clone a value for safe event payload emission.
424
 * @param {*} value - Value to clone when object-like
425
 * @returns {*}
426
 */
427
function cloneForEvent(value) {
428
  return value !== null && typeof value === 'object' ? structuredClone(value) : value;
138!
429
}
430

431
/**
432
 * Collect leaf values from an object into a dot-notation map.
433
 * Plain-object leaves are flattened; arrays and primitives are treated as terminal values.
434
 * @param {*} value - Root value
435
 * @param {string} prefix - Current dot-notation prefix
436
 * @param {Map<string, *>} out - Output map
437
 */
438
function collectLeafValues(value, prefix, out) {
439
  if (isPlainObject(value)) {
233✔
440
    for (const key of Object.keys(value)) {
95✔
441
      if (DANGEROUS_KEYS.has(key)) continue;
200✔
442
      const path = prefix ? `${prefix}.${key}` : key;
197✔
443
      collectLeafValues(value[key], path, out);
200✔
444
    }
445
    return;
95✔
446
  }
447

448
  if (prefix) {
138!
449
    out.set(prefix, cloneForEvent(value));
138✔
450
  }
451
}
452

453
/**
454
 * Build path-level changed leaf events for a reset scope.
455
 * @param {Object} beforeConfig - Effective config before reset
456
 * @param {Object} afterConfig - Effective config after reset
457
 * @param {string|undefined} scopePath - Optional section path scope
458
 * @returns {Array<{path: string, newValue: *, oldValue: *}>}
459
 */
460
function getChangedLeafEvents(beforeConfig, afterConfig, scopePath) {
461
  const scopeParts = scopePath ? scopePath.split('.') : [];
18✔
462
  const beforeScoped = scopePath ? getNestedValue(beforeConfig, scopeParts) : beforeConfig;
18✔
463
  const afterScoped = scopePath ? getNestedValue(afterConfig, scopeParts) : afterConfig;
18✔
464

465
  const beforeLeaves = new Map();
18✔
466
  const afterLeaves = new Map();
18✔
467

468
  if (beforeScoped !== undefined) {
18!
469
    collectLeafValues(beforeScoped, scopePath || '', beforeLeaves);
18✔
470
  }
471
  if (afterScoped !== undefined) {
18!
472
    collectLeafValues(afterScoped, scopePath || '', afterLeaves);
18✔
473
  }
474

475
  const allPaths = new Set([...beforeLeaves.keys(), ...afterLeaves.keys()]);
18✔
476
  const changed = [];
18✔
477

478
  for (const path of allPaths) {
18✔
479
    const oldValue = beforeLeaves.has(path) ? beforeLeaves.get(path) : undefined;
74✔
480
    const newValue = afterLeaves.has(path) ? afterLeaves.get(path) : undefined;
74✔
481
    if (!isDeepStrictEqual(oldValue, newValue)) {
74✔
482
      changed.push({ path, newValue, oldValue });
25✔
483
    }
484
  }
485

486
  return changed;
18✔
487
}
488

489
/**
490
 * Set a config value using dot notation (e.g., "ai.model" or "welcome.enabled")
491
 * Persists to database and updates in-memory cache
492
 * @param {string} path - Dot-notation path (e.g., "ai.model")
493
 * @param {*} value - Value to set (automatically parsed from string)
494
 * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults
495
 * @returns {Promise<Object>} Updated section config
496
 */
497
export async function setConfigValue(path, value, guildId = 'global') {
83✔
498
  const parts = path.split('.');
83✔
499
  if (parts.length < 2) {
83✔
500
    throw new Error('Path must include section and key (e.g., "ai.model")');
1✔
501
  }
502

503
  // Reject dangerous keys to prevent prototype pollution
504
  validatePathSegments(parts);
82✔
505

506
  const section = parts[0];
82✔
507
  const nestedParts = parts.slice(1);
82✔
508
  const parsedVal = parseValue(value);
82✔
509

510
  // Get the current guild entry from cache (or empty object for new guild)
511
  const guildConfig = configCache.get(guildId) || {};
82✔
512

513
  // Deep clone the section for the INSERT case (new section that doesn't exist yet)
514
  const sectionClone = structuredClone(guildConfig[section] || {});
83✔
515
  setNestedValue(sectionClone, nestedParts, parsedVal);
83✔
516

517
  // Write to database first, then update cache.
518
  // Uses a transaction with row lock to prevent concurrent writes from clobbering.
519
  // Reads the current row, applies the change in JS (handles arbitrary nesting),
520
  // then writes back — safe because the row is locked for the duration.
521
  let dbPersisted = false;
83✔
522

523
  // Separate pool acquisition from transaction work so we can distinguish
524
  // "DB not configured" (graceful fallback) from real transaction errors (must surface).
525
  let pool;
526
  try {
83✔
527
    pool = getPool();
83✔
528
  } catch {
529
    // DB not initialized — skip persistence, fall through to in-memory update
530
    logWarn('Database not initialized for config write — updating in-memory only');
76✔
531
  }
532

533
  if (pool) {
79✔
534
    const client = await pool.connect();
3✔
535
    try {
3✔
536
      await client.query('BEGIN');
3✔
537
      // Lock the row (or prepare for INSERT if missing)
538
      const { rows } = await client.query(
3✔
539
        'SELECT value FROM config WHERE guild_id = $1 AND key = $2 FOR UPDATE',
540
        [guildId, section],
541
      );
542

543
      if (rows.length > 0) {
3✔
544
        // Row exists — merge change into the live DB value
545
        const dbSection = rows[0].value;
2✔
546
        setNestedValue(dbSection, nestedParts, parsedVal);
2✔
547

548
        await client.query(
2✔
549
          'UPDATE config SET value = $1, updated_at = NOW() WHERE guild_id = $2 AND key = $3',
550
          [JSON.stringify(dbSection), guildId, section],
551
        );
552
      } else {
553
        // New section — use ON CONFLICT to handle concurrent inserts safely
554
        await client.query(
1✔
555
          'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
556
          [guildId, section, JSON.stringify(sectionClone)],
557
        );
558
      }
559
      await client.query('COMMIT');
2✔
560
      dbPersisted = true;
2✔
561
    } catch (txErr) {
562
      try {
1✔
563
        await client.query('ROLLBACK');
1✔
564
      } catch {
565
        /* ignore rollback failure */
566
      }
567
      throw txErr;
1✔
568
    } finally {
569
      client.release();
3✔
570
    }
571
  }
572

573
  // Ensure guild entry exists in cache
574
  if (!configCache.has(guildId)) {
78✔
575
    configCache.set(guildId, {});
29✔
576
  }
577
  const cacheEntry = configCache.get(guildId);
78✔
578

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

585
  // Update in-memory cache (mutate in-place for reference propagation)
586
  if (
83✔
587
    !cacheEntry[section] ||
177✔
588
    typeof cacheEntry[section] !== 'object' ||
589
    Array.isArray(cacheEntry[section])
590
  ) {
591
    cacheEntry[section] = {};
31✔
592
  }
593
  setNestedValue(cacheEntry[section], nestedParts, parsedVal);
78✔
594

595
  // Invalidate merged config cache for this guild (will be rebuilt on next getConfig)
596
  // When global config changes, ALL merged entries are stale (they depend on global)
597
  if (guildId === 'global') {
78✔
598
    mergedConfigCache.clear();
45✔
599
    globalConfigGeneration++;
45✔
600
  } else {
601
    mergedConfigCache.delete(guildId);
33✔
602
  }
603

604
  info('Config updated', { path, value: parsedVal, guildId, persisted: dbPersisted });
78✔
605
  await emitConfigChangeEvents(path, parsedVal, oldValue, guildId);
78✔
606
  return cacheEntry[section];
78✔
607
}
608

609
/**
610
 * Reset a config section to defaults.
611
 * For global: resets to config.json defaults.
612
 * For guild: deletes guild overrides (falls back to global).
613
 * @param {string} [section] - Section to reset, or all if omitted
614
 * @param {string} [guildId='global'] - Guild ID, or 'global' for global defaults
615
 * @returns {Promise<Object>} Reset config (global config object for global, or remaining guild overrides)
616
 */
617
export async function resetConfig(section, guildId = 'global') {
19✔
618
  // Guild reset — just delete overrides
619
  if (guildId !== 'global') {
19✔
620
    const beforeEffective = getConfig(guildId);
7✔
621

622
    let pool = null;
7✔
623
    try {
7✔
624
      pool = getPool();
7✔
625
    } catch {
626
      logWarn('Database unavailable for config reset — updating in-memory only');
7✔
627
    }
628

629
    if (pool) {
7!
UNCOV
630
      try {
×
UNCOV
631
        if (section) {
×
UNCOV
632
          await pool.query('DELETE FROM config WHERE guild_id = $1 AND key = $2', [
×
633
            guildId,
634
            section,
635
          ]);
636
        } else {
UNCOV
637
          await pool.query('DELETE FROM config WHERE guild_id = $1', [guildId]);
×
638
        }
639
      } catch (err) {
UNCOV
640
        logError('Database error during guild config reset — updating in-memory only', {
×
641
          guildId,
642
          section,
643
          error: err.message,
644
        });
645
      }
646
    }
647

648
    const guildConfig = configCache.get(guildId);
7✔
649
    if (guildConfig) {
7!
650
      if (section) {
7✔
651
        delete guildConfig[section];
5✔
652
      } else {
653
        configCache.delete(guildId);
2✔
654
      }
655
    }
656

657
    mergedConfigCache.delete(guildId);
7✔
658

659
    const afterEffective = getConfig(guildId);
7✔
660
    const changedEvents = getChangedLeafEvents(beforeEffective, afterEffective, section);
7✔
661
    for (const { path, newValue, oldValue } of changedEvents) {
7✔
662
      await emitConfigChangeEvents(path, newValue, oldValue, guildId);
9✔
663
    }
664

665
    info('Guild config reset', { guildId, section: section || 'all' });
7✔
666
    return section ? configCache.get(guildId) || {} : {};
7!
667
  }
668

669
  // Global reset — same logic as before, resets to config.json defaults
670
  let fileConfig;
671
  try {
12✔
672
    fileConfig = loadConfigFromFile();
12✔
673
  } catch {
UNCOV
674
    throw new Error(
×
675
      'Cannot reset configuration: config.json is not available. ' +
676
        'Reset requires the default config file as a baseline.',
677
    );
678
  }
679

680
  let pool = null;
12✔
681
  try {
12✔
682
    pool = getPool();
12✔
683
  } catch {
684
    logWarn('Database unavailable for config reset — updating in-memory only');
10✔
685
  }
686

687
  const globalConfig = configCache.get('global') || {};
12!
688
  const beforeGlobal = structuredClone(globalConfig);
19✔
689

690
  if (section) {
19✔
691
    if (!fileConfig[section]) {
7✔
692
      throw new Error(`Section '${section}' not found in config.json defaults`);
1✔
693
    }
694

695
    if (pool) {
6✔
696
      try {
1✔
697
        await pool.query(
1✔
698
          'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
699
          ['global', section, JSON.stringify(fileConfig[section])],
700
        );
701
      } catch (err) {
UNCOV
702
        logError('Database error during section reset — updating in-memory only', {
×
703
          section,
704
          error: err.message,
705
        });
706
      }
707
    }
708

709
    // Mutate in-place so references stay valid (deep clone to avoid shared refs)
710
    const sectionData = globalConfig[section];
6✔
711
    if (sectionData && typeof sectionData === 'object' && !Array.isArray(sectionData)) {
6✔
712
      for (const key of Object.keys(sectionData)) delete sectionData[key];
14✔
713
      Object.assign(sectionData, structuredClone(fileConfig[section]));
5✔
714
    } else {
715
      globalConfig[section] = isPlainObject(fileConfig[section])
1!
716
        ? structuredClone(fileConfig[section])
717
        : fileConfig[section];
718
    }
719
    info('Config section reset', { section });
6✔
720
  } else {
721
    // Reset all inside a transaction
722
    if (pool) {
5✔
723
      const client = await pool.connect();
1✔
724
      try {
1✔
725
        await client.query('BEGIN');
1✔
726
        for (const [key, value] of Object.entries(fileConfig)) {
1✔
727
          await client.query(
2✔
728
            'INSERT INTO config (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (guild_id, key) DO UPDATE SET value = $3, updated_at = NOW()',
729
            ['global', key, JSON.stringify(value)],
730
          );
731
        }
732
        // Remove stale global keys that exist in DB but not in config.json
733
        const fileKeys = Object.keys(fileConfig);
1✔
734
        if (fileKeys.length > 0) {
1!
735
          await client.query('DELETE FROM config WHERE guild_id = $1 AND key != ALL($2::text[])', [
1✔
736
            'global',
737
            fileKeys,
738
          ]);
739

740
          // Warn about orphaned per-guild rows that reference keys no longer in global defaults
741
          const orphanResult = await client.query(
1✔
742
            'SELECT DISTINCT guild_id, key FROM config WHERE guild_id != $1 AND key != ALL($2::text[])',
743
            ['global', fileKeys],
744
          );
745
          if (orphanResult.rows?.length > 0) {
1!
UNCOV
746
            const orphanSummary = orphanResult.rows.map((r) => `${r.guild_id}:${r.key}`).join(', ');
×
UNCOV
747
            logWarn('Orphaned per-guild config rows reference keys no longer in global defaults', {
×
748
              orphanedEntries: orphanSummary,
749
              count: orphanResult.rows.length,
750
            });
751
          }
752
        }
753
        await client.query('COMMIT');
1✔
754
      } catch (txErr) {
UNCOV
755
        try {
×
UNCOV
756
          await client.query('ROLLBACK');
×
757
        } catch {
758
          /* ignore rollback failure */
759
        }
UNCOV
760
        logError('Database error during full config reset — updating in-memory only', {
×
761
          error: txErr.message,
762
        });
763
      } finally {
764
        client.release();
1✔
765
      }
766
    }
767

768
    // Mutate in-place and remove stale keys from cache (deep clone to avoid shared refs)
769
    for (const key of Object.keys(globalConfig)) {
5✔
770
      if (!(key in fileConfig)) {
13✔
771
        delete globalConfig[key];
1✔
772
      }
773
    }
774
    for (const [key, value] of Object.entries(fileConfig)) {
5✔
775
      if (globalConfig[key] && isPlainObject(globalConfig[key]) && isPlainObject(value)) {
12✔
776
        for (const k of Object.keys(globalConfig[key])) delete globalConfig[key][k];
19✔
777
        Object.assign(globalConfig[key], structuredClone(value));
11✔
778
      } else {
779
        globalConfig[key] = isPlainObject(value) ? structuredClone(value) : value;
1!
780
      }
781
    }
782
    info('All config reset to defaults');
5✔
783
  }
784

785
  // Global config changed — all guild merged entries are stale
786
  mergedConfigCache.clear();
11✔
787
  globalConfigGeneration++;
11✔
788

789
  const changedEvents = getChangedLeafEvents(beforeGlobal, globalConfig, section);
11✔
790
  for (const { path, newValue, oldValue } of changedEvents) {
11✔
791
    await emitConfigChangeEvents(path, newValue, oldValue, 'global');
16✔
792
  }
793

794
  return globalConfig;
11✔
795
}
796

797
/**
798
 * Validate that no path segment is a prototype-pollution vector.
799
 * @param {string[]} segments - Path segments to check
800
 * @throws {Error} If any segment is a dangerous key
801
 */
802
function validatePathSegments(segments) {
803
  for (const segment of segments) {
82✔
804
    if (DANGEROUS_KEYS.has(segment)) {
172✔
805
      throw new Error(`Invalid config path: '${segment}' is a reserved key and cannot be used`);
3✔
806
    }
807
  }
808
}
809

810
/**
811
 * Traverse a nested object along dot-notation path segments, creating
812
 * intermediate objects as needed, and set the leaf value.
813
 * @param {Object} root - Object to traverse
814
 * @param {string[]} pathParts - Path segments (excluding the root key)
815
 * @param {*} value - Value to set at the leaf
816
 */
817
function setNestedValue(root, pathParts, value) {
818
  if (pathParts.length === 0) {
159!
UNCOV
819
    throw new Error('setNestedValue requires at least one path segment');
×
820
  }
821
  let current = root;
159✔
822
  for (let i = 0; i < pathParts.length - 1; i++) {
159✔
823
    // Defensive: reject prototype-pollution keys even for internal callers
824
    if (DANGEROUS_KEYS.has(pathParts[i])) {
18!
UNCOV
825
      throw new Error(`Invalid config path segment: '${pathParts[i]}' is a reserved key`);
×
826
    }
827
    if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object') {
18!
828
      current[pathParts[i]] = {};
18✔
UNCOV
829
    } else if (Array.isArray(current[pathParts[i]])) {
×
830
      // Keep arrays intact when the next path segment is a valid numeric index;
831
      // otherwise replace with a plain object (legacy behaviour for non-numeric keys).
UNCOV
832
      if (!/^\d+$/.test(pathParts[i + 1])) {
×
UNCOV
833
        current[pathParts[i]] = {};
×
834
      }
835
    }
836
    current = current[pathParts[i]];
18✔
837
  }
838
  const leafKey = pathParts[pathParts.length - 1];
159✔
839
  if (DANGEROUS_KEYS.has(leafKey)) {
159!
UNCOV
840
    throw new Error(`Invalid config path segment: '${leafKey}' is a reserved key`);
×
841
  }
842
  current[leafKey] = value;
159✔
843
}
844

845
/**
846
 * Check if a value is a plain object (not null, not array)
847
 * @param {*} val - Value to check
848
 * @returns {boolean} True if plain object
849
 */
850
function isPlainObject(val) {
851
  return typeof val === 'object' && val !== null && !Array.isArray(val);
359✔
852
}
853

854
/**
855
 * Parse a string value into its appropriate JS type.
856
 *
857
 * Coercion rules:
858
 * - "true" / "false" → boolean
859
 * - "null" → null
860
 * - Numeric strings → number (unless beyond Number.MAX_SAFE_INTEGER)
861
 * - JSON arrays/objects → parsed value
862
 * - Everything else → kept as-is string
863
 *
864
 * To force a literal string (e.g. the word "true"), wrap it in JSON quotes:
865
 *   "\"true\"" → parsed by JSON.parse into the string "true"
866
 *
867
 * @param {string} value - String value to parse
868
 * @returns {*} Parsed value
869
 */
870
function parseValue(value) {
871
  if (typeof value !== 'string') return value;
79✔
872

873
  // Booleans
874
  if (value === 'true') return true;
78✔
875
  if (value === 'false') return false;
74✔
876

877
  // Null
878
  if (value === 'null') return null;
72✔
879

880
  // Numbers (keep as string if beyond safe integer range to avoid precision loss)
881
  // Matches: 123, -123, 1.5, -1.5, 1., .5, -.5
882
  if (/^-?(\d+\.?\d*|\.\d+)$/.test(value)) {
71✔
883
    const num = Number(value);
12✔
884
    if (!Number.isFinite(num)) return value;
12!
885
    if (!value.includes('.') && !Number.isSafeInteger(num)) return value;
12✔
886
    return num;
11✔
887
  }
888

889
  // JSON strings (e.g. "\"true\"" → force literal string "true"), arrays, and objects
890
  if (
59✔
891
    (value.startsWith('"') && value.endsWith('"')) ||
184✔
892
    (value.startsWith('[') && value.endsWith(']')) ||
893
    (value.startsWith('{') && value.endsWith('}'))
894
  ) {
895
    try {
9✔
896
      return JSON.parse(value);
9✔
897
    } catch {
UNCOV
898
      return value;
×
899
    }
900
  }
901

902
  // Plain string
903
  return value;
50✔
904
}
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