/** @typedef {'app' | 'scheduler'} RuntimeMode */ /** @typedef {Record} RuntimeEnvironment */ const APP_REQUIRED = [ 'APP_ORIGIN', 'ORIGIN', 'PASSKEY_RP_ID', 'SCHEDULER_TOKEN', 'TDR_DATABASE_PATH', 'TDR_AVATAR_STORAGE_PATH', 'TDR_GAME_ICON_STORAGE_PATH', ]; /** * Validate environment variables after the runtime secret provider has injected them. * * @param {RuntimeMode} mode * @param {RuntimeEnvironment} environment * @returns {string[]} */ export function validateRuntimeEnvironment(mode, environment) { if (mode !== 'app' && mode !== 'scheduler') { throw new TypeError(`Unsupported runtime mode: ${mode}`); } const errors = []; const get = (name) => environment[name]?.trim() ?? ''; const requireValues = (names) => { for (const name of names) { if (!get(name)) errors.push(`${name} is required.`); } }; if (mode === 'scheduler') { requireValues(['SCHEDULER_URL', 'SCHEDULER_TOKEN']); validateHttpUrl('SCHEDULER_URL', get('SCHEDULER_URL'), errors); if ( get('SCHEDULER_URL') && get('SCHEDULER_URL') !== 'http://app:3000/api/internal/scheduler' ) { errors.push( 'SCHEDULER_URL must use the internal app scheduler endpoint.', ); } validateSchedulerToken(get('SCHEDULER_TOKEN'), errors); return errors; } requireValues(APP_REQUIRED); validateSchedulerToken(get('SCHEDULER_TOKEN'), errors); const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors); const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors); if ( appOrigin && adapterOrigin && appOrigin.hostname !== adapterOrigin.hostname ) { errors.push('APP_ORIGIN and ORIGIN must use the same hostname.'); } const rpId = get('PASSKEY_RP_ID').toLowerCase(); if (rpId && !isHostname(rpId)) { errors.push('PASSKEY_RP_ID must be a hostname.'); } else if ( appOrigin && rpId && appOrigin.hostname !== rpId && !appOrigin.hostname.endsWith(`.${rpId}`) ) { errors.push( 'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.', ); } requirePair( 'DISCORD_CLIENT_ID', get('DISCORD_CLIENT_ID'), 'DISCORD_CLIENT_SECRET', get('DISCORD_CLIENT_SECRET'), errors, ); requirePair( 'TURNSTILE_SITE_KEY', get('TURNSTILE_SITE_KEY'), 'TURNSTILE_SECRET_KEY', get('TURNSTILE_SECRET_KEY'), errors, ); const smtpEnabled = [ 'SMTP_HOST', 'SMTP_FROM', 'SMTP_USER', 'SMTP_PASSWORD', ].some((name) => Boolean(get(name))); if (smtpEnabled) { requirePair( 'SMTP_HOST', get('SMTP_HOST'), 'SMTP_FROM', get('SMTP_FROM'), errors, ); requirePair( 'SMTP_USER', get('SMTP_USER'), 'SMTP_PASSWORD', get('SMTP_PASSWORD'), errors, ); } const smtpPort = get('SMTP_PORT'); if ( smtpPort && (!/^\d+$/.test(smtpPort) || Number(smtpPort) < 1 || Number(smtpPort) > 65535) ) { errors.push('SMTP_PORT must be an integer from 1 through 65535.'); } const smtpSecure = get('SMTP_SECURE'); if (smtpSecure && !['true', 'false'].includes(smtpSecure)) { errors.push('SMTP_SECURE must be true or false.'); } const discordRedirect = validateHttpUrl( 'DISCORD_REDIRECT_URI', get('DISCORD_REDIRECT_URI'), errors, ); if ( appOrigin && discordRedirect && (discordRedirect.hostname !== appOrigin.hostname || discordRedirect.pathname !== '/auth/discord/callback') ) { errors.push( 'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.', ); } for (const name of [ 'TDR_DATABASE_PATH', 'TDR_AVATAR_STORAGE_PATH', 'TDR_GAME_ICON_STORAGE_PATH', ]) { if (get(name) && !get(name).startsWith('/')) { errors.push(`${name} must be an absolute hosted path.`); } } return errors; } /** * @param {string} firstName * @param {string} firstValue * @param {string} secondName * @param {string} secondValue * @param {string[]} errors */ function requirePair(firstName, firstValue, secondName, secondValue, errors) { if (Boolean(firstValue) !== Boolean(secondValue)) { errors.push(`${firstName} and ${secondName} must be configured together.`); } } /** * @param {string} name * @param {string} value * @param {string[]} errors * @returns {URL | undefined} */ function validateHttpUrl(name, value, errors) { if (!value) return undefined; try { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) { throw new TypeError('not an HTTP URL'); } return url; } catch { errors.push(`${name} must be a valid HTTP or HTTPS URL.`); return undefined; } } /** * @param {string} name * @param {string} value * @param {string[]} errors * @returns {URL | undefined} */ function validateOrigin(name, value, errors) { const url = validateHttpUrl(name, value, errors); if (!url) return undefined; if ( url.username || url.password || url.pathname !== '/' || url.search || url.hash ) { errors.push(`${name} must contain only a URL origin.`); return undefined; } return url; } /** @param {string} value */ function isHostname(value) { if (value === 'localhost') return true; if (value.length > 253 || !value.includes('.')) return false; return value .split('.') .every( (label) => label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), ); } /** @param {string} value @param {string[]} errors */ function validateSchedulerToken(value, errors) { if (value && (value.length < 32 || /\s/.test(value))) { errors.push( 'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.', ); } }