Files
shd.build/scripts/runtime-environment.js

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);
}
}