diff --git a/routes/internalApi.js b/routes/internalApi.js index fda8f36..8c5a9f6 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -5,6 +5,7 @@ const { CONFIG } = require('../config'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { logSystem } = require('../services/debugLog'); const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); +const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); const router = express.Router(); @@ -38,68 +39,8 @@ router.get('/config', (req, res) => { res.json(obj); }); -// POST /config — apply config updates (allowlisted keys only) -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' -]); - +// POST /config — apply config updates. ALLOWED_CONFIG_KEYS comes from +// services/configSchema (the canonical schema + allowlist). router.post('/config', express.json(), async (req, res) => { const updates = req.body; if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { @@ -110,11 +51,16 @@ router.post('/config', express.json(), async (req, res) => { return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); } const result = applyConfigUpdates(updates); + const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', '); await logSystem('Config updated via settings UI', [ { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false }, - { name: 'Errors', value: result.errors.join(', ') || 'none', inline: false } + { name: 'Errors', value: errorSummary || 'none', inline: false } ]).catch(() => {}); - res.json(result); + // Partial success stays 200 so the client can still apply the successful keys. + // Only 400 when every submitted key failed validation (i.e. the save did nothing). + const totalSubmitted = Object.keys(updates).length; + const allFailed = totalSubmitted > 0 && result.applied.length === 0 && result.errors.length > 0; + res.status(allFailed ? 400 : 200).json(result); }); // GET /discord/guild — return guild info for smart dropdowns @@ -215,4 +161,8 @@ router.get('/notifications/alerts', (req, res) => { res.json(NOTIFICATION_REGISTRY); }); +// Expose the allowlist for the Phase 8 schema smoke test. Attached to the +// router function object; doesn't show up as a route. +router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS); + module.exports = router; diff --git a/services/configPersistence.js b/services/configPersistence.js index c7bf7c3..5047850 100644 --- a/services/configPersistence.js +++ b/services/configPersistence.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const { CONFIG } = require('../config'); +const { getValidator } = require('./configSchema'); const ENV_PATH = process.env.ENV_FILE ? path.resolve(process.env.ENV_FILE) @@ -64,33 +65,40 @@ function writeEnvFile(updates) { /** * Apply a flat object of { KEY: value } to both CONFIG and .env. - * Returns { applied: string[], errors: string[] } + * Returns { applied: string[], errors: Array<{ key, error }> }. + * + * Every value is routed through services/configSchema so invalid inputs are + * rejected before being persisted. Valid values are typed via the validator's + * coerced return (boolean/number/string) into CONFIG; .env always stores + * String(coerced). Invalid values are reported and NEITHER written to .env NOR + * applied to CONFIG — callers see them in the `errors` array. */ function applyConfigUpdates(updates) { const applied = []; const errors = []; + const coercedForEnv = new Map(); for (const [key, rawValue] of Object.entries(updates)) { - try { - if (rawValue === 'true' || rawValue === 'false') { - CONFIG[key] = rawValue === 'true'; - } else if (!isNaN(rawValue) && rawValue !== '') { - CONFIG[key] = Number(rawValue); - } else { - CONFIG[key] = rawValue; - } - applied.push(key); - } catch (err) { - errors.push(`${key}: ${err.message}`); + const validator = getValidator(key); + const result = validator.validate(rawValue); + if (!result.ok) { + errors.push({ key, error: result.error }); + continue; } + CONFIG[key] = result.coerced; + coercedForEnv.set(key, String(result.coerced)); + applied.push(key); } - // Write to .env - const envMap = readEnvFile(); - for (const [key, value] of Object.entries(updates)) { - envMap.set(key, String(value)); + // Write only the valid entries to .env. Skip the disk write entirely when + // nothing validated, so a fully-rejected save doesn't rewrite the file. + if (applied.length > 0) { + const envMap = readEnvFile(); + for (const [key, value] of coercedForEnv) { + envMap.set(key, value); + } + writeEnvFile(envMap); } - writeEnvFile(envMap); return { applied, errors }; } diff --git a/services/configSchema.js b/services/configSchema.js new file mode 100644 index 0000000..d9a7b12 --- /dev/null +++ b/services/configSchema.js @@ -0,0 +1,258 @@ +/** + * 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' +]); + +// ---------- 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 === '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 +}; diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 64fa6e3..2d6dd29 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -970,3 +970,22 @@ button.ss-chip { color: var(--primary); } +/* ---------- Field-level validation errors (Phase 8) ---------- */ + +.field.field-error input, +.field.field-error select, +.field.field-error textarea, +.field.field-error .smart-select-display { + border-color: var(--danger); + box-shadow: 0 0 0 3px rgba(255, 90, 82, 0.15); +} +.field.field-error label { color: var(--danger); } +.field-error-message { + font-family: var(--font-body); + font-size: 12px; + color: var(--danger); + margin-top: 2px; + line-height: 1.4; +} + + diff --git a/settings-site/public/js/fields.js b/settings-site/public/js/fields.js index f3b8ddf..f79e9fd 100644 --- a/settings-site/public/js/fields.js +++ b/settings-site/public/js/fields.js @@ -80,9 +80,46 @@ updateSaveBar(); } + function clearFieldErrors() { + document.querySelectorAll('.field.field-error').forEach(f => f.classList.remove('field-error')); + document.querySelectorAll('.field-error-message').forEach(el => el.remove()); + } + + function normalizeErrorEntry(e) { + if (e && typeof e === 'object' && 'key' in e) { + return { key: e.key, message: e.error || 'Invalid value' }; + } + // Tolerate legacy string shape "KEY: message" from older bot builds. + const str = String(e); + const idx = str.indexOf(':'); + if (idx > 0) return { key: str.slice(0, idx).trim(), message: str.slice(idx + 1).trim() }; + return { key: str, message: 'Invalid value' }; + } + + function applyFieldErrors(errors) { + let firstField = null; + for (const raw of errors) { + const { key, message } = normalizeErrorEntry(raw); + const selector = `[data-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`; + const input = document.querySelector(selector); + if (!input) continue; + const field = input.closest('.field'); + if (!field) continue; + field.classList.add('field-error'); + const msgEl = document.createElement('div'); + msgEl.className = 'field-error-message'; + msgEl.textContent = message; + msgEl.setAttribute('role', 'alert'); + field.appendChild(msgEl); + if (!firstField) firstField = field; + } + if (firstField) firstField.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + async function saveConfig(mode) { const buttons = document.querySelectorAll('#save-bar button'); buttons.forEach(b => b.disabled = true); + clearFieldErrors(); try { if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) { return; @@ -96,14 +133,21 @@ const data = await res.json(); if (data.applied) { for (const key of data.applied) savedConfig[key] = pendingChanges[key]; - pendingChanges = {}; + for (const key of data.applied) delete pendingChanges[key]; updateSaveBar(); - document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed')); - Util.showToast(`${data.applied.length} settings saved.`, 'success'); + document.querySelectorAll('.changed').forEach(el => { + const key = el.dataset && el.dataset.key; + if (!key || !(key in pendingChanges)) el.classList.remove('changed'); + }); + if (data.applied.length > 0) { + Util.showToast(`${data.applied.length} setting${data.applied.length === 1 ? '' : 's'} saved.`, 'success'); + } } const hasErrors = data.errors && data.errors.length > 0; if (hasErrors) { - Util.showToast(`Errors: ${data.errors.join(', ')}`, 'error'); + applyFieldErrors(data.errors); + const keys = data.errors.map(e => normalizeErrorEntry(e).key).join(', '); + Util.showToast(`${data.errors.length} setting${data.errors.length === 1 ? '' : 's'} failed: ${keys}`, 'error'); } if (mode === 'restart' && !hasErrors) { await fetch('/api/restart', { @@ -114,7 +158,7 @@ }); Util.showToast('Restart initiated.', 'warning'); } else if (mode === 'restart' && hasErrors) { - Util.showToast(`Restart cancelled — save returned errors: ${data.errors.join(', ')}`, 'warning'); + Util.showToast('Restart cancelled — some settings failed validation.', 'warning'); } } catch (e) { Util.showToast('Failed to save. Bot may be unreachable.', 'error');