phase 8 server-side validation (configSchema, inline field errors, partial-success semantics)

This commit is contained in:
2026-04-18 19:54:47 +00:00
parent 23a02c87d9
commit 39a5482516
5 changed files with 365 additions and 86 deletions

View File

@@ -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;

View File

@@ -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 };
}

258
services/configSchema.js Normal file
View File

@@ -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 (1720 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
};

View File

@@ -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;
}

View File

@@ -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');