Files
broccolini-bot/services/configSchema.js

235 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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',
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_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',
// 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',
'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',
// 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'
]);
// ---------- 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 === 'LOGO_URL') return 'url';
if (/_EMAIL$/.test(key)) return 'email';
if (key.includes('COLOR')) return 'hex_color';
// 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
};