263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
/**
|
||
* 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',
|
||
// Notification enable state (Phase 9)
|
||
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
|
||
]);
|
||
|
||
// ---------- 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 === 'NOTIFICATION_ENABLED_JSON') return 'json';
|
||
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
|
||
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
|
||
};
|