diff --git a/package.json b/package.json index 624c8d27..7be201c4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "discord.js": "^14.25.1", "dotenv": "^17.2.3", + "pg": "^8.18.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" }, diff --git a/src/commands/config.js b/src/commands/config.js index da5f179c..3f76038e 100644 --- a/src/commands/config.js +++ b/src/commands/config.js @@ -1,10 +1,19 @@ +/** + * Config Command + * View, set, and reset bot configuration via slash commands + */ + import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { getConfig, setConfigValue, resetConfig, loadConfigFromFile } from '../modules/config.js'; + +// Derived from config.json top-level keys so static slash-command choices stay in sync automatically. +const VALID_SECTIONS = Object.keys(loadConfigFromFile()); -const __dirname = dirname(fileURLToPath(import.meta.url)); -const configPath = join(__dirname, '..', '..', 'config.json'); +/** @type {Array<{name: string, value: string}>} Derived choices for section options */ +const SECTION_CHOICES = VALID_SECTIONS.map(s => ({ + name: s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1'), + value: s, +})); export const data = new SlashCommandBuilder() .setName('config') @@ -18,69 +27,283 @@ export const data = new SlashCommandBuilder() .setName('section') .setDescription('Specific config section to view') .setRequired(false) - .addChoices( - { name: 'AI Settings', value: 'ai' }, - { name: 'Welcome Messages', value: 'welcome' }, - { name: 'Moderation', value: 'moderation' }, - { name: 'Permissions', value: 'permissions' } - ) + .addChoices(...SECTION_CHOICES) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Set a configuration value') + .addStringOption(option => + option + .setName('path') + .setDescription('Dot-notation path (e.g., ai.model, welcome.enabled)') + .setRequired(true) + .setAutocomplete(true) + ) + .addStringOption(option => + option + .setName('value') + .setDescription('Value (auto-coerces true/false/null/numbers; use "\\"text\\"" for literal strings)') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('reset') + .setDescription('Reset configuration to defaults from config.json') + .addStringOption(option => + option + .setName('section') + .setDescription('Section to reset (omit to reset all)') + .setRequired(false) + .addChoices(...SECTION_CHOICES) ) ); export const adminOnly = true; +/** + * Recursively collect leaf-only dot-notation paths from a config object. + * Only emits paths that point to non-object values (leaves), preventing + * autocomplete from suggesting intermediate paths whose selection would + * overwrite all nested config beneath them with a scalar. + * @param {Object} obj - Object to flatten + * @param {string} prefix - Current path prefix + * @returns {string[]} Array of dot-notation leaf paths + */ +function flattenConfigKeys(obj, prefix) { + const paths = []; + for (const [key, value] of Object.entries(obj)) { + const fullPath = `${prefix}.${key}`; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + paths.push(...flattenConfigKeys(value, fullPath)); + } else { + paths.push(fullPath); + } + } + return paths; +} + +/** + * Handle autocomplete for the `path` option in /config set. + * + * The `section` option (used by view/reset) uses static choices via + * addChoices(), so Discord resolves those client-side and never fires + * an autocomplete event for them. Only the `path` option is registered + * with setAutocomplete(true). + * + * Suggests dot-notation leaf paths (≥2 segments) that setConfigValue + * actually accepts. + * + * @param {Object} interaction - Discord interaction + */ +export async function autocomplete(interaction) { + try { + const focusedValue = interaction.options.getFocused().toLowerCase(); + const config = getConfig(); + + const paths = []; + for (const [section, value] of Object.entries(config)) { + if (typeof value === 'object' && value !== null) { + paths.push(...flattenConfigKeys(value, section)); + } + } + + const choices = paths + .filter(p => p.includes('.') && p.toLowerCase().includes(focusedValue)) + .sort((a, b) => { + const aStarts = a.toLowerCase().startsWith(focusedValue); + const bStarts = b.toLowerCase().startsWith(focusedValue); + if (aStarts !== bStarts) return aStarts ? -1 : 1; + return a.localeCompare(b); + }) + .slice(0, 25) + .map(p => ({ name: p, value: p })); + + await interaction.respond(choices); + } catch { + // Silently ignore — autocomplete failures (e.g. expired interaction token) + // are non-critical and must not produce unhandled promise rejections. + } +} + +/** + * Execute the config command + * @param {Object} interaction - Discord interaction + */ export async function execute(interaction) { const subcommand = interaction.options.getSubcommand(); - if (subcommand === 'view') { - try { - const config = JSON.parse(readFileSync(configPath, 'utf-8')); - const section = interaction.options.getString('section'); - - const embed = new EmbedBuilder() - .setColor(0x5865F2) - .setTitle('⚙️ Bot Configuration') - .setTimestamp(); - - if (section) { - // Show specific section - const sectionData = config[section]; - if (!sectionData) { - return await interaction.reply({ - content: `❌ Section '${section}' not found in config`, - ephemeral: true - }); - } + switch (subcommand) { + case 'view': + await handleView(interaction); + break; + case 'set': + await handleSet(interaction); + break; + case 'reset': + await handleReset(interaction); + break; + } +} - embed.setDescription(`**${section.toUpperCase()} Configuration**`); - embed.addFields({ - name: 'Settings', - value: '```json\n' + JSON.stringify(sectionData, null, 2) + '\n```' +/** @type {number} Discord embed total character limit */ +const EMBED_CHAR_LIMIT = 6000; + +/** + * Handle /config view + */ +async function handleView(interaction) { + try { + const config = getConfig(); + const section = interaction.options.getString('section'); + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('⚙️ Bot Configuration') + .setFooter({ text: `${process.env.DATABASE_URL ? 'Stored in PostgreSQL' : 'Stored in memory (config.json)'} • Use /config set to modify` }) + .setTimestamp(); + + if (section) { + const sectionData = config[section]; + if (!sectionData) { + return await interaction.reply({ + content: `❌ Section '${section}' not found in config`, + ephemeral: true }); - } else { - // Show all sections - embed.setDescription('Current bot configuration'); + } + + embed.setDescription(`**${section.toUpperCase()} Configuration**`); + const sectionJson = JSON.stringify(sectionData, null, 2); + embed.addFields({ + name: 'Settings', + value: '```json\n' + (sectionJson.length > 1000 ? sectionJson.slice(0, 997) + '...' : sectionJson) + '\n```' + }); + } else { + embed.setDescription('Current bot configuration'); - for (const [key, value] of Object.entries(config)) { - const jsonStr = JSON.stringify(value, null, 2); - const truncated = jsonStr.length > 1000 - ? jsonStr.slice(0, 997) + '...' - : jsonStr; + // Track cumulative embed size to stay under Discord's 6000-char limit + let totalLength = embed.data.title.length + embed.data.description.length; + let truncated = false; + for (const [key, value] of Object.entries(config)) { + const jsonStr = JSON.stringify(value, null, 2); + const fieldValue = '```json\n' + (jsonStr.length > 1000 ? jsonStr.slice(0, 997) + '...' : jsonStr) + '\n```'; + const fieldName = key.toUpperCase(); + const fieldLength = fieldName.length + fieldValue.length; + + if (totalLength + fieldLength > EMBED_CHAR_LIMIT - 200) { + // Reserve space for a truncation notice embed.addFields({ - name: `${key.toUpperCase()}`, - value: '```json\n' + truncated + '\n```', + name: '⚠️ Truncated', + value: `Use \`/config view section:\` to see remaining sections.`, inline: false }); + truncated = true; + break; } + + totalLength += fieldLength; + embed.addFields({ + name: fieldName, + value: fieldValue, + inline: false + }); } - await interaction.reply({ embeds: [embed], ephemeral: true }); - } catch (err) { - await interaction.reply({ - content: `❌ Failed to load config: ${err.message}`, - ephemeral: true - }); + if (truncated) { + embed.setFooter({ text: 'Some sections omitted • Use /config view section: for details' }); + } + } + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } catch (err) { + await interaction.reply({ + content: `❌ Failed to load config: ${err.message}`, + ephemeral: true + }); + } +} + +/** + * Handle /config set + */ +async function handleSet(interaction) { + const path = interaction.options.getString('path'); + const value = interaction.options.getString('value'); + + // Validate section exists in live config (may include DB-added sections beyond config.json) + const section = path.split('.')[0]; + const liveSections = Object.keys(getConfig()); + if (!liveSections.includes(section)) { + return await interaction.reply({ + content: `❌ Invalid section '${section}'. Valid sections: ${liveSections.join(', ')}`, + ephemeral: true + }); + } + + try { + await interaction.deferReply({ ephemeral: true }); + + const updatedSection = await setConfigValue(path, value); + + // Traverse to the actual leaf value for display + const leafValue = path.split('.').slice(1).reduce((obj, k) => obj?.[k], updatedSection); + + const displayValue = JSON.stringify(leafValue, null, 2) ?? value; + const truncatedValue = displayValue.length > 1000 ? displayValue.slice(0, 997) + '...' : displayValue; + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('✅ Config Updated') + .addFields( + { name: 'Path', value: `\`${path}\``, inline: true }, + { name: 'New Value', value: `\`${truncatedValue}\``, inline: true } + ) + .setFooter({ text: 'Changes take effect immediately' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + const content = `❌ Failed to set config: ${err.message}`; + if (interaction.deferred) { + await interaction.editReply({ content }); + } else { + await interaction.reply({ content, ephemeral: true }); + } + } +} + +/** + * Handle /config reset + */ +async function handleReset(interaction) { + const section = interaction.options.getString('section'); + + try { + await interaction.deferReply({ ephemeral: true }); + + await resetConfig(section || undefined); + + const embed = new EmbedBuilder() + .setColor(0xFEE75C) + .setTitle('🔄 Config Reset') + .setDescription( + section + ? `Section **${section}** has been reset to defaults from config.json.` + : 'All configuration has been reset to defaults from config.json.' + ) + .setFooter({ text: 'Changes take effect immediately' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + const content = `❌ Failed to reset config: ${err.message}`; + if (interaction.deferred) { + await interaction.editReply({ content }); + } else { + await interaction.reply({ content, ephemeral: true }); } } } diff --git a/src/db.js b/src/db.js new file mode 100644 index 00000000..4b34f3e7 --- /dev/null +++ b/src/db.js @@ -0,0 +1,100 @@ +/** + * Database Module + * PostgreSQL connection pool and schema initialization + */ + +import pg from 'pg'; +import { info, error as logError } from './logger.js'; + +const { Pool } = pg; + +/** @type {pg.Pool | null} */ +let pool = null; + +/** + * Initialize the database connection pool and create schema + * @returns {Promise} The connection pool + */ +export async function initDb() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + // Guard against double initialization — close any existing pool to prevent leaks + if (pool) { + info('Closing existing database pool before re-initialization'); + await pool.end().catch(() => {}); + pool = null; + } + + pool = new Pool({ + connectionString, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + // Railway internal connections don't need SSL; others default to verified TLS + ssl: connectionString.includes('railway.internal') + ? false + : process.env.DB_SSL_REJECT_UNAUTHORIZED === 'false' + ? { rejectUnauthorized: false } + : { rejectUnauthorized: true }, + }); + + // Prevent unhandled pool errors from crashing the process + pool.on('error', (err) => { + logError('Unexpected database pool error', { error: err.message }); + }); + + try { + // Test connection + const client = await pool.connect(); + try { + await client.query('SELECT NOW()'); + info('Database connected'); + } finally { + client.release(); + } + + // Create schema + await pool.query(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + info('Database schema initialized'); + } catch (err) { + // Clean up the pool so getPool() doesn't return an unusable instance + await pool.end().catch(() => {}); + pool = null; + throw err; + } + + return pool; +} + +/** + * Get the database pool + * @returns {pg.Pool} The connection pool + * @throws {Error} If pool is not initialized + */ +export function getPool() { + if (!pool) { + throw new Error('Database not initialized. Call initDb() first.'); + } + return pool; +} + +/** + * Gracefully close the database pool + */ +export async function closeDb() { + if (pool) { + await pool.end(); + pool = null; + info('Database pool closed'); + } +} diff --git a/src/index.js b/src/index.js index 4863cceb..8efc22cd 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ import { readdirSync, writeFileSync, readFileSync, existsSync, mkdirSync } from import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { info, warn, error } from './logger.js'; +import { initDb, closeDb } from './db.js'; import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { HealthMonitor } from './utils/health.js'; @@ -35,8 +36,11 @@ const statePath = join(dataDir, 'state.json'); // Load environment variables dotenvConfig(); -// Load configuration -const config = loadConfig(); +// Config is loaded asynchronously after DB init (see startup below). +// After loadConfig() resolves, `config` points to the same object as +// configCache inside modules/config.js, so in-place mutations from +// setConfigValue() propagate here automatically without re-assignment. +let config = {}; // Initialize Discord client with required intents const client = new Client({ @@ -138,8 +142,7 @@ async function loadCommands() { } } -// Register all event handlers -registerEventHandlers(client, config, healthMonitor); +// Event handlers are registered after config loads (see startup below) // Extend ready handler to register slash commands client.once('clientReady', async () => { @@ -159,8 +162,21 @@ client.once('clientReady', async () => { } }); -// Handle slash commands +// Handle slash commands and autocomplete client.on('interactionCreate', async (interaction) => { + // Handle autocomplete + if (interaction.isAutocomplete()) { + const command = client.commands.get(interaction.commandName); + if (command?.autocomplete) { + try { + await command.autocomplete(interaction); + } catch (err) { + error('Autocomplete error', { command: interaction.commandName, error: err.message }); + } + } + return; + } + if (!interaction.isChatInputCommand()) return; const { commandName, member } = interaction; @@ -234,11 +250,19 @@ async function gracefulShutdown(signal) { info('Saving conversation state'); saveState(); - // 3. Destroy Discord client + // 3. Close database pool + info('Closing database connection'); + try { + await closeDb(); + } catch (err) { + error('Failed to close database pool', { error: err.message }); + } + + // 4. Destroy Discord client info('Disconnecting from Discord'); client.destroy(); - // 4. Log clean exit + // 5. Log clean exit info('Shutdown complete'); process.exit(0); } @@ -271,13 +295,40 @@ if (!token) { process.exit(1); } -// Load previous state on startup -loadState(); +/** + * Main startup sequence + * 1. Initialize database + * 2. Load config from DB (seeds from config.json if empty) + * 3. Load previous conversation state + * 4. Register event handlers with live config + * 5. Load commands + * 6. Login to Discord + */ +async function startup() { + // Initialize database + if (process.env.DATABASE_URL) { + await initDb(); + info('Database initialized'); + } else { + warn('DATABASE_URL not set — using config.json only (no persistence)'); + } -// Load commands and login -loadCommands() - .then(() => client.login(token)) - .catch((err) => { - error('Startup failed', { error: err.message }); - process.exit(1); - }); + // Load config (from DB if available, else config.json) + config = await loadConfig(); + info('Configuration loaded', { sections: Object.keys(config) }); + + // Load previous conversation state + loadState(); + + // Register event handlers with live config reference + registerEventHandlers(client, config, healthMonitor); + + // Load commands and login + await loadCommands(); + await client.login(token); +} + +startup().catch((err) => { + error('Startup failed', { error: err.message, stack: err.stack }); + process.exit(1); +}); diff --git a/src/modules/config.js b/src/modules/config.js index f5493886..c2b12c57 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,30 +1,396 @@ /** * Configuration Module - * Handles loading and exporting bot configuration + * Loads config from PostgreSQL with config.json as the seed/fallback */ import { readFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { getPool } from '../db.js'; +import { info, warn as logWarn, error as logError } from '../logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const configPath = join(__dirname, '..', '..', 'config.json'); +/** @type {Object} In-memory config cache */ +let configCache = {}; + /** - * Load configuration from config.json - * @returns {Object} Configuration object + * Load config.json from disk (used as seed/fallback) + * @returns {Object} Configuration object from file + * @throws {Error} If config.json is missing or unparseable + */ +export function loadConfigFromFile() { + if (!existsSync(configPath)) { + const err = new Error('config.json not found!'); + err.code = 'CONFIG_NOT_FOUND'; + throw err; + } + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (err) { + throw new Error(`Failed to load config.json: ${err.message}`); + } +} + +/** + * Load config from PostgreSQL, seeding from config.json if empty + * Falls back to config.json if database is unavailable + * @returns {Promise} Configuration object */ -export function loadConfig() { +export async function loadConfig() { + // Try loading config.json but don't hard-exit — DB may have valid config + let fileConfig; + try { + fileConfig = loadConfigFromFile(); + } catch { + fileConfig = null; + info('config.json not available, will rely on database for configuration'); + } + try { - if (!existsSync(configPath)) { - console.error('❌ config.json not found!'); - process.exit(1); + let pool; + try { + pool = getPool(); + } catch { + // DB not initialized — file config is our only option + if (!fileConfig) { + throw new Error('No configuration source available: config.json is missing and database is not initialized'); + } + info('Database not available, using config.json'); + configCache = { ...fileConfig }; + return configCache; + } + + // Check if config table has any rows + const { rows } = await pool.query('SELECT key, value FROM config'); + + if (rows.length === 0) { + if (!fileConfig) { + throw new Error('No configuration source available: database is empty and config.json is missing'); + } + // Seed database from config.json inside a transaction + info('No config in database, seeding from config.json'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const [key, value] of Object.entries(fileConfig)) { + await client.query( + 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', + [key, JSON.stringify(value)] + ); + } + await client.query('COMMIT'); + info('Config seeded to database'); + configCache = { ...fileConfig }; + } catch (txErr) { + try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } + throw txErr; + } finally { + client.release(); + } + } else { + // Load from database + configCache = {}; + for (const row of rows) { + configCache[row.key] = row.value; + } + info('Config loaded from database'); } - const config = JSON.parse(readFileSync(configPath, 'utf-8')); - console.log('✅ Loaded config.json'); - return config; } catch (err) { - console.error('❌ Failed to load config.json:', err.message); - process.exit(1); + if (!fileConfig) { + // No fallback available — re-throw + throw err; + } + logError('Failed to load config from database, using config.json', { error: err.message }); + configCache = { ...fileConfig }; + } + + return configCache; +} + +/** + * Get the current config (from cache) + * @returns {Object} Configuration object + */ +export function getConfig() { + return configCache; +} + +/** + * Set a config value using dot notation (e.g., "ai.model" or "welcome.enabled") + * Persists to database and updates in-memory cache + * @param {string} path - Dot-notation path (e.g., "ai.model") + * @param {*} value - Value to set (automatically parsed from string) + * @returns {Promise} Updated section config + */ +export async function setConfigValue(path, value) { + const parts = path.split('.'); + if (parts.length < 2) { + throw new Error('Path must include section and key (e.g., "ai.model")'); + } + + // Reject dangerous keys to prevent prototype pollution + validatePathSegments(parts); + + const section = parts[0]; + const nestedParts = parts.slice(1); + const parsedVal = parseValue(value); + + // Deep clone the section for the INSERT case (new section that doesn't exist yet) + const sectionClone = structuredClone(configCache[section] || {}); + setNestedValue(sectionClone, nestedParts, parsedVal); + + // Write to database first, then update cache. + // Uses a transaction with row lock to prevent concurrent writes from clobbering. + // Reads the current row, applies the change in JS (handles arbitrary nesting), + // then writes back — safe because the row is locked for the duration. + let dbPersisted = false; + + // Separate pool acquisition from transaction work so we can distinguish + // "DB not configured" (graceful fallback) from real transaction errors (must surface). + let pool; + try { + pool = getPool(); + } catch { + // DB not initialized — skip persistence, fall through to in-memory update + logWarn('Database not initialized for config write — updating in-memory only'); + } + + if (pool) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Lock the row (or prepare for INSERT if missing) + const { rows } = await client.query( + 'SELECT value FROM config WHERE key = $1 FOR UPDATE', + [section] + ); + + if (rows.length > 0) { + // Row exists — merge change into the live DB value + const dbSection = rows[0].value; + setNestedValue(dbSection, nestedParts, parsedVal); + + await client.query( + 'UPDATE config SET value = $1, updated_at = NOW() WHERE key = $2', + [JSON.stringify(dbSection), section] + ); + } else { + // New section — insert the full clone + await client.query( + 'INSERT INTO config (key, value) VALUES ($1, $2)', + [section, JSON.stringify(sectionClone)] + ); + } + await client.query('COMMIT'); + dbPersisted = true; + } catch (txErr) { + try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } + throw txErr; + } finally { + client.release(); + } + } + + // Update in-memory cache (mutate in-place for reference propagation) + if (!configCache[section] || typeof configCache[section] !== 'object' || Array.isArray(configCache[section])) { + configCache[section] = {}; + } + setNestedValue(configCache[section], nestedParts, parsedVal); + + info('Config updated', { path, value: parsedVal, persisted: dbPersisted }); + return configCache[section]; +} + +/** + * Reset a config section to config.json defaults + * @param {string} [section] - Section to reset, or all if omitted + * @returns {Promise} Reset config + */ +export async function resetConfig(section) { + let fileConfig; + try { + fileConfig = loadConfigFromFile(); + } catch { + throw new Error( + 'Cannot reset configuration: config.json is not available. ' + + 'Reset requires the default config file as a baseline.' + ); + } + + let pool = null; + try { + pool = getPool(); + } catch { + logWarn('Database unavailable for config reset — updating in-memory only'); + } + + if (section) { + if (!fileConfig[section]) { + throw new Error(`Section '${section}' not found in config.json defaults`); + } + + if (pool) { + await pool.query( + 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', + [section, JSON.stringify(fileConfig[section])] + ); + } + + // Mutate in-place so references stay valid + const sectionData = configCache[section]; + if (sectionData && typeof sectionData === 'object') { + for (const key of Object.keys(sectionData)) delete sectionData[key]; + Object.assign(sectionData, fileConfig[section]); + } else { + configCache[section] = isPlainObject(fileConfig[section]) + ? { ...fileConfig[section] } + : fileConfig[section]; + } + info('Config section reset', { section }); + } else { + // Reset all inside a transaction + if (pool) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const [key, value] of Object.entries(fileConfig)) { + await client.query( + 'INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', + [key, JSON.stringify(value)] + ); + } + // Remove stale keys that exist in DB but not in config.json + const fileKeys = Object.keys(fileConfig); + if (fileKeys.length > 0) { + await client.query( + 'DELETE FROM config WHERE key != ALL($1::text[])', + [fileKeys] + ); + } + await client.query('COMMIT'); + } catch (txErr) { + try { await client.query('ROLLBACK'); } catch { /* ignore rollback failure */ } + throw txErr; + } finally { + client.release(); + } + } + + // Mutate in-place and remove stale keys from cache + for (const key of Object.keys(configCache)) { + if (!(key in fileConfig)) { + delete configCache[key]; + } + } + for (const [key, value] of Object.entries(fileConfig)) { + if (configCache[key] && isPlainObject(configCache[key]) && isPlainObject(value)) { + for (const k of Object.keys(configCache[key])) delete configCache[key][k]; + Object.assign(configCache[key], value); + } else { + configCache[key] = isPlainObject(value) ? { ...value } : value; + } + } + info('All config reset to defaults'); + } + + return configCache; +} + +/** Keys that must never be used as path segments (prototype pollution vectors) */ +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Validate that no path segment is a prototype-pollution vector. + * @param {string[]} segments - Path segments to check + * @throws {Error} If any segment is a dangerous key + */ +function validatePathSegments(segments) { + for (const segment of segments) { + if (DANGEROUS_KEYS.has(segment)) { + throw new Error(`Invalid config path: '${segment}' is a reserved key and cannot be used`); + } } } + +/** + * Set a value at a nested path within an object, creating intermediate objects as needed. + * @param {Object} root - Target object to modify (the section-level object) + * @param {string[]} pathParts - Path segments below the section (e.g., ['model'] for 'ai.model') + * @param {*} value - Value to set at the leaf key + */ +function setNestedValue(root, pathParts, value) { + if (pathParts.length === 0) { + throw new Error('setNestedValue requires at least one path segment'); + } + let current = root; + for (let i = 0; i < pathParts.length - 1; i++) { + if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object' || Array.isArray(current[pathParts[i]])) { + current[pathParts[i]] = {}; + } + current = current[pathParts[i]]; + } + current[pathParts[pathParts.length - 1]] = value; +} + +/** + * Check if a value is a plain object (not null, not array) + * @param {*} val - Value to check + * @returns {boolean} True if plain object + */ +function isPlainObject(val) { + return typeof val === 'object' && val !== null && !Array.isArray(val); +} + +/** + * Parse a string value into its appropriate JS type. + * + * Coercion rules: + * - "true" / "false" → boolean + * - "null" → null + * - Numeric strings → number (unless beyond Number.MAX_SAFE_INTEGER) + * - JSON arrays/objects/quoted strings → parsed value + * - Everything else → kept as-is string + * + * To force a literal string (e.g. the word "true"), wrap it in JSON quotes: + * "\"true\"" → parsed by JSON.parse into the string "true" + * + * @param {string} value - String value to parse + * @returns {*} Parsed value + */ +function parseValue(value) { + if (typeof value !== 'string') return value; + + // Booleans + if (value === 'true') return true; + if (value === 'false') return false; + + // Null + if (value === 'null') return null; + + // Numbers (keep as string if beyond safe integer range to avoid precision loss) + // Matches: 123, -123, 1.5, -1.5, 1., .5, -.5 + if (/^-?(\d+\.?\d*|\.\d+)$/.test(value)) { + const num = Number(value); + if (!Number.isFinite(num)) return value; + if (!value.includes('.') && !Number.isSafeInteger(num)) return value; + return num; + } + + // JSON strings (e.g. "\"true\"" → force literal string "true"), arrays, and objects + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('{') && value.endsWith('}')) + ) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + // Plain string + return value; +}