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 { 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;
|
||||
|
||||
Reference in New Issue
Block a user