diff --git a/scripts/validate-env.js b/scripts/validate-env.js new file mode 100644 index 0000000..530229e --- /dev/null +++ b/scripts/validate-env.js @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +/** + * Environment Variable Validation Script + * Checks your .env file for production deployment without exposing secrets + */ + +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Colors for terminal output +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +const log = { + error: (msg) => console.log(`${colors.red}❌ ${msg}${colors.reset}`), + success: (msg) => console.log(`${colors.green}✅ ${msg}${colors.reset}`), + warning: (msg) => console.log(`${colors.yellow}⚠️ ${msg}${colors.reset}`), + info: (msg) => console.log(`${colors.blue}ℹ️ ${msg}${colors.reset}`), + header: (msg) => console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`), + title: (msg) => console.log(`${colors.magenta}${msg}${colors.reset}`), +}; + +// Load environment variables +const envPath = join(__dirname, '..', '.env'); +const envLocalPath = join(__dirname, '..', '.env.local'); + +console.clear(); +log.header(); +log.title('🔍 Environment Configuration Validator'); +log.header(); + +// Check if .env files exist +console.log('\n📁 Checking .env files...\n'); + +if (!existsSync(envPath)) { + log.error('.env file not found!'); + process.exit(1); +} +log.success('.env file exists'); + +if (existsSync(envLocalPath)) { + log.info('.env.local file exists (will override .env values)'); + dotenv.config({ path: envLocalPath }); +} + +dotenv.config({ path: envPath }); + +// Validation rules +const issues = []; +const warnings = []; +let passedChecks = 0; +const totalChecks = 15; + +console.log('\n🔍 Validating configuration...\n'); + +// Helper functions +const checkRequired = (key, name) => { + if (!process.env[key]) { + issues.push(`Missing required variable: ${key} (${name})`); + return false; + } + passedChecks++; + return true; +}; + +const checkUrl = (key, expectedProtocol, expectedDomain) => { + const value = process.env[key]; + if (!value) { + issues.push(`Missing ${key}`); + return false; + } + + if (value.includes('localhost') || value.includes('127.0.0.1')) { + issues.push(`${key} contains localhost/127.0.0.1 - should be production domain`); + log.error(`${key}: Uses localhost (should be ${expectedDomain})`); + return false; + } + + if (!value.startsWith(expectedProtocol)) { + warnings.push(`${key} should start with ${expectedProtocol}`); + log.warning(`${key}: Should start with ${expectedProtocol}`); + } + + if (expectedDomain && !value.includes(expectedDomain)) { + issues.push(`${key} should contain ${expectedDomain}`); + log.error(`${key}: Should contain ${expectedDomain}`); + return false; + } + + log.success(`${key}: Correctly configured`); + passedChecks++; + return true; +}; + +const checkValue = (key, expectedValue, description) => { + const value = process.env[key]; + if (!value) { + warnings.push(`${key} not set (${description})`); + return false; + } + + if (value !== expectedValue) { + issues.push(`${key} should be "${expectedValue}" but is "${value}"`); + log.error(`${key}: Should be "${expectedValue}"`); + return false; + } + + log.success(`${key}: Correctly set to "${expectedValue}"`); + passedChecks++; + return true; +}; + +const checkBoolean = (key, expectedValue) => { + const value = process.env[key]; + if (!value) { + warnings.push(`${key} not set`); + return false; + } + + if (value !== expectedValue) { + issues.push(`${key} should be "${expectedValue}" but is "${value}"`); + log.error(`${key}: Should be "${expectedValue}" for production`); + return false; + } + + log.success(`${key}: Correctly set to "${expectedValue}"`); + passedChecks++; + return true; +}; + +const maskSecret = (value) => { + if (!value || value.length < 8) return '***'; + return value.substring(0, 4) + '...' + value.substring(value.length - 4); +}; + +// Run validations +console.log('🌐 Network Configuration:'); +checkUrl('STEAM_REALM', 'https://', 'api.turbotrades.dev'); +checkUrl('STEAM_RETURN_URL', 'https://', 'api.turbotrades.dev'); +checkUrl('CORS_ORIGIN', 'https://', 'turbotrades.dev'); + +// Check CORS_ORIGIN is NOT the API domain +if (process.env.CORS_ORIGIN && process.env.CORS_ORIGIN.includes('api.turbotrades.dev')) { + issues.push('CORS_ORIGIN should be the FRONTEND domain (turbotrades.dev), not the API domain'); + log.error('CORS_ORIGIN: Should be https://turbotrades.dev (frontend domain)'); + log.info(' CORS_ORIGIN = where requests COME FROM (frontend)'); + log.info(' STEAM_REALM = where requests GO TO (backend)'); +} else if (process.env.CORS_ORIGIN && !process.env.CORS_ORIGIN.includes('localhost')) { + passedChecks++; +} + +console.log('\n🔒 Security Configuration:'); +checkBoolean('COOKIE_SECURE', 'true'); +checkValue('COOKIE_SAME_SITE', 'none', 'for cross-domain cookies'); +checkValue('NODE_ENV', 'production', 'production environment'); + +// Check cookie domain +if (process.env.COOKIE_DOMAIN) { + if (process.env.COOKIE_DOMAIN.includes('localhost')) { + issues.push('COOKIE_DOMAIN contains localhost - should be .turbotrades.dev'); + log.error('COOKIE_DOMAIN: Contains localhost'); + } else if (process.env.COOKIE_DOMAIN === '.turbotrades.dev' || process.env.COOKIE_DOMAIN === 'turbotrades.dev') { + log.success('COOKIE_DOMAIN: Correctly set'); + passedChecks++; + } else { + warnings.push('COOKIE_DOMAIN might be incorrect'); + log.warning(`COOKIE_DOMAIN: Set to "${process.env.COOKIE_DOMAIN}" (should be .turbotrades.dev)`); + } +} + +console.log('\n🔑 Required Secrets:'); + +// Check Steam API Key +if (checkRequired('STEAM_API_KEY', 'Steam API Key')) { + const key = process.env.STEAM_API_KEY; + if (key.length === 32 && /^[A-F0-9]{32}$/i.test(key)) { + log.success(`STEAM_API_KEY: Valid format (${maskSecret(key)})`); + } else { + warnings.push('STEAM_API_KEY format looks unusual (should be 32 hex characters)'); + log.warning(`STEAM_API_KEY: Unusual format (${maskSecret(key)})`); + } +} + +// Check MongoDB URI +if (checkRequired('MONGODB_URI', 'MongoDB connection string')) { + const uri = process.env.MONGODB_URI; + if (uri.includes('localhost') || uri.includes('127.0.0.1')) { + issues.push('MONGODB_URI contains localhost - should be production database'); + log.error('MONGODB_URI: Uses localhost (should be production database)'); + } else { + log.success('MONGODB_URI: Points to remote database'); + } +} + +// Check JWT secrets +console.log('\n🎫 JWT Configuration:'); +['JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET', 'SESSION_SECRET'].forEach((key) => { + if (process.env[key]) { + const value = process.env[key]; + if (value.includes('your-') || value.includes('change-this') || value.length < 16) { + warnings.push(`${key} appears to be a default/weak value - generate a secure one!`); + log.warning(`${key}: Using default/weak value (${maskSecret(value)})`); + } else { + log.success(`${key}: Set (${maskSecret(value)})`); + passedChecks++; + } + } else { + warnings.push(`${key} not set - will use default`); + log.warning(`${key}: Not set (will use default)`); + } +}); + +// Summary +log.header(); +console.log('\n📊 Validation Summary:\n'); + +const score = Math.round((passedChecks / totalChecks) * 100); + +if (issues.length === 0 && warnings.length === 0) { + log.success(`Perfect! All checks passed (${passedChecks}/${totalChecks})`); + log.success('Your .env file is correctly configured for production! 🎉'); + process.exit(0); +} + +console.log(`Checks passed: ${passedChecks}/${totalChecks} (${score}%)\n`); + +if (issues.length > 0) { + log.error(`Found ${issues.length} critical issue(s):\n`); + issues.forEach((issue, i) => { + console.log(` ${i + 1}. ${issue}`); + }); + console.log(); +} + +if (warnings.length > 0) { + log.warning(`Found ${warnings.length} warning(s):\n`); + warnings.forEach((warning, i) => { + console.log(` ${i + 1}. ${warning}`); + }); + console.log(); +} + +// Recommendations +if (issues.length > 0) { + log.header(); + log.title('🔧 Recommended Fixes:'); + log.header(); + console.log('\n1. Edit your .env file:'); + console.log(' nano .env # or vim .env\n'); + console.log('2. Update the following values:\n'); + + if (issues.some(i => i.includes('CORS_ORIGIN'))) { + console.log(' CORS_ORIGIN=https://turbotrades.dev'); + } + if (issues.some(i => i.includes('STEAM_REALM'))) { + console.log(' STEAM_REALM=https://api.turbotrades.dev'); + } + if (issues.some(i => i.includes('STEAM_RETURN_URL'))) { + console.log(' STEAM_RETURN_URL=https://api.turbotrades.dev/auth/steam/return'); + } + if (issues.some(i => i.includes('COOKIE_DOMAIN'))) { + console.log(' COOKIE_DOMAIN=.turbotrades.dev'); + } + if (issues.some(i => i.includes('COOKIE_SECURE'))) { + console.log(' COOKIE_SECURE=true'); + } + if (issues.some(i => i.includes('MONGODB_URI'))) { + console.log(' MONGODB_URI=mongodb+srv://...(your production database)'); + } + + console.log('\n3. Restart PM2:'); + console.log(' pm2 restart turbotrades-backend --update-env\n'); + + log.header(); + process.exit(1); +} + +if (warnings.length > 0) { + log.info('Configuration is functional but has some warnings.'); + log.info('Consider addressing the warnings above for better security.'); + process.exit(0); +}