/** * 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', 'DISCORD_TICKET_CATEGORY_ID', // 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', 'ADMIN_ID', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'RENAME_LOG_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', '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', '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', // Limits and thresholds 'GLOBAL_TICKET_LIMIT', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', // Embed colors 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' ]); // ---------- 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 === 'LOGO_URL') return 'url'; if (/_EMAIL$/.test(key)) return 'email'; if (key.includes('COLOR')) return 'hex_color'; // 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)]; } module.exports = { ALLOWED_CONFIG_KEYS, getValidator };