292 lines
7.4 KiB
JavaScript
292 lines
7.4 KiB
JavaScript
/** @typedef {'app' | 'scheduler'} RuntimeMode */
|
|
/** @typedef {Record<string, string | undefined>} RuntimeEnvironment */
|
|
|
|
const APP_REQUIRED = [
|
|
'APP_ORIGIN',
|
|
'ORIGIN',
|
|
'PASSKEY_RP_ID',
|
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
|
'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);
|
|
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), 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.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate the app-only TOTP encryption keyring without including configured
|
|
* values in an error. The first entry is the active write key.
|
|
*
|
|
* @param {string} value
|
|
* @param {string[]} errors
|
|
*/
|
|
function validateMfaTotpEncryptionKeys(value, errors) {
|
|
if (!value) return;
|
|
const entries = value.split(',').map((entry) => entry.trim());
|
|
if (
|
|
entries.length < 1 ||
|
|
entries.length > 3 ||
|
|
entries.some((entry) => !entry)
|
|
) {
|
|
errors.push(
|
|
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const ids = new Set();
|
|
const keys = new Set();
|
|
for (const entry of entries) {
|
|
const separator = entry.indexOf(':');
|
|
const keyId = separator > 0 ? entry.slice(0, separator) : '';
|
|
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
|
|
if (
|
|
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
|
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
|
|
encodedKey.length !== 43
|
|
) {
|
|
errors.push(
|
|
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
|
);
|
|
continue;
|
|
}
|
|
let decoded;
|
|
try {
|
|
decoded = Buffer.from(encodedKey, 'base64url');
|
|
} catch {
|
|
decoded = Buffer.alloc(0);
|
|
}
|
|
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
|
|
errors.push(
|
|
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
|
);
|
|
continue;
|
|
}
|
|
if (ids.has(keyId) || keys.has(encodedKey)) {
|
|
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
|
|
}
|
|
ids.add(keyId);
|
|
keys.add(encodedKey);
|
|
}
|
|
}
|