mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
642 lines
34 KiB
JavaScript
642 lines
34 KiB
JavaScript
/**
|
||
* Startup configuration validation.
|
||
*
|
||
* Development remains forgiving so a contributor can boot individual subsystems. Production is
|
||
* deliberately fail-closed: an ambiguous money or block-source setting must never silently fall
|
||
* back to a dangerous default.
|
||
*/
|
||
|
||
const { isSupported, familyFor } = require('../chain/chainProfile');
|
||
const { resolveMatchRuleset } = require('../game/rulesets');
|
||
const money = require('../money/atomic');
|
||
const { validateOperatedProductProfile } = require('./operatedProductProfiles');
|
||
const {
|
||
PG_BIGINT_MAX,
|
||
cryptoRulesetSupported,
|
||
parseHouseFeeBasisPoints,
|
||
parsePositiveAtomic,
|
||
playerContract
|
||
} = require('../network/matchEconomyPolicy');
|
||
|
||
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
||
const BOOLEAN_VALUES = new Set(['true', 'false', '1', '0', 'yes', 'no', 'y', 'n', 'on', 'off']);
|
||
const PLACEHOLDER_SECRET = /(change[_-]?me|replace[_-]?with|your[_-]|example|password|secret)/i;
|
||
|
||
const BOOLEAN_ENV_KEYS = [
|
||
'PAYMENTS_ENABLED', 'PAYOUTS_ENABLED', 'DIRECT_PAYMENT_ENABLED',
|
||
'DIRECT_PAYOUTS_ENABLED', 'CREDITS_ENABLED', 'CREDITS_PAYOUTS_ENABLED',
|
||
'CREDITS_PAYOUT_ENABLED', 'FREE_PLAY_ENABLED', 'DIRECT_REQUIRES_ADDRESS',
|
||
'DIRECT_ALLOW_GUEST_PLAY', 'CREDITS_REQUIRES_ADDRESS', 'ALLOW_MIXED_MODE',
|
||
'PREFER_CREDITS_FIRST', 'EARLY_ENTRY_ENABLED', 'EARLY_ENTRY_FREE_MODE',
|
||
'EARLY_ENTRY_CREDITS_MODE', 'ALLOW_MAINNET_PAYOUTS', 'TRUST_PROXY',
|
||
'SMIRK_ENABLED', 'TAVERN_ENABLED', 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED',
|
||
'MATCH_PAYOUTS_ENABLED',
|
||
'SIMULATED_BLOCKS', 'FORCE_SIMULATED_BLOCKS', 'DEBUG_HOTKEYS',
|
||
'PAID_ACKNOWLEDGEMENT_REQUIRED'
|
||
];
|
||
|
||
const ATOMIC_ENV_KEYS = [
|
||
'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE',
|
||
'CREDITS_PAYOUT_BASE', 'PAYOUT_MIN_AMOUNT', 'PAYOUT_MAX_PER_GAME',
|
||
'CREDITS_PAYOUT_MIN', 'CREDITS_PAYOUT_MAX', 'MAX_CREDIT_PURCHASE_PER_DAY',
|
||
'BALANCE_WARN', 'BALANCE_CRITICAL', 'LOW_BALANCE_THRESHOLD', 'MATCH_ENTRY_FEE_ATOMIC',
|
||
'MATCH_PAYOUT_MAX'
|
||
];
|
||
|
||
const POSITIVE_ATOMIC_ENV_KEYS = new Set([
|
||
'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE',
|
||
'CREDITS_PAYOUT_BASE', 'PAYOUT_MAX_PER_GAME', 'CREDITS_PAYOUT_MAX',
|
||
'MAX_CREDIT_PURCHASE_PER_DAY', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_PAYOUT_MAX'
|
||
]);
|
||
|
||
function isTrue(value) {
|
||
return TRUE_VALUES.has(String(value || '').trim().toLowerCase());
|
||
}
|
||
|
||
function canonicalInteger(value) {
|
||
return String(value ?? '').trim().replace(/_/g, '');
|
||
}
|
||
|
||
function raceTicketGrant(product) {
|
||
const grants = product?.grants;
|
||
if (!grants || typeof grants !== 'object') return null;
|
||
const hasEntries = Object.prototype.hasOwnProperty.call(grants, 'race_entries')
|
||
|| Object.prototype.hasOwnProperty.call(grants, 'raceEntries');
|
||
if (!hasEntries) return null;
|
||
return {
|
||
productId: String(product.id || '(unnamed)'),
|
||
entries: grants.race_entries ?? grants.raceEntries,
|
||
valueAtomic: grants.race_entry_value_atomic ?? grants.raceEntryValueAtomic,
|
||
price: product.price
|
||
};
|
||
}
|
||
|
||
function isLoopbackHost(hostname) {
|
||
const normalized = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
|
||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
|
||
}
|
||
|
||
class EnvironmentValidator {
|
||
constructor(options = {}) {
|
||
this.logger = options.logger || console;
|
||
this.env = options.env || process.env;
|
||
}
|
||
|
||
_endpointIssue(name, value) {
|
||
if (!value) return `${name} is not set.`;
|
||
try {
|
||
const endpoint = new URL(value);
|
||
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
||
return `${name} must use http:// or https://.`;
|
||
}
|
||
return null;
|
||
} catch (_) {
|
||
return `${name} must be a valid URL.`;
|
||
}
|
||
}
|
||
|
||
_pushEndpointTransportWarning(warnings, name, value) {
|
||
if (!value) return;
|
||
try {
|
||
const endpoint = new URL(value);
|
||
if (endpoint.protocol === 'http:' && !isLoopbackHost(endpoint.hostname)) {
|
||
warnings.push(`${name} uses plaintext HTTP on a non-loopback host; use TLS or a private authenticated network.`);
|
||
}
|
||
} catch (_) {
|
||
// Syntax errors are reported separately.
|
||
}
|
||
}
|
||
|
||
validate(config) {
|
||
const env = this.env;
|
||
const warnings = [];
|
||
const errors = [];
|
||
const production = String(env.NODE_ENV || '').toLowerCase() === 'production';
|
||
const paymentsEnabled = Boolean(config?.paymentsEnabled);
|
||
const directEnabled = paymentsEnabled && Boolean(config?.modes?.direct?.enabled);
|
||
const creditsEnabled = paymentsEnabled && Boolean(config?.modes?.credits?.enabled);
|
||
const payoutMasterEnabled = Boolean(config?.payouts?.enabled);
|
||
const directPayoutsEnabled = payoutMasterEnabled && directEnabled
|
||
&& Boolean(config?.payouts?.rules?.direct?.enabled);
|
||
const creditsPayoutsEnabled = payoutMasterEnabled && creditsEnabled
|
||
&& Boolean(config?.payouts?.rules?.credits?.enabled);
|
||
const matchModeEnabled = isTrue(env.MATCH_ENABLED);
|
||
const matchCryptoEnabled = matchModeEnabled && isTrue(env.MATCH_CRYPTO_RACE_ENABLED);
|
||
const matchPayoutsEnabled = payoutMasterEnabled && matchCryptoEnabled
|
||
&& isTrue(env.MATCH_PAYOUTS_ENABLED);
|
||
const anyPayoutsEnabled = directPayoutsEnabled || creditsPayoutsEnabled || matchPayoutsEnabled;
|
||
// PAYOUTS_ENABLED also authorizes settlement of already-created durable rows even when
|
||
// every admission switch is off, so payout-only recovery has the same wallet boundary.
|
||
const walletRequired = paymentsEnabled || payoutMasterEnabled;
|
||
const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || 'WOW').trim().toUpperCase();
|
||
const network = String(env.MONERO_NETWORK || 'mainnet').trim().toLowerCase();
|
||
const financialSinkUrl = String(env.FINANCIAL_EVENT_SINK_URL || '').trim();
|
||
const financialSinkToken = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim();
|
||
|
||
if (env.GAME_MODE && env.PAYMENT_MODES) {
|
||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||
}
|
||
|
||
if (!isSupported(cryptoType)) {
|
||
errors.push(`CRYPTO_TYPE=${cryptoType || '(empty)'} is not a supported chain profile.`);
|
||
} else if (walletRequired && familyFor(cryptoType) !== 'monero') {
|
||
errors.push(`Paid production adapters are not implemented for CRYPTO_TYPE=${cryptoType}; use WOW or XMR.`);
|
||
}
|
||
|
||
if (cryptoType === 'WOW' && network !== 'mainnet') {
|
||
errors.push('Wownero only supports MONERO_NETWORK=mainnet.');
|
||
}
|
||
if (cryptoType === 'XMR' && !['mainnet', 'stagenet', 'testnet'].includes(network)) {
|
||
errors.push('MONERO_NETWORK must be mainnet, stagenet, or testnet for XMR.');
|
||
}
|
||
|
||
const walletIssue = this._endpointIssue('PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT);
|
||
if (walletRequired && walletIssue) {
|
||
(production ? errors : warnings).push(`Financial workers require a wallet but ${walletIssue}`);
|
||
}
|
||
if (Boolean(env.WALLET_RPC_USER) !== Boolean(env.WALLET_RPC_PASSWORD)) {
|
||
(production ? errors : warnings).push('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.');
|
||
}
|
||
if (production && paymentsEnabled) {
|
||
if (!isTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED)) {
|
||
errors.push('Production payments require PAID_ACKNOWLEDGEMENT_REQUIRED=true; the server will not accept a paid action without the current acknowledgement.');
|
||
}
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(env.LEGAL_POLICY_VERSION || ''))) {
|
||
errors.push('LEGAL_POLICY_VERSION must be an explicit 1-64 character version identifier for production payments.');
|
||
}
|
||
const effectiveDate = String(env.TERMS_EFFECTIVE_DATE || '');
|
||
const parsedEffectiveDate = /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate)
|
||
? new Date(`${effectiveDate}T00:00:00Z`)
|
||
: null;
|
||
if (!parsedEffectiveDate || Number.isNaN(parsedEffectiveDate.getTime())
|
||
|| parsedEffectiveDate.toISOString().slice(0, 10) !== effectiveDate) {
|
||
errors.push('TERMS_EFFECTIVE_DATE must be an explicit valid YYYY-MM-DD date for production payments.');
|
||
}
|
||
const configuredMinimumAge = Number(env.MINIMUM_AGE);
|
||
if (!Number.isInteger(configuredMinimumAge) || configuredMinimumAge < 18 || configuredMinimumAge > 120) {
|
||
errors.push('MINIMUM_AGE must be an explicit integer from 18 through 120 for production payments.');
|
||
}
|
||
const operatorName = String(env.OPERATOR_NAME || '').trim();
|
||
if (operatorName.length < 2 || operatorName.length > 120 || PLACEHOLDER_SECRET.test(operatorName)) {
|
||
errors.push('OPERATOR_NAME must identify the responsible operator without placeholder text for production payments.');
|
||
}
|
||
try {
|
||
const contact = new URL(String(env.OPERATOR_CONTACT_URL || ''));
|
||
if (!['https:', 'mailto:'].includes(contact.protocol)) {
|
||
errors.push('OPERATOR_CONTACT_URL must use https:// or mailto: for production payments.');
|
||
}
|
||
if (PLACEHOLDER_SECRET.test(String(env.OPERATOR_CONTACT_URL || ''))) {
|
||
errors.push('OPERATOR_CONTACT_URL must not contain placeholder text for production payments.');
|
||
}
|
||
} catch (_) {
|
||
errors.push('OPERATOR_CONTACT_URL must be an explicit valid https:// or mailto: URL for production payments.');
|
||
}
|
||
}
|
||
if (production && walletRequired) {
|
||
if (!env.WALLET_RPC_USER || !env.WALLET_RPC_PASSWORD) {
|
||
errors.push('Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.');
|
||
} else if (String(env.WALLET_RPC_PASSWORD).length < 24
|
||
|| PLACEHOLDER_SECRET.test(String(env.WALLET_RPC_PASSWORD))) {
|
||
errors.push('WALLET_RPC_PASSWORD must be a strong non-placeholder secret of at least 24 characters in production.');
|
||
}
|
||
if (env.PRIMARY_WALLET_ENDPOINT) {
|
||
try {
|
||
const walletEndpoint = new URL(env.PRIMARY_WALLET_ENDPOINT);
|
||
if (walletEndpoint.protocol === 'http:' && !isLoopbackHost(walletEndpoint.hostname)) {
|
||
errors.push('Production wallet-rpc over plaintext HTTP must bind to a loopback address.');
|
||
}
|
||
} catch (_) {
|
||
// Endpoint syntax is reported by _endpointIssue above.
|
||
}
|
||
}
|
||
}
|
||
if (env.HOSTED_BY) {
|
||
try {
|
||
const hostedBy = new URL(env.HOSTED_BY);
|
||
if (!['http:', 'https:'].includes(hostedBy.protocol)) {
|
||
(production ? errors : warnings).push('HOSTED_BY must use http:// or https://.');
|
||
}
|
||
if (production && hostedBy.protocol !== 'https:') {
|
||
errors.push('HOSTED_BY must use https:// in production.');
|
||
}
|
||
} catch (_) {
|
||
(production ? errors : warnings).push('HOSTED_BY must be a valid public URL.');
|
||
}
|
||
}
|
||
const daemonIssue = this._endpointIssue('PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT);
|
||
if (production && daemonIssue) {
|
||
errors.push(`Production block timing requires a daemon RPC: ${daemonIssue}`);
|
||
} else if (paymentsEnabled && daemonIssue) {
|
||
warnings.push(`Payments are enabled but ${daemonIssue}`);
|
||
}
|
||
this._pushEndpointTransportWarning(warnings, 'PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT);
|
||
this._pushEndpointTransportWarning(warnings, 'PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT);
|
||
|
||
// Accounting export is optional, but a partial or misleading configuration is not. The
|
||
// stagenet product intentionally suppresses its no-value test events instead of sending
|
||
// them to a real accounting sink.
|
||
if (Boolean(financialSinkUrl) !== Boolean(financialSinkToken)) {
|
||
errors.push('FINANCIAL_EVENT_SINK_URL and FINANCIAL_EVENT_SINK_TOKEN must be configured together.');
|
||
}
|
||
if (financialSinkUrl && financialSinkToken) {
|
||
try {
|
||
const endpoint = new URL(financialSinkUrl);
|
||
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
||
errors.push('FINANCIAL_EVENT_SINK_URL must use http:// or https://.');
|
||
}
|
||
if (endpoint.username || endpoint.password) {
|
||
errors.push('FINANCIAL_EVENT_SINK_URL must not embed credentials.');
|
||
}
|
||
if (production && endpoint.protocol !== 'https:') {
|
||
errors.push('FINANCIAL_EVENT_SINK_URL must use https:// in production.');
|
||
}
|
||
} catch (_) {
|
||
errors.push('FINANCIAL_EVENT_SINK_URL must be a valid URL.');
|
||
}
|
||
if (financialSinkToken.length < 32
|
||
|| PLACEHOLDER_SECRET.test(financialSinkToken)
|
||
|| new Set(financialSinkToken).size < 8) {
|
||
errors.push('FINANCIAL_EVENT_SINK_TOKEN must be a strong non-placeholder secret of at least 32 characters.');
|
||
}
|
||
if (network !== 'mainnet') {
|
||
errors.push('Financial event export must be unset on non-mainnet networks; test-network events are suppressed locally.');
|
||
}
|
||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(accountRef)) {
|
||
errors.push('FINANCIAL_EVENT_ACCOUNT_REF must be a non-PII identifier using only letters, digits, dot, underscore, colon, slash, or hyphen.');
|
||
}
|
||
}
|
||
|
||
if (config?.modes?.direct?.enabled && config.modes.direct.price <= 0n) {
|
||
errors.push('Direct payment mode is enabled but its price is not positive.');
|
||
}
|
||
if (config?.modes?.credits?.enabled && !config.modes.credits.packages?.length) {
|
||
errors.push('Credits mode is enabled but no credit packages are defined.');
|
||
}
|
||
|
||
if (env.CREDITS_PACKAGES) {
|
||
try {
|
||
const packages = JSON.parse(env.CREDITS_PACKAGES);
|
||
if (!Array.isArray(packages)) {
|
||
errors.push('CREDITS_PACKAGES must be a JSON array.');
|
||
} else if (packages.length === 0) {
|
||
errors.push('CREDITS_PACKAGES must not be empty when explicitly configured.');
|
||
} else {
|
||
packages.forEach((pkg, index) => {
|
||
if (!pkg || typeof pkg !== 'object') {
|
||
errors.push(`CREDITS_PACKAGES[${index}] must be an object.`);
|
||
return;
|
||
}
|
||
if (!pkg.id) errors.push(`CREDITS_PACKAGES[${index}] is missing "id".`);
|
||
if (!Number.isInteger(Number(pkg.credits)) || Number(pkg.credits) <= 0) {
|
||
errors.push(`CREDITS_PACKAGES[${index}].credits must be a positive integer.`);
|
||
}
|
||
try {
|
||
if (BigInt(pkg.price) <= 0n) throw new Error('not positive');
|
||
} catch (_) {
|
||
errors.push(`CREDITS_PACKAGES[${index}].price must be a positive atomic-unit integer.`);
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
errors.push(`CREDITS_PACKAGES is not valid JSON: ${error.message}`);
|
||
}
|
||
} else if (creditsEnabled) {
|
||
warnings.push('Credits mode is using built-in package defaults; set CREDITS_PACKAGES explicitly for production pricing.');
|
||
}
|
||
|
||
if (!payoutMasterEnabled
|
||
&& (Boolean(config?.payouts?.rules?.direct?.enabled) || Boolean(config?.payouts?.rules?.credits?.enabled))) {
|
||
warnings.push('Payout rules are enabled but PAYOUTS_ENABLED is off; the master switch suppresses all payout creation and dispatch.');
|
||
}
|
||
|
||
// A Such Software operated-product identifier is an exact public claim in every
|
||
// environment. Never let a development or preview process impersonate that identity
|
||
// while bypassing the host/network/economic contract.
|
||
errors.push(...validateOperatedProductProfile(env, config));
|
||
|
||
if (production) {
|
||
for (const key of BOOLEAN_ENV_KEYS) {
|
||
if (Object.prototype.hasOwnProperty.call(env, key)
|
||
&& !BOOLEAN_VALUES.has(String(env[key]).trim().toLowerCase())) {
|
||
errors.push(`${key} must be an explicit boolean (true or false).`);
|
||
}
|
||
}
|
||
|
||
for (const key of ATOMIC_ENV_KEYS) {
|
||
if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue;
|
||
const raw = canonicalInteger(env[key]);
|
||
if (!/^\d+$/.test(raw)) {
|
||
errors.push(`${key} must be a non-negative integer in atomic units.`);
|
||
continue;
|
||
}
|
||
try {
|
||
if (POSITIVE_ATOMIC_ENV_KEYS.has(key) && BigInt(raw) <= 0n) {
|
||
errors.push(`${key} must be greater than zero.`);
|
||
}
|
||
} catch (_) {
|
||
errors.push(`${key} must be a valid atomic-unit integer.`);
|
||
}
|
||
}
|
||
|
||
if (env.GAME_MODE && !['FREE', 'PAID_SINGLE', 'PAID_CREDITS'].includes(String(env.GAME_MODE).trim().toUpperCase())) {
|
||
errors.push('GAME_MODE must be FREE, PAID_SINGLE, or PAID_CREDITS.');
|
||
}
|
||
if (env.PAYMENT_MODES) {
|
||
const configuredModes = String(env.PAYMENT_MODES).split(',')
|
||
.map(mode => mode.trim().toLowerCase()).filter(Boolean);
|
||
const invalidModes = configuredModes.filter(mode => !['direct', 'credits'].includes(mode));
|
||
if (configuredModes.length === 0 || invalidModes.length > 0) {
|
||
errors.push('PAYMENT_MODES must contain only direct and/or credits.');
|
||
}
|
||
}
|
||
|
||
for (const key of [
|
||
'DIRECT_PAYOUT_ESCAPE', 'DIRECT_PAYOUT_TREASURE',
|
||
'CREDITS_PAYOUT_ESCAPE', 'CREDITS_PAYOUT_TREASURE'
|
||
]) {
|
||
if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue;
|
||
const multiplier = Number(env[key]);
|
||
if (!Number.isFinite(multiplier) || multiplier <= 0) {
|
||
errors.push(`${key} must be a finite number greater than zero.`);
|
||
}
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(env, 'MATCH_HOUSE_FEE_PERCENT')) {
|
||
if (parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT) === null) {
|
||
errors.push('MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.');
|
||
}
|
||
}
|
||
if (isTrue(env.TRUST_PROXY)) {
|
||
const proxyHops = Number(env.TRUST_PROXY_HOPS || 1);
|
||
if (!Number.isInteger(proxyHops) || proxyHops < 1 || proxyHops > 8) {
|
||
errors.push('TRUST_PROXY_HOPS must be an integer from 1 through 8.');
|
||
}
|
||
}
|
||
|
||
if (matchModeEnabled) {
|
||
for (const key of [
|
||
'MATCH_PAID_ENTROPY_DELAY_BLOCKS',
|
||
'MATCH_PAID_ENTROPY_CONFIRMATIONS'
|
||
]) {
|
||
const raw = String(env[key] ?? '').trim();
|
||
const value = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
||
if (!Number.isSafeInteger(value) || value < 2 || value > 100) {
|
||
errors.push(`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (matchCryptoEnabled && !Object.prototype.hasOwnProperty.call(env, 'MATCH_PAYOUTS_ENABLED')) {
|
||
errors.push('Production crypto matches require an explicit MATCH_PAYOUTS_ENABLED=true|false setting.');
|
||
}
|
||
if (isTrue(env.MATCH_PAYOUTS_ENABLED) && !matchCryptoEnabled) {
|
||
errors.push('MATCH_PAYOUTS_ENABLED=true requires MATCH_ENABLED=true and MATCH_CRYPTO_RACE_ENABLED=true.');
|
||
}
|
||
if (matchPayoutsEnabled) {
|
||
if (!directEnabled && !creditsEnabled) {
|
||
errors.push('Crypto match payouts require at least one enabled paid product mode for race-entry tickets.');
|
||
}
|
||
const payoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX);
|
||
if (payoutCap === null) {
|
||
errors.push('MATCH_PAYOUT_MAX must be an explicit positive PostgreSQL BIGINT atomic-unit cap when crypto match payouts are enabled.');
|
||
}
|
||
|
||
const ruleset = resolveMatchRuleset(env.MATCH_RULESET_ID || 'race');
|
||
if (!cryptoRulesetSupported(ruleset)) {
|
||
errors.push(`MATCH_RULESET_ID=${ruleset.id} does not have supported single-winner crypto payout semantics.`);
|
||
}
|
||
const requestedMaxRaw = String(env.MATCH_MAX_PLAYERS ?? '').trim();
|
||
const requestedMax = /^\d+$/.test(requestedMaxRaw)
|
||
? Number(requestedMaxRaw)
|
||
: NaN;
|
||
const contract = playerContract(ruleset, env.MATCH_MAX_PLAYERS || ruleset.players.max);
|
||
if (!Number.isInteger(requestedMax)
|
||
|| requestedMax < contract.minPlayers
|
||
|| requestedMax > ruleset.players.max
|
||
|| requestedMax > 32) {
|
||
errors.push(`MATCH_MAX_PLAYERS must be an integer from ${contract.minPlayers} through ${Math.min(32, ruleset.players.max)} for ruleset ${ruleset.id}.`);
|
||
}
|
||
|
||
const entryFee = parsePositiveAtomic(env.MATCH_ENTRY_FEE_ATOMIC);
|
||
const feeBasisPoints = parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT);
|
||
if (entryFee === null) {
|
||
errors.push('MATCH_ENTRY_FEE_ATOMIC must be an explicit positive PostgreSQL BIGINT atomic-unit value when crypto match payouts are enabled.');
|
||
}
|
||
if (feeBasisPoints === null) {
|
||
errors.push('MATCH_HOUSE_FEE_PERCENT must be explicitly set from 0 (inclusive) to 100 (exclusive) when crypto match payouts are enabled.');
|
||
}
|
||
|
||
const catalog = [
|
||
...(config?.modes?.credits?.packages || []),
|
||
...(config?.products?.cosmetic || [])
|
||
];
|
||
const ticketProducts = catalog.map(raceTicketGrant).filter(Boolean);
|
||
if (ticketProducts.length === 0) {
|
||
errors.push('Crypto match payouts require at least one configured race-entry product with explicit per-ticket atomic backing.');
|
||
}
|
||
for (const ticket of ticketProducts) {
|
||
const rawEntries = String(ticket.entries ?? '').trim();
|
||
let ticketEntries = null;
|
||
if (/^\d+$/.test(rawEntries)) {
|
||
try {
|
||
const parsed = BigInt(rawEntries);
|
||
if (parsed > 0n && parsed <= 2147483647n) ticketEntries = parsed;
|
||
} catch (_) { /* reported below */ }
|
||
}
|
||
if (ticketEntries === null) {
|
||
errors.push(`Race-entry product ${ticket.productId} must grant a positive integer number of tickets.`);
|
||
continue;
|
||
}
|
||
const ticketValue = parsePositiveAtomic(ticket.valueAtomic);
|
||
if (ticketValue === null) {
|
||
errors.push(`Race-entry product ${ticket.productId} must set grants.race_entry_value_atomic to a positive PostgreSQL BIGINT atomic-unit value.`);
|
||
continue;
|
||
}
|
||
if (entryFee !== null && ticketValue !== entryFee) {
|
||
errors.push(`Race-entry product ${ticket.productId} backing must equal MATCH_ENTRY_FEE_ATOMIC (${entryFee} atomic units per ticket).`);
|
||
}
|
||
const price = parsePositiveAtomic(ticket.price);
|
||
const requiredBacking = ticketValue * ticketEntries;
|
||
if (price === null || price < requiredBacking) {
|
||
errors.push(`Race-entry product ${ticket.productId} price must fund at least ${requiredBacking} atomic units of ticket backing.`);
|
||
}
|
||
}
|
||
|
||
if (entryFee !== null && payoutCap !== null && feeBasisPoints !== null
|
||
&& Number.isInteger(requestedMax)) {
|
||
const pot = entryFee * BigInt(requestedMax);
|
||
const feeBp = BigInt(feeBasisPoints);
|
||
const maximumLiability = pot - ((pot * feeBp) / 10000n);
|
||
if (pot > PG_BIGINT_MAX) {
|
||
errors.push(`MATCH_ENTRY_FEE_ATOMIC * MATCH_MAX_PLAYERS exceeds PostgreSQL BIGINT (${PG_BIGINT_MAX}).`);
|
||
}
|
||
if (maximumLiability > payoutCap) {
|
||
errors.push(`MATCH_PAYOUT_MAX is below the configured ${requestedMax}-player winner liability (${maximumLiability} atomic units).`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (payoutMasterEnabled) {
|
||
const balanceCritical = parsePositiveAtomic(env.BALANCE_CRITICAL);
|
||
const balanceWarn = parsePositiveAtomic(env.BALANCE_WARN);
|
||
const acceptedLiabilities = [];
|
||
|
||
if (directPayoutsEnabled || creditsPayoutsEnabled) {
|
||
const soloPayoutCap = parsePositiveAtomic(env.PAYOUT_MAX_PER_GAME);
|
||
if (soloPayoutCap === null) {
|
||
errors.push('PAYOUT_MAX_PER_GAME must be an explicit positive PostgreSQL BIGINT atomic-unit cap when solo payouts are enabled.');
|
||
} else {
|
||
acceptedLiabilities.push({ label: 'PAYOUT_MAX_PER_GAME', amount: soloPayoutCap });
|
||
}
|
||
}
|
||
if (matchPayoutsEnabled) {
|
||
const matchPayoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX);
|
||
if (matchPayoutCap !== null) {
|
||
acceptedLiabilities.push({ label: 'MATCH_PAYOUT_MAX', amount: matchPayoutCap });
|
||
}
|
||
}
|
||
|
||
if (balanceCritical === null) {
|
||
errors.push('BALANCE_CRITICAL must be an explicit positive PostgreSQL BIGINT atomic-unit reserve when payouts are enabled.');
|
||
} else {
|
||
const uncovered = acceptedLiabilities
|
||
.filter(liability => balanceCritical < liability.amount)
|
||
.sort((left, right) => left.amount < right.amount ? 1 : -1);
|
||
if (uncovered.length > 0) {
|
||
const largest = uncovered[0];
|
||
errors.push(`BALANCE_CRITICAL must be at least ${largest.label} (${largest.amount} atomic units) so one accepted payout is always covered.`);
|
||
}
|
||
}
|
||
|
||
if (balanceWarn === null) {
|
||
errors.push('BALANCE_WARN must be an explicit positive PostgreSQL BIGINT atomic-unit warning threshold when payouts are enabled.');
|
||
} else if (balanceCritical !== null && balanceWarn < balanceCritical) {
|
||
errors.push(`BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL (${balanceCritical} atomic units).`);
|
||
}
|
||
}
|
||
|
||
// Never let production advertise a multiplier outcome which settlement would cap or
|
||
// suppress. Browser disclosures and immutable game terms must describe the same amount.
|
||
const validateSoloOutcomes = (label, enabled, base, rule) => {
|
||
if (!enabled) return;
|
||
let baseAtomic;
|
||
let minAtomic;
|
||
let maxAtomic;
|
||
try {
|
||
baseAtomic = money.toBig(base);
|
||
minAtomic = money.toBig(rule?.minPayout);
|
||
maxAtomic = money.toBig(rule?.maxPayout);
|
||
} catch (_) {
|
||
errors.push(`${label} payout base/min/max must be exact atomic-unit integers.`);
|
||
return;
|
||
}
|
||
for (const [outcome, multiplier] of [
|
||
['escape', rule?.multipliers?.escape],
|
||
['escapeWithTreasure', rule?.multipliers?.escapeWithTreasure]
|
||
]) {
|
||
let amount;
|
||
try { amount = money.mulByDecimal(baseAtomic, multiplier); }
|
||
catch (_) {
|
||
errors.push(`${label} ${outcome} multiplier must be a non-negative decimal.`);
|
||
continue;
|
||
}
|
||
if (amount < minAtomic || amount <= 0n) {
|
||
errors.push(`${label} ${outcome} payout (${amount} atomic units) is below its configured minimum (${minAtomic}).`);
|
||
}
|
||
if (amount > maxAtomic) {
|
||
errors.push(`${label} ${outcome} payout (${amount} atomic units) exceeds its configured maximum (${maxAtomic}).`);
|
||
}
|
||
}
|
||
};
|
||
validateSoloOutcomes(
|
||
'Direct', directPayoutsEnabled,
|
||
config?.modes?.direct?.price,
|
||
config?.payouts?.rules?.direct
|
||
);
|
||
validateSoloOutcomes(
|
||
'Credits', creditsPayoutsEnabled,
|
||
config?.payouts?.rules?.credits?.baseValue,
|
||
config?.payouts?.rules?.credits
|
||
);
|
||
|
||
if (!Object.prototype.hasOwnProperty.call(env, 'PAYMENTS_ENABLED') && !env.GAME_MODE) {
|
||
errors.push('Production requires an explicit PAYMENTS_ENABLED=true|false setting.');
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(env, 'PAYOUTS_ENABLED')) {
|
||
errors.push('Production requires an explicit PAYOUTS_ENABLED=true|false setting.');
|
||
}
|
||
if (directEnabled && !Object.prototype.hasOwnProperty.call(env, 'DIRECT_PAYOUTS_ENABLED')) {
|
||
errors.push('Production direct mode requires an explicit DIRECT_PAYOUTS_ENABLED=true|false setting.');
|
||
}
|
||
if (creditsEnabled
|
||
&& !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUTS_ENABLED')
|
||
&& !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUT_ENABLED')) {
|
||
errors.push('Production credits mode requires an explicit CREDITS_PAYOUTS_ENABLED=true|false setting.');
|
||
}
|
||
|
||
const adminKey = String(env.ADMIN_API_KEY || '');
|
||
if (adminKey.length < 32 || PLACEHOLDER_SECRET.test(adminKey) || new Set(adminKey).size < 8) {
|
||
errors.push('ADMIN_API_KEY must be a strong non-placeholder secret of at least 32 characters in production.');
|
||
}
|
||
if (!env.DB_PASSWORD || PLACEHOLDER_SECRET.test(String(env.DB_PASSWORD))) {
|
||
errors.push('DB_PASSWORD must be set to a non-placeholder value in production.');
|
||
}
|
||
|
||
const simulated = isTrue(env.SIMULATED_BLOCKS)
|
||
|| isTrue(env.FORCE_SIMULATED_BLOCKS)
|
||
|| String(env.BLOCK_SOURCE || '').toLowerCase() === 'simulated';
|
||
if (simulated) {
|
||
errors.push('Simulated blocks are forbidden when NODE_ENV=production.');
|
||
}
|
||
if (isTrue(env.DEBUG_HOTKEYS)) {
|
||
errors.push('DEBUG_HOTKEYS must not be enabled in production.');
|
||
}
|
||
|
||
if (payoutMasterEnabled && network === 'mainnet' && !isTrue(env.ALLOW_MAINNET_PAYOUTS)) {
|
||
errors.push('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.');
|
||
}
|
||
if (network !== 'mainnet' && isTrue(env.SMIRK_ENABLED)) {
|
||
warnings.push('SMIRK_ENABLED is ignored on test networks; set it false to keep operator intent clear.');
|
||
}
|
||
if (isTrue(env.TRUST_PROXY) === false) {
|
||
warnings.push('TRUST_PROXY is not enabled; this is correct only when the Node process is directly exposed.');
|
||
}
|
||
}
|
||
|
||
if (!paymentsEnabled) {
|
||
this.logger.info?.('ℹ️ Payments disabled. Server will run in free mode.');
|
||
}
|
||
warnings.forEach(message => this.logger.warn?.(`⚠️ ${message}`));
|
||
errors.forEach(message => this.logger.error?.(`❌ ${message}`));
|
||
|
||
return {
|
||
warnings,
|
||
errors,
|
||
production,
|
||
money: {
|
||
paymentsEnabled,
|
||
payoutsEnabled: anyPayoutsEnabled,
|
||
payoutWorkerEnabled: payoutMasterEnabled,
|
||
walletRequired,
|
||
directPayoutsEnabled,
|
||
creditsPayoutsEnabled,
|
||
matchPayoutsEnabled
|
||
}
|
||
};
|
||
}
|
||
|
||
assertValid(config) {
|
||
const result = this.validate(config);
|
||
if (result.errors.length > 0) {
|
||
const error = new Error(`Invalid production configuration (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}).`);
|
||
error.name = 'EnvironmentValidationError';
|
||
error.code = 'INVALID_ENVIRONMENT';
|
||
error.validation = result;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
module.exports = EnvironmentValidator;
|