phase 8 server-side validation (configSchema, inline field errors, partial-success semantics)
This commit is contained in:
@@ -5,6 +5,7 @@ const { CONFIG } = require('../config');
|
|||||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||||
const { logSystem } = require('../services/debugLog');
|
const { logSystem } = require('../services/debugLog');
|
||||||
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
|
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
|
||||||
|
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -38,68 +39,8 @@ router.get('/config', (req, res) => {
|
|||||||
res.json(obj);
|
res.json(obj);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /config — apply config updates (allowlisted keys only)
|
// POST /config — apply config updates. ALLOWED_CONFIG_KEYS comes from
|
||||||
const ALLOWED_CONFIG_KEYS = new Set([
|
// services/configSchema (the canonical schema + allowlist).
|
||||||
// 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'
|
|
||||||
]);
|
|
||||||
|
|
||||||
router.post('/config', express.json(), async (req, res) => {
|
router.post('/config', express.json(), async (req, res) => {
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
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(', ')}` });
|
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
|
||||||
}
|
}
|
||||||
const result = applyConfigUpdates(updates);
|
const result = applyConfigUpdates(updates);
|
||||||
|
const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', ');
|
||||||
await logSystem('Config updated via settings UI', [
|
await logSystem('Config updated via settings UI', [
|
||||||
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
|
{ 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(() => {});
|
]).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
|
// GET /discord/guild — return guild info for smart dropdowns
|
||||||
@@ -215,4 +161,8 @@ router.get('/notifications/alerts', (req, res) => {
|
|||||||
res.json(NOTIFICATION_REGISTRY);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
|
const { getValidator } = require('./configSchema');
|
||||||
|
|
||||||
const ENV_PATH = process.env.ENV_FILE
|
const ENV_PATH = process.env.ENV_FILE
|
||||||
? path.resolve(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.
|
* 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) {
|
function applyConfigUpdates(updates) {
|
||||||
const applied = [];
|
const applied = [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
const coercedForEnv = new Map();
|
||||||
|
|
||||||
for (const [key, rawValue] of Object.entries(updates)) {
|
for (const [key, rawValue] of Object.entries(updates)) {
|
||||||
try {
|
const validator = getValidator(key);
|
||||||
if (rawValue === 'true' || rawValue === 'false') {
|
const result = validator.validate(rawValue);
|
||||||
CONFIG[key] = rawValue === 'true';
|
if (!result.ok) {
|
||||||
} else if (!isNaN(rawValue) && rawValue !== '') {
|
errors.push({ key, error: result.error });
|
||||||
CONFIG[key] = Number(rawValue);
|
continue;
|
||||||
} else {
|
|
||||||
CONFIG[key] = rawValue;
|
|
||||||
}
|
|
||||||
applied.push(key);
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(`${key}: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
CONFIG[key] = result.coerced;
|
||||||
|
coercedForEnv.set(key, String(result.coerced));
|
||||||
|
applied.push(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to .env
|
// Write only the valid entries to .env. Skip the disk write entirely when
|
||||||
const envMap = readEnvFile();
|
// nothing validated, so a fully-rejected save doesn't rewrite the file.
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
if (applied.length > 0) {
|
||||||
envMap.set(key, String(value));
|
const envMap = readEnvFile();
|
||||||
|
for (const [key, value] of coercedForEnv) {
|
||||||
|
envMap.set(key, value);
|
||||||
|
}
|
||||||
|
writeEnvFile(envMap);
|
||||||
}
|
}
|
||||||
writeEnvFile(envMap);
|
|
||||||
|
|
||||||
return { applied, errors };
|
return { applied, errors };
|
||||||
}
|
}
|
||||||
|
|||||||
258
services/configSchema.js
Normal file
258
services/configSchema.js
Normal 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 (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
|
||||||
|
};
|
||||||
@@ -970,3 +970,22 @@ button.ss-chip {
|
|||||||
color: var(--primary);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,46 @@
|
|||||||
updateSaveBar();
|
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) {
|
async function saveConfig(mode) {
|
||||||
const buttons = document.querySelectorAll('#save-bar button');
|
const buttons = document.querySelectorAll('#save-bar button');
|
||||||
buttons.forEach(b => b.disabled = true);
|
buttons.forEach(b => b.disabled = true);
|
||||||
|
clearFieldErrors();
|
||||||
try {
|
try {
|
||||||
if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) {
|
if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) {
|
||||||
return;
|
return;
|
||||||
@@ -96,14 +133,21 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.applied) {
|
if (data.applied) {
|
||||||
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
|
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
|
||||||
pendingChanges = {};
|
for (const key of data.applied) delete pendingChanges[key];
|
||||||
updateSaveBar();
|
updateSaveBar();
|
||||||
document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed'));
|
document.querySelectorAll('.changed').forEach(el => {
|
||||||
Util.showToast(`${data.applied.length} settings saved.`, 'success');
|
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;
|
const hasErrors = data.errors && data.errors.length > 0;
|
||||||
if (hasErrors) {
|
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) {
|
if (mode === 'restart' && !hasErrors) {
|
||||||
await fetch('/api/restart', {
|
await fetch('/api/restart', {
|
||||||
@@ -114,7 +158,7 @@
|
|||||||
});
|
});
|
||||||
Util.showToast('Restart initiated.', 'warning');
|
Util.showToast('Restart initiated.', 'warning');
|
||||||
} else if (mode === 'restart' && hasErrors) {
|
} 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) {
|
} catch (e) {
|
||||||
Util.showToast('Failed to save. Bot may be unreachable.', 'error');
|
Util.showToast('Failed to save. Bot may be unreachable.', 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user