#!/usr/bin/env node import { spawn } from 'node:child_process'; import { readFile, unlink } from 'node:fs/promises'; import process from 'node:process'; import { validateRuntimeEnvironment } from './runtime-environment.js'; const APP_SECRET_NAMES = new Set([ 'APP_ORIGIN', 'DISCORD_CLIENT_ID', 'DISCORD_CLIENT_SECRET', 'DISCORD_REDIRECT_URI', 'OPENAI_MECHANIC_API_KEY', 'OPENAI_MECHANIC_MODEL', 'OPENAI_MECHANIC_REASONING_EFFORT', 'OPENAI_MODERATION_API_KEY', 'ORIGIN', 'PASSKEY_RP_ID', 'REMOVE_BG_API_KEY', 'REMOVE_BG_CREDIT_CHECK_HOURS', 'REMOVE_BG_LOW_CREDIT_THRESHOLD', 'REMOVE_BG_LOW_FREE_CALL_THRESHOLD', 'SMTP_FROM', 'SMTP_HOST', 'SMTP_PASSWORD', 'SMTP_PORT', 'SMTP_SECURE', 'SMTP_USER', 'TDR_AVATAR_STORAGE_PATH', 'TDR_DATABASE_PATH', 'TDR_GAME_ICON_STORAGE_PATH', 'TURNSTILE_SECRET_KEY', 'TURNSTILE_SITE_KEY', ]); const SHARED_SECRET_NAMES = new Set(['SCHEDULER_TOKEN']); const REMOVED_ENVIRONMENT_NAMES = [ 'BASH_ENV', 'CDPATH', 'ENV', 'IFS', 'INFISICAL_ENVIRONMENT', 'INFISICAL_PROJECT_ID', 'INFISICAL_RUNTIME_SCOPE', 'INFISICAL_TOKEN', 'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE', 'LD_LIBRARY_PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'NODE_PATH', 'PYTHONPATH', 'TOKEN', ]; const [runtimeScope, environment, buildNumber = '0', commitSha = '', ...rest] = process.argv.slice(2); const separatorIndex = rest.indexOf('--'); if ( !['app', 'scheduler'].includes(runtimeScope) || !['staging', 'prod'].includes(environment) || separatorIndex < 1 ) { console.error( 'Usage: runtime-launcher.mjs -- ', ); process.exit(2); } const secretFiles = rest.slice(0, separatorIndex); const requestedCommand = rest.slice(separatorIndex + 1); if (requestedCommand.length === 0) { console.error('A runtime command is required.'); process.exit(2); } const expectedFileCount = runtimeScope === 'app' ? 2 : 1; if (secretFiles.length !== expectedFileCount) { console.error( `${runtimeScope} requires ${expectedFileCount} secret export file(s).`, ); process.exit(2); } const secrets = {}; try { await loadSecretFile( secretFiles[0], runtimeScope === 'app' ? APP_SECRET_NAMES : SHARED_SECRET_NAMES, secrets, ); if (runtimeScope === 'app') { await loadSecretFile(secretFiles[1], SHARED_SECRET_NAMES, secrets); } } finally { const deletionResults = await Promise.allSettled( secretFiles.map((file) => unlink(file)), ); const undeletedFiles = secretFiles.filter( (_, index) => deletionResults[index].status === 'rejected', ); if (undeletedFiles.length > 0) { throw new Error( `Failed to delete Infisical secret export: ${undeletedFiles.join(', ')}`, ); } } const childEnvironment = { ...process.env, ...secrets }; for (const name of REMOVED_ENVIRONMENT_NAMES) delete childEnvironment[name]; Object.assign( childEnvironment, trustedRuntimeEnvironment(runtimeScope, environment, buildNumber, commitSha), ); const errors = validateRuntimeEnvironment(runtimeScope, childEnvironment); if (errors.length > 0) { console.error( `Invalid ${runtimeScope} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`, ); process.exit(1); } process.umask(0o007); if (requestedCommand[0] === 'serve') { await runAsNode(['node', 'scripts/db-migrate.mjs'], childEnvironment); await runAsNode(['node', 'build'], childEnvironment); } else if (requestedCommand[0] === 'scheduler') { await runAsNode(['node', 'scripts/scheduler-client.mjs'], childEnvironment); } else { await runAsNode(requestedCommand, childEnvironment); } async function loadSecretFile(file, allowedNames, target) { let entries; try { entries = JSON.parse(await readFile(file, 'utf8')); } catch { throw new Error('Infisical returned an unreadable secret export.'); } if (!Array.isArray(entries)) { throw new Error('Infisical secret export must be an array.'); } for (const entry of entries) { const name = entry?.key; const value = entry?.value; if (typeof name !== 'string' || typeof value !== 'string') { throw new Error('Infisical secret export contains an invalid entry.'); } if (!allowedNames.has(name)) { throw new Error( `Infisical secret ${name} is not allowed in this runtime scope.`, ); } if (Object.hasOwn(target, name)) { throw new Error(`Infisical secret ${name} was exported more than once.`); } target[name] = value; } } function trustedRuntimeEnvironment( scope, targetEnvironment, release, revision, ) { const common = { BODY_SIZE_LIMIT: '6M', HOST: '0.0.0.0', NODE_ENV: 'production', PORT: '3000', SHD_BUILD_NUMBER: release, SHD_COMMIT_SHA: revision, }; if (scope === 'scheduler') { return { ...common, SCHEDULER_URL: 'http://app:3000/api/internal/scheduler', }; } if (targetEnvironment === 'staging') { return { ...common, APP_ORIGIN: 'https://stage.shd.build', DISCORD_REDIRECT_URI: 'https://stage.shd.build/auth/discord/callback', ORIGIN: 'https://stage.shd.build', PASSKEY_RP_ID: 'shd.build', SKIP_DEFAULT_USERS: 'true', TDR_AVATAR_STORAGE_PATH: '/app/data/avatars', TDR_DATABASE_PATH: '/app/data/shd-build-staging.db', TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons', UBISOFT_NEWS_IMPORT_ENABLED: 'false', }; } return { ...common, APP_ORIGIN: 'https://shd.build', DISCORD_REDIRECT_URI: 'https://shd.build/auth/discord/callback', ORIGIN: 'https://shd.build', PASSKEY_RP_ID: 'shd.build', SKIP_DEFAULT_USERS: 'true', TDR_AVATAR_STORAGE_PATH: '/app/data/avatars', TDR_DATABASE_PATH: '/app/data/shd-build-production.db', TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons', UBISOFT_NEWS_IMPORT_ENABLED: 'true', }; } function runAsNode(command, environmentVariables) { const usesPrivilegeDrop = typeof process.getuid === 'function' && process.getuid() === 0; const executable = usesPrivilegeDrop ? 'gosu' : command[0]; const args = usesPrivilegeDrop ? ['shd-runtime', ...command] : command.slice(1); return new Promise((resolve, reject) => { const child = spawn(executable, args, { env: environmentVariables, stdio: 'inherit', }); const signalHandlers = new Map(); for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) { const handler = () => child.kill(signal); signalHandlers.set(signal, handler); process.once(signal, handler); } const cleanup = () => { for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } }; child.once('error', (error) => { cleanup(); reject(error); }); child.once('exit', (code, signal) => { cleanup(); if (signal) { process.kill(process.pid, signal); } else if (code === 0) { resolve(); } else { reject(new Error(`Runtime command exited with status ${code ?? 1}.`)); } }); }); }