/** * Per-key config value validator registry. * * Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS. * getValidator(key) returns { type, validate(value) }, where validate returns * { ok: true, coerced } — typed value to assign into CONFIG[key] * { ok: false, error } — human-readable reason surfaced in the save UI * * .env always stores String(coerced); CONFIG gets the typed coerced value so * downstream consumers that compare === true / === 5 still work. * * This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi * imports the Set from here. That keeps the require graph acyclic: * internalApi -> configPersistence -> configSchema * internalApi -> configSchema * No side effects beyond a one-line startup log of the fallback-string keys. */ 'use strict'; const ALLOWED_CONFIG_KEYS = new Set([ // Ticket settings 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', 'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS', // Escalation categories 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', // Roles and staff 'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID', 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', 'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID', 'STAFF_NOTIFICATION_CATEGORY_ID', // Pattern channel IDs 'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID', 'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID', // Messages and labels 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', 'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', // Branding 'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', // Toggles 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', 'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE', 'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', 'STAFF_DND_COUNTS_AS_AVAILABLE', // Limits and thresholds 'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', // Embed colors 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI', // Pattern thresholds 'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD', 'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD', 'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES', // Surge settings 'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES', 'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES', 'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS', 'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS', 'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES', 'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD', // Chat alerts 'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT', 'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES', // Notification thresholds 'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS', // Notification enable state (Phase 9) 'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED' ]); // ---------- Regex primitives ---------- const SNOWFLAKE_RE = /^[0-9]{17,20}$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/; const INT_RE = /^-?\d+$/; const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/; function isEmptyInput(v) { return v === '' || v === null || v === undefined; } // ---------- Validators ---------- const VALIDATORS = { boolean: { type: 'boolean', validate(value) { if (value === true || value === 'true') return { ok: true, coerced: true }; if (value === false || value === 'false') return { ok: true, coerced: false }; return { ok: false, error: 'must be true or false' }; } }, integer: { type: 'integer', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' }; const n = parseInt(str, 10); if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' }; return { ok: true, coerced: n }; } }, hex_color: { type: 'hex_color', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); const m = str.match(HEX_COLOR_RE); if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' }; return { ok: true, coerced: '0x' + m[1].toUpperCase() }; } }, url: { type: 'url', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); try { new URL(str); return { ok: true, coerced: str }; } catch (_) { return { ok: false, error: 'must be a valid URL (include the protocol)' }; } } }, email: { type: 'email', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' }; return { ok: true, coerced: str }; } }, discord_id: { type: 'discord_id', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (17–20 digits) or empty' }; return { ok: true, coerced: str }; } }, discord_id_list: { type: 'discord_id_list', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value).trim(); if (str === '') return { ok: true, coerced: '' }; const parts = str.split(',').map(p => p.trim()).filter(Boolean); for (const p of parts) { if (!SNOWFLAKE_RE.test(p)) return { ok: false, error: `"${p}" is not a Discord ID` }; } return { ok: true, coerced: parts.join(',') }; } }, json: { type: 'json', validate(value) { if (isEmptyInput(value)) return { ok: true, coerced: '' }; const str = String(value); try { JSON.parse(str); return { ok: true, coerced: str }; } catch (_) { return { ok: false, error: 'must be valid JSON' }; } } }, string_or_json: { type: 'string_or_json', validate(value) { if (value === null || value === undefined) return { ok: false, error: 'cannot be null' }; return { ok: true, coerced: String(value) }; } }, // Fallback. Preserves legacy coercion so CONFIG.* values keep their types // for consumers that compare with === true / === 5 (see old applyConfigUpdates). string: { type: 'string', validate(value) { if (value === null || value === undefined) return { ok: false, error: 'cannot be null' }; if (value === 'true' || value === true) return { ok: true, coerced: true }; if (value === 'false' || value === false) return { ok: true, coerced: false }; const str = String(value); if (str !== '' && NUMERIC_COERCE_RE.test(str)) return { ok: true, coerced: Number(str) }; return { ok: true, coerced: str }; } } }; // ---------- Type inference ---------- function inferType(key) { // 1. Explicit overrides if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json'; if (key === 'NOTIFICATION_ENABLED_JSON') return 'json'; if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean'; if (key === 'LOGO_URL') return 'url'; if (/_EMAIL$/.test(key)) return 'email'; if (key.includes('COLOR')) return 'hex_color'; if (/_EMOJIS$/.test(key)) return 'string_or_json'; // ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it. if (key === 'ROLE_ID_TO_PING') return 'discord_id'; // 2. Name patterns if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean'; if (/_IDS$/.test(key)) return 'discord_id_list'; if (/_ID$/.test(key)) return 'discord_id'; if (/_HOURS$|_MINUTES$|_SECONDS$|_COUNT$|_LIMIT$|_THRESHOLD$/.test(key)) return 'integer'; // 3. Fallback return 'string'; } function getValidator(key) { return VALIDATORS[inferType(key)]; } // Pre-build per-key validator map for callers that want O(1) lookup // (and for the smoke test / boot log). const ALL_VALIDATORS = {}; for (const key of ALLOWED_CONFIG_KEYS) { ALL_VALIDATORS[key] = getValidator(key); } // ---------- Startup log (no-op if console.log is suppressed) ---------- (function logDistribution() { const dist = {}; const fallback = []; for (const [key, v] of Object.entries(ALL_VALIDATORS)) { dist[v.type] = (dist[v.type] || 0) + 1; if (v.type === 'string') fallback.push(key); } console.log('[configSchema] type distribution:', JSON.stringify(dist)); if (fallback.length) { console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', ')); } })(); module.exports = { ALLOWED_CONFIG_KEYS, VALIDATORS, ALL_VALIDATORS, getValidator, inferType };