feat: migrate secrets to Infisical
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const files = ['.env.development', '.env.staging', '.env.production'];
|
||||
const action = process.argv[2];
|
||||
const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
|
||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function dotenvx(command, file) {
|
||||
run(process.execPath, [dotenvxCli, command, '-f', file]);
|
||||
}
|
||||
|
||||
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
|
||||
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
|
||||
}
|
||||
|
||||
if (action === 'stage') {
|
||||
run(gitCommand, ['add', '--', ...files]);
|
||||
console.log('Encrypted and staged environment files.');
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/** @typedef {'app' | 'scheduler'} RuntimeMode */
|
||||
/** @typedef {Record<string, string | undefined>} 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||
|
||||
const validAppEnvironment = {
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
PASSKEY_RP_ID: 'shd.build',
|
||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||
};
|
||||
|
||||
describe('validateRuntimeEnvironment', () => {
|
||||
it('accepts the hosted app contract without optional providers', () => {
|
||||
expect(validateRuntimeEnvironment('app', validAppEnvironment)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports every missing required app value', () => {
|
||||
expect(validateRuntimeEnvironment('app', {})).toEqual([
|
||||
'APP_ORIGIN is required.',
|
||||
'ORIGIN is required.',
|
||||
'PASSKEY_RP_ID is required.',
|
||||
'SCHEDULER_TOKEN is required.',
|
||||
'TDR_DATABASE_PATH is required.',
|
||||
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||
'TDR_GAME_ICON_STORAGE_PATH is required.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates canonical origins and passkey hostname scope', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
APP_ORIGIN: 'https://stage.shd.build/sign-in',
|
||||
}),
|
||||
).toContain('APP_ORIGIN must contain only a URL origin.');
|
||||
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
PASSKEY_RP_ID: 'example.com',
|
||||
}),
|
||||
).toContain(
|
||||
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects mismatched app and adapter hostnames', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
ORIGIN: 'https://other.shd.build',
|
||||
}),
|
||||
).toContain('APP_ORIGIN and ORIGIN must use the same hostname.');
|
||||
});
|
||||
|
||||
it('requires optional integration credentials only as pairs', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
DISCORD_CLIENT_ID: 'client-id',
|
||||
TURNSTILE_SECRET_KEY: 'secret',
|
||||
}),
|
||||
).toEqual([
|
||||
'DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be configured together.',
|
||||
'TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be configured together.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows SMTP defaults without enabling email and validates enabled SMTP', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
SMTP_PORT: '587',
|
||||
SMTP_SECURE: 'false',
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_USER: 'agent',
|
||||
SMTP_PORT: '70000',
|
||||
}),
|
||||
).toEqual([
|
||||
'SMTP_HOST and SMTP_FROM must be configured together.',
|
||||
'SMTP_USER and SMTP_PASSWORD must be configured together.',
|
||||
'SMTP_PORT must be an integer from 1 through 65535.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates optional redirect URLs when supplied', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
DISCORD_REDIRECT_URI: 'ftp://stage.shd.build/callback',
|
||||
}),
|
||||
).toContain('DISCORD_REDIRECT_URI must be a valid HTTP or HTTPS URL.');
|
||||
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
DISCORD_REDIRECT_URI: 'https://dev.shd.build/auth/discord/callback',
|
||||
}),
|
||||
).toContain(
|
||||
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates hosted paths and SMTP TLS configuration', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
SMTP_SECURE: 'yes',
|
||||
TDR_DATABASE_PATH: './data/staging.db',
|
||||
}),
|
||||
).toEqual([
|
||||
'SMTP_SECURE must be true or false.',
|
||||
'TDR_DATABASE_PATH must be an absolute hosted path.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a configured scheduler and rejects missing or invalid values', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('scheduler', {
|
||||
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
validateRuntimeEnvironment('scheduler', {
|
||||
SCHEDULER_URL: 'not a URL',
|
||||
}),
|
||||
).toEqual([
|
||||
'SCHEDULER_TOKEN is required.',
|
||||
'SCHEDULER_URL must be a valid HTTP or HTTPS URL.',
|
||||
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a strong scheduler token and fixed internal endpoint', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('scheduler', {
|
||||
SCHEDULER_URL: 'https://example.com/api/internal/scheduler',
|
||||
SCHEDULER_TOKEN: 'too-short',
|
||||
}),
|
||||
).toEqual([
|
||||
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unsupported runtime modes', () => {
|
||||
expect(() =>
|
||||
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||
).toThrow('Unsupported runtime mode: worker');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/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 <app|scheduler> <staging|prod> <build> <sha> <secret-files...> -- <command...>',
|
||||
);
|
||||
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}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const stagingFile = resolve('.env.staging');
|
||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
||||
const sharedKeys = [
|
||||
'DISCORD_CLIENT_ID',
|
||||
'DISCORD_CLIENT_SECRET',
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
'SMTP_SECURE',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASSWORD',
|
||||
'SMTP_FROM',
|
||||
];
|
||||
|
||||
for (const key of sharedKeys) {
|
||||
if (!process.env[key])
|
||||
throw new Error(`Source environment is missing ${key}.`);
|
||||
}
|
||||
|
||||
function dotenvx(action) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[dotenvxCli, action, '-f', stagingFile],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
|
||||
}
|
||||
|
||||
dotenvx('decrypt');
|
||||
try {
|
||||
let contents = await readFile(stagingFile, 'utf8');
|
||||
for (const key of sharedKeys) {
|
||||
const replacement = `${key}=${JSON.stringify(process.env[key])}`;
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (!pattern.test(contents))
|
||||
throw new Error(`Staging environment is missing ${key}.`);
|
||||
contents = contents.replace(pattern, replacement);
|
||||
}
|
||||
await writeFile(stagingFile, contents, 'utf8');
|
||||
} finally {
|
||||
dotenvx('encrypt');
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Copied shared Discord and SMTP credentials into encrypted staging configuration.',
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process';
|
||||
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||
|
||||
const mode = process.argv[2];
|
||||
if (mode !== 'app' && mode !== 'scheduler') {
|
||||
console.error(
|
||||
'Usage: node scripts/validate-runtime-environment.mjs <app|scheduler>',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const errors = validateRuntimeEnvironment(mode, process.env);
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`Invalid ${mode} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user