diff --git a/.env.example b/.env.example index 07c3e8d..1b9356a 100644 --- a/.env.example +++ b/.env.example @@ -1,80 +1,131 @@ # .env.example — copy to .env and fill in values - -# ── Database ────────────────────────────────────────────────── +# IMPORTANT: Never commit secrets to version control. Use ./scripts/generate-secrets.sh +# for secure random values. See README.md "Pre-Beta Environment Setup Guide" for details. + +# ── DATABASE (Required for API) ──────────────────────────────── +# PostgreSQL connection string. Default for local Docker: port 5434 DATABASE_URL="postgresql://useroutr:password@localhost:5434/useroutr" - -# ── Redis ───────────────────────────────────────────────────── + +# ── REDIS (Required for API) ─────────────────────────────────── +# Redis connection string for caching, job queues, and rate limiting REDIS_URL="redis://localhost:6379" - -# ── Auth ────────────────────────────────────────────────────── -JWT_SECRET="your-256-bit-secret-here" + +# ── AUTHENTICATION (Required for API) ────────────────────────── +# JWT_SECRET: Generate via `./scripts/generate-secrets.sh` +# Base64-encoded 64 bytes (512 bits) for HS256 signing +JWT_SECRET="your-jwt-secret-here" + +# JWT_EXPIRY: Token expiration time (used for session management) JWT_EXPIRY="7d" -API_KEY_SALT="your-api-key-salt" - -# ── Stellar ─────────────────────────────────────────────────── -STELLAR_NETWORK="testnet" # "mainnet" for production + +# BANK_SESSION_ENCRYPTION_KEY: Generate via `./scripts/generate-secrets.sh` +# Base64-encoded 32 bytes for AES-256 encryption of bank session data +BANK_SESSION_ENCRYPTION_KEY="your-bank-session-encryption-key-here" + +# BANK_WEBHOOK_SECRET: Generate via `./scripts/generate-secrets.sh` +# Used to verify integrity of bank webhook payloads +BANK_WEBHOOK_SECRET="your-bank-webhook-secret-here" + +# ── STELLAR (Required for payments) ──────────────────────────── +# Network choice: "testnet" for development, "mainnet" for production +STELLAR_NETWORK="testnet" + +# Stellar RPC endpoints STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org" STELLAR_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" -STELLAR_RELAY_KEYPAIR_SECRET="S..." # Relay hot wallet (NEVER share) + +# Relay keypair for HTLC hot wallet operations (NEVER share or commit) +# Generate via: stellar key generate +STELLAR_RELAY_KEYPAIR_SECRET="S..." STELLAR_RELAY_PUBLIC_KEY="G..." - -# ── Soroban Contracts ───────────────────────────────────────── + +# ── SOROBAN CONTRACTS (Required for settlement) ──────────────── +# Contract IDs deployed on Stellar (get from deployment output) SOROBAN_HTLC_CONTRACT_ID="C..." SOROBAN_SETTLEMENT_CONTRACT_ID="C..." SOROBAN_FEE_COLLECTOR_CONTRACT_ID="C..." SOROBAN_ESCROW_CONTRACT_ID="C..." - -# ── EVM (deploy HTLC on each chain) ─────────────────────────── -EVM_RELAY_PRIVATE_KEY="0x..." # EVM relay wallet (NEVER share) + +# ── EVM CHAINS (Required for multi-chain support) ─────────────── +# EVM relay private key for hot wallet operations (NEVER share or commit) +# Format: 0x, generate via ethers.js or MetaMask +EVM_RELAY_PRIVATE_KEY="0x..." + +# HTLC contract addresses on each EVM chain HTLC_ADDRESS_ETHEREUM="0x..." HTLC_ADDRESS_BASE="0x..." HTLC_ADDRESS_BNB="0x..." HTLC_ADDRESS_POLYGON="0x..." HTLC_ADDRESS_ARBITRUM="0x..." HTLC_ADDRESS_AVALANCHE="0x..." - -# RPC endpoints (use Alchemy / Infura in production) + +# RPC endpoints for each chain (use Alchemy/Infura for production reliability) RPC_ETHEREUM="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY" RPC_BASE="https://base-mainnet.g.alchemy.com/v2/YOUR_KEY" -RPC_BNB="https://bsc-dataseed.binance.org/" - +RPC_BNB="https://bsc-dataseed.binance.org" RPC_POLYGON="https://polygon-rpc.com" RPC_ARBITRUM="https://arb1.arbitrum.io/rpc" RPC_AVALANCHE="https://api.avax.network/ext/bc/C/rpc" - -# ── Circle CCTP ─────────────────────────────────────────────── + +# ── CIRCLE CCTP (For stablecoin bridging) ────────────────────── +# API key from Circle developer portal CIRCLE_API_KEY="your-circle-api-key" - -# ── Wormhole ────────────────────────────────────────────────── -WORMHOLE_ENV="Testnet" # "Mainnet" for production - -# ── Layerswap (Starknet) ────────────────────────────────────── -LAYERSWAP_API_KEY="your-layerswap-key" - -# ── MoneyGram ───────────────────────────────────────────────── -MONEYGRAM_HOME_DOMAIN="extstellar.moneygram.com" # testnet -# MONEYGRAM_HOME_DOMAIN="stellar.moneygram.com" # mainnet - -# ── Stripe (card payments) ──────────────────────────────────── + +# ── WORMHOLE (For cross-chain messaging) ─────────────────────── +# Environment: "Testnet" for development, "Mainnet" for production +WORMHOLE_ENV="Testnet" + +# ── LAYERSWAP (For Starknet routing) ─────────────────────────── +# API key from Layerswap dashboard +LAYERSWAP_API_KEY="your-layerswap-api-key" + +# ── MONEYGRAM (For fiat settlement) ──────────────────────────── +# Testnet domain (comment out for mainnet) +MONEYGRAM_HOME_DOMAIN="extstellar.moneygram.com" +# Mainnet domain (uncomment for production) +# MONEYGRAM_HOME_DOMAIN="stellar.moneygram.com" + +# ── STRIPE (For card payments) ───────────────────────────────── +# Secret key from Stripe dashboard (format: sk_test_... or sk_live_...) STRIPE_SECRET_KEY="sk_test_..." + +# Webhook signing secret from Stripe webhook settings STRIPE_WEBHOOK_SECRET="whsec_..." - -# ── Email ───────────────────────────────────────────────────── + +# ── EMAIL (For notifications) ────────────────────────────────── +# API key from Resend dashboard RESEND_API_KEY="re_..." + +# From email address for notifications EMAIL_FROM="payments@useroutr.com" - -# ── Storage (Cloudinary) ────────────────────────────────────── + +# ── STORAGE (For media uploads) ──────────────────────────────── +# Cloudinary configuration for image storage CLOUDINARY_CLOUD_NAME="your-cloud-name" -CLOUDINARY_API_KEY="your-api-key" -CLOUDINARY_API_SECRET="your-api-secret" - -# ── App ─────────────────────────────────────────────────────── +CLOUDINARY_API_KEY="your-cloudinary-api-key" +CLOUDINARY_API_SECRET="your-cloudinary-api-secret" + +# ── APPLICATION CONFIG ───────────────────────────────────────── +# Port the API runs on PORT=3000 + +# API URL for external access (used in webhooks, emails, etc.) API_URL="https://api.yourdomain.com" + +# Node environment NODE_ENV="development" + +# Frontend URLs (for CORS and redirects) NEXT_PUBLIC_API_URL="http://localhost:3000" -FRONTEND_URL="http://localhost:3001" +WWW_URL="http://localhost:3000" DASHBOARD_URL="http://localhost:3001" CHECKOUT_URL="http://localhost:3002" -USEROUTR_FEE_BPS=50 # Default 0.5% platform fee + +# Platform fee in basis points (50 = 0.5%) +USEROUTR_FEE_BPS=50 + +# Stripe publishable key (public-facing) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." + +# Bank session TTL in hours +BANK_SESSION_TTL_HOURS=24 diff --git a/README.md b/README.md index 305b2af..f69c8a5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Smart contracts: - Node.js 20+ - npm 10+ - Docker + Docker Compose +- `openssl` (for secret generation) Optional for contract work: @@ -87,7 +88,6 @@ Set at least these for local API startup: - `DATABASE_URL` - `REDIS_URL=redis://localhost:6379` - `JWT_SECRET` -- `API_KEY_SALT` ### 4) Start local infrastructure @@ -115,6 +115,272 @@ npm run start:dev API runs on `http://localhost:3000` by default. +## Pre-Beta Environment Setup Guide + +Before deploying to beta or production, ensure all required environment variables are properly configured. This section details all secrets, credentials, and their sources. + +### Security Warnings + +⚠️ **NEVER commit secrets to version control.** All `.env` files are gitignored by default. + +⚠️ **Use `./scripts/generate-secrets.sh`** to generate cryptographically secure random values for JWT and encryption keys. + +### Environment Variables by Category + +#### 1. Core Infrastructure (Required) + +These variables are required for the API to start: + +| Variable | Description | Source | Example | +|----------|-------------|--------|---------| +| `DATABASE_URL` | PostgreSQL connection string | Create a database and get the connection URL | `postgresql://useroutr:password@localhost:5434/useroutr` | +| `REDIS_URL` | Redis connection string | Redis instance (local or cloud) | `redis://localhost:6379` | +| `NODE_ENV` | Application environment | Set to `development`, `staging`, or `production` | `development` | +| `PORT` | API listen port | Choose any available port | `3000` | + +**Setup for local development:** + +```bash +docker compose up -d +# Database and Redis are now available +``` + +#### 2. Authentication & Security (Required) + +Generate these using the provided script: + +```bash +./scripts/generate-secrets.sh > secrets.txt +# Copy values from secrets.txt to .env +``` + +| Variable | Description | Length | Source | +|----------|-------------|--------|--------| +| `JWT_SECRET` | JWT signing key (HS256) | 64+ bytes (base64) | Run `./scripts/generate-secrets.sh` | +| `BANK_SESSION_ENCRYPTION_KEY` | AES-256 key for sensitive data | 32 bytes (base64) | Run `./scripts/generate-secrets.sh` | +| `BANK_WEBHOOK_SECRET` | Verify bank webhook integrity | 32+ bytes (base64) | Run `./scripts/generate-secrets.sh` | +| `JWT_EXPIRY` | JWT token expiration | N/A | Set to `7d` or preferred duration | + +**Example setup:** + +```bash +# Generate secrets +./scripts/generate-secrets.sh > .env.secrets + +# Add to .env +cat .env.secrets >> .env +``` + +#### 3. Stellar Configuration (Required for Payments) + +| Variable | Description | Source | Format | +|----------|-------------|--------|--------| +| `STELLAR_NETWORK` | Stellar environment | Set to `testnet` or `mainnet` | `testnet` | +| `STELLAR_HORIZON_URL` | Stellar HTTP API endpoint | Use public endpoints or self-hosted | `https://horizon-testnet.stellar.org` | +| `STELLAR_SOROBAN_RPC_URL` | Soroban RPC endpoint | Use public endpoints or self-hosted | `https://soroban-testnet.stellar.org` | +| `STELLAR_RELAY_KEYPAIR_SECRET` | Relay account signing key | Generate via `stellar key generate` | `SXXXXXX...` (starts with `S`) | +| `STELLAR_RELAY_PUBLIC_KEY` | Relay account public key | Derived from keypair | `GXXXXXX...` (starts with `G`) | + +**Generate Stellar keys:** + +```bash +# Install stellar CLI if needed +brew install stellar-cli + +# Generate a new keypair +stellar key generate + +# Output will show secret and public key +# IMPORTANT: Fund the account with testnet XLM at https://friendbot.stellar.org +``` + +**Deploy contracts:** + +The Soroban contract IDs must be deployed and set in the environment: + +| Variable | Description | Source | +|----------|-------------|--------| +| `SOROBAN_HTLC_CONTRACT_ID` | HTLC contract | Deploy `contract/soroban` and record the ID | +| `SOROBAN_SETTLEMENT_CONTRACT_ID` | Settlement contract | Deploy `contract/soroban` and record the ID | +| `SOROBAN_FEE_COLLECTOR_CONTRACT_ID` | Fee collector contract | Deploy `contract/soroban` and record the ID | +| `SOROBAN_ESCROW_CONTRACT_ID` | Escrow contract | Deploy `contract/soroban` and record the ID | + +#### 4. EVM Configuration (Required for Multi-Chain Support) + +| Variable | Description | Source | Format | +|----------|-------------|--------|--------| +| `EVM_RELAY_PRIVATE_KEY` | EVM relay account private key | Generate via MetaMask or ethers.js | `0xXXXX...` (0x-prefixed hex, 64 chars) | +| `RPC_ETHEREUM` | Ethereum RPC endpoint | Alchemy, Infura, or public endpoint | `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY` | +| `RPC_BASE` | Base RPC endpoint | Alchemy, Infura, or public endpoint | `https://base-mainnet.g.alchemy.com/v2/YOUR_KEY` | +| `RPC_BNB` | BNB Chain RPC endpoint | Binance public RPC | `https://bsc-dataseed.binance.org` | +| `RPC_POLYGON` | Polygon RPC endpoint | Polygon public RPC | `https://polygon-rpc.com` | +| `RPC_ARBITRUM` | Arbitrum RPC endpoint | Arbitrum public RPC | `https://arb1.arbitrum.io/rpc` | +| `RPC_AVALANCHE` | Avalanche C-Chain RPC endpoint | Avalanche public RPC | `https://api.avax.network/ext/bc/C/rpc` | + +**Deploy HTLC contracts on each chain:** + +| Variable | Description | Source | +|----------|-------------|--------| +| `HTLC_ADDRESS_ETHEREUM` | HTLC contract on Ethereum | Deploy `contract/evm` via Hardhat, record address | +| `HTLC_ADDRESS_BASE` | HTLC contract on Base | Deploy `contract/evm` via Hardhat, record address | +| `HTLC_ADDRESS_BNB` | HTLC contract on BNB Chain | Deploy `contract/evm` via Hardhat, record address | +| `HTLC_ADDRESS_POLYGON` | HTLC contract on Polygon | Deploy `contract/evm` via Hardhat, record address | +| `HTLC_ADDRESS_ARBITRUM` | HTLC contract on Arbitrum | Deploy `contract/evm` via Hardhat, record address | +| `HTLC_ADDRESS_AVALANCHE` | HTLC contract on Avalanche | Deploy `contract/evm` via Hardhat, record address | + +**Generate an EVM private key:** + +```bash +# Via ethers.js +node -e "const ethers = require('ethers'); const wallet = ethers.Wallet.createRandom(); console.log('Private Key:', wallet.privateKey); console.log('Address:', wallet.address);" + +# Or import MetaMask: Export → Private Key (⚠️ keep secure) +``` + +#### 5. Bridge Integrations (Optional but Recommended for Beta) + +| Variable | Description | Source | When Required | +|----------|-------------|--------|----------------| +| `CIRCLE_API_KEY` | Circle CCTP API key | [Circle Developer Portal](https://developers.circle.com) | For stablecoin bridging | +| `WORMHOLE_ENV` | Wormhole environment | Set to `Testnet` or `Mainnet` | For cross-chain messaging | +| `LAYERSWAP_API_KEY` | Layerswap API key | [Layerswap Dashboard](https://www.layerswap.io) | For Starknet routing | + +#### 6. Stripe Integration (Optional but Recommended for Beta) + +| Variable | Description | Source | Format | +|----------|-------------|--------|--------| +| `STRIPE_SECRET_KEY` | Stripe secret API key | [Stripe Dashboard](https://dashboard.stripe.com) | `sk_test_...` (test) or `sk_live_...` (production) | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | Stripe → Webhooks → Signing secret | `whsec_...` | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (public) | [Stripe Dashboard](https://dashboard.stripe.com) | `pk_test_...` (test) or `pk_live_...` (production) | + +**Setup:** + +1. Create a Stripe account +2. Get API keys from Dashboard → API Keys +3. Create a webhook endpoint pointing to `https://api.yourdomain.com/v1/webhooks/stripe` +4. Copy the webhook signing secret + +#### 7. Email Service (Optional but Recommended for Beta) + +| Variable | Description | Source | +|----------|-------------|--------| +| `RESEND_API_KEY` | Resend email service API key | [Resend Dashboard](https://resend.com) | +| `EMAIL_FROM` | From address for notifications | Your verified sender address | `payments@useroutr.com` | + +#### 8. Media Storage (Optional) + +| Variable | Description | Source | +|----------|-------------|--------| +| `CLOUDINARY_CLOUD_NAME` | Cloudinary cloud name | [Cloudinary Dashboard](https://cloudinary.com) | +| `CLOUDINARY_API_KEY` | Cloudinary API key | Cloudinary Settings → API Keys | +| `CLOUDINARY_API_SECRET` | Cloudinary API secret | Cloudinary Settings → API Keys (keep secure) | + +#### 9. URLs & Application Config (Required) + +| Variable | Description | Example | +|----------|-------------|---------| +| `API_URL` | Public API URL | `https://api.yourdomain.com` | +| `NEXT_PUBLIC_API_URL` | Frontend API endpoint | `https://api.yourdomain.com` or `http://localhost:3000` | +| `WWW_URL` | Main website URL | `https://useroutr.com` or `http://localhost:3000` | +| `DASHBOARD_URL` | Dashboard URL | `https://dashboard.useroutr.com` or `http://localhost:3001` | +| `CHECKOUT_URL` | Checkout widget URL | `https://checkout.useroutr.com` or `http://localhost:3002` | +| `USEROUTR_FEE_BPS` | Platform fee in basis points | `50` (0.5%) | + +#### 10. MoneyGram Integration (Optional) + +| Variable | Description | Source | Environment | +|----------|-------------|--------|-------------| +| `MONEYGRAM_HOME_DOMAIN` | MoneyGram federation domain | MoneyGram Developer Portal | Testnet: `extstellar.moneygram.com` / Mainnet: `stellar.moneygram.com` | + +### Environment Setup by Stage + +#### Local Development + +Minimum required for `npm run start:dev`: + +```env +DATABASE_URL=postgresql://useroutr:password@localhost:5434/useroutr +REDIS_URL=redis://localhost:6379 +JWT_SECRET= +NODE_ENV=development +PORT=3000 +# STELLAR_RELAY_KEYPAIR_SECRET and EVM_RELAY_PRIVATE_KEY are only required +# when NODE_ENV=production; payment signing features need them at runtime. +``` + +#### Beta / Staging + +Add all bridge and payment service integrations: + +- All Core Infrastructure variables +- All Authentication variables +- All Stellar variables (with testnet endpoints) +- All EVM variables (with testnet RPCs) +- Circle, Wormhole, Layerswap credentials +- Stripe test keys +- Resend API key +- All URLs pointing to staging domain + +#### Production + +⚠️ **Do not proceed until:** +- [ ] Smart contracts have been audited +- [ ] All private keys are stored in a secure key management system (AWS Secrets Manager, HashiCorp Vault, etc.) +- [ ] Rate limiting and DDoS protection are configured +- [ ] Monitoring and alerting are in place +- [ ] Disaster recovery plan is documented + +Production variables: + +- All Core Infrastructure variables (using production database and Redis) +- All Authentication variables (regenerated and stored securely) +- All Stellar variables (mainnet endpoints) +- All EVM variables (mainnet RPCs, deployed contracts) +- All third-party service keys (production credentials) +- Production URLs and domains + +### Validation + +The API automatically validates critical environment variables on startup. If any required variable is missing or contains a placeholder value, the API will fail to start with a clear error message. + +Example error: + +``` +❌ Environment Configuration Errors: + +STARTUP FAILED: STELLAR_RELAY_KEYPAIR_SECRET is a placeholder or missing. Set a valid value. +STARTUP FAILED: JWT_SECRET is a placeholder or missing. Set a valid value. + +📋 To generate secrets, run: ./scripts/generate-secrets.sh +``` + +### Troubleshooting + +**Issue:** API fails to start with "JWT_SECRET is a placeholder" + +**Solution:** +```bash +./scripts/generate-secrets.sh > .env.secrets +# Copy values to .env, ensure no placeholder patterns like "your-secret" remain +``` + +**Issue:** "STELLAR_RELAY_KEYPAIR_SECRET not set" + +**Solution:** +```bash +stellar key generate +# Fund the account: https://friendbot.stellar.org/?addr=GXXXXXX +# Add secret to .env +``` + +**Issue:** "DATABASE_URL connection failed" + +**Solution:** +```bash +docker compose up -d # Ensure Postgres is running +docker compose logs postgres # Check for errors +``` + ## Useful Commands From `apps/api`: diff --git a/apps/api/src/common/config/env-validation.spec.ts b/apps/api/src/common/config/env-validation.spec.ts new file mode 100644 index 0000000..00471ee --- /dev/null +++ b/apps/api/src/common/config/env-validation.spec.ts @@ -0,0 +1,102 @@ +import { + EnvValidationError, + isPlaceholder, + validateEnvironmentConfig, +} from './env-validation'; + +describe('isPlaceholder', () => { + it('flags .env.example template values', () => { + expect(isPlaceholder('your-jwt-secret-here')).toBe(true); + expect(isPlaceholder('YOUR-CIRCLE-API-KEY')).toBe(true); + expect(isPlaceholder('placeholder')).toBe(true); + expect(isPlaceholder('S...')).toBe(true); + expect(isPlaceholder('G...')).toBe(true); + expect(isPlaceholder('C...')).toBe(true); + expect(isPlaceholder('0x...')).toBe(true); + }); + + it('accepts real-looking values', () => { + expect(isPlaceholder('postgresql://user:pass@localhost:5432/db')).toBe( + false, + ); + expect(isPlaceholder('SDGKJH3H5K2J4H5K2J4H5K2J4H5K2J4H5K2J4H5K2J4H')).toBe( + false, + ); + expect(isPlaceholder('0xabc123def456')).toBe(false); + expect(isPlaceholder(undefined)).toBe(false); + expect(isPlaceholder('')).toBe(false); + }); +}); + +describe('validateEnvironmentConfig', () => { + const validBase: NodeJS.ProcessEnv = { + NODE_ENV: 'development', + DATABASE_URL: 'postgresql://useroutr:password@localhost:5434/useroutr', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'a'.repeat(64), + }; + + it('passes with a valid development configuration', () => { + expect(() => validateEnvironmentConfig(validBase)).not.toThrow(); + }); + + it('does not require chain hot-wallet keys outside production', () => { + expect(() => validateEnvironmentConfig(validBase)).not.toThrow(); + }); + + it('requires chain hot-wallet keys in production', () => { + const env = { ...validBase, NODE_ENV: 'production' }; + expect(() => validateEnvironmentConfig(env)).toThrow(EnvValidationError); + try { + validateEnvironmentConfig(env); + fail('expected EnvValidationError'); + } catch (err) { + const problems = (err as EnvValidationError).problems.join('\n'); + expect(problems).toContain('STELLAR_RELAY_KEYPAIR_SECRET'); + expect(problems).toContain('EVM_RELAY_PRIVATE_KEY'); + } + }); + + it('rejects missing required variables', () => { + const env = { ...validBase }; + delete env.DATABASE_URL; + expect(() => validateEnvironmentConfig(env)).toThrow(/DATABASE_URL/); + }); + + it('rejects placeholder values even for optional variables', () => { + const env = { + ...validBase, + BANK_WEBHOOK_SECRET: 'your-bank-webhook-secret-here', + }; + expect(() => validateEnvironmentConfig(env)).toThrow( + /BANK_WEBHOOK_SECRET.*placeholder/, + ); + }); + + it('rejects placeholder chain keys in any environment', () => { + const env = { ...validBase, STELLAR_RELAY_KEYPAIR_SECRET: 'S...' }; + expect(() => validateEnvironmentConfig(env)).toThrow( + /STELLAR_RELAY_KEYPAIR_SECRET.*placeholder/, + ); + }); + + it('rejects a JWT_SECRET shorter than 32 characters', () => { + const env = { ...validBase, JWT_SECRET: 'short-secret' }; + expect(() => validateEnvironmentConfig(env)).toThrow( + /JWT_SECRET.*at least 32/, + ); + }); + + it('reports every problem at once', () => { + const env: NodeJS.ProcessEnv = { NODE_ENV: 'development' }; + try { + validateEnvironmentConfig(env); + fail('expected EnvValidationError'); + } catch (err) { + expect(err).toBeInstanceOf(EnvValidationError); + expect( + (err as EnvValidationError).problems.length, + ).toBeGreaterThanOrEqual(3); + } + }); +}); diff --git a/apps/api/src/common/config/env-validation.ts b/apps/api/src/common/config/env-validation.ts new file mode 100644 index 0000000..87bc488 --- /dev/null +++ b/apps/api/src/common/config/env-validation.ts @@ -0,0 +1,129 @@ +/** + * Startup environment validation. + * + * Verifies that critical secrets are present and not left at the + * `.env.example` placeholder values before the application boots. + * Variables the app can run without in development (chain hot-wallet + * keys) are only enforced in production, so a fresh checkout can start + * the API without provisioning real wallets. + */ + +/** Values copied verbatim from `.env.example` that must never be used. */ +const PLACEHOLDER_PATTERNS: RegExp[] = [ + /^your-/i, + /^placeholder/i, + /^[sgc]\.\.\.$/i, // Stellar secret/public keys, Soroban contract ids + /^0x\.\.\.$/, +]; + +/** When a variable must be set for the API to start. */ +type Requirement = 'always' | 'production' | 'optional'; + +export interface CriticalEnvVar { + requiredIn: Requirement; + description: string; +} + +export const CRITICAL_ENV_VARS: Record = { + // Core infrastructure — nothing works without these. + DATABASE_URL: { + requiredIn: 'always', + description: 'PostgreSQL connection string', + }, + REDIS_URL: { + requiredIn: 'always', + description: 'Redis connection string for caching and queues', + }, + // Authentication. + JWT_SECRET: { + requiredIn: 'always', + description: + 'JWT signing secret (generate via scripts/generate-secrets.sh)', + }, + // Bank rails — feature-gated, but a placeholder is always an error. + BANK_SESSION_ENCRYPTION_KEY: { + requiredIn: 'optional', + description: 'AES-256 key for bank session encryption', + }, + BANK_WEBHOOK_SECRET: { + requiredIn: 'optional', + description: 'Secret for bank webhook verification', + }, + // Chain hot wallets — only signing operations need these, so local + // development can run without them. Production cannot. + STELLAR_RELAY_KEYPAIR_SECRET: { + requiredIn: 'production', + description: 'Stellar relay keypair secret (starts with S)', + }, + EVM_RELAY_PRIVATE_KEY: { + requiredIn: 'production', + description: 'EVM relay private key (0x-prefixed hex)', + }, +}; + +/** Minimum lengths for secrets where a short value defeats the purpose. */ +const MIN_LENGTHS: Record = { + JWT_SECRET: 32, +}; + +export class EnvValidationError extends Error { + constructor(public readonly problems: string[]) { + super( + [ + 'Environment configuration is invalid:', + ...problems.map((p) => ` - ${p}`), + 'Generate secrets with: ./scripts/generate-secrets.sh', + 'See the "Pre-Beta Environment Setup Guide" in README.md for details.', + ].join('\n'), + ); + this.name = 'EnvValidationError'; + } +} + +/** True when a value is still one of the `.env.example` placeholders. */ +export function isPlaceholder(value: string | undefined): boolean { + if (!value) return false; + return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)); +} + +/** + * Validate critical environment variables. Throws {@link EnvValidationError} + * listing every problem found — it never exits the process itself, so it + * stays unit-testable and callers decide how to fail. + */ +export function validateEnvironmentConfig( + env: NodeJS.ProcessEnv = process.env, +): void { + const problems: string[] = []; + const isProduction = env.NODE_ENV === 'production'; + + for (const [name, spec] of Object.entries(CRITICAL_ENV_VARS)) { + const value = env[name]; + const required = + spec.requiredIn === 'always' || + (spec.requiredIn === 'production' && isProduction); + + if (!value) { + if (required) { + problems.push(`${name} is not set. ${spec.description}.`); + } + continue; + } + + if (isPlaceholder(value)) { + problems.push( + `${name} is still set to a placeholder value. ${spec.description}.`, + ); + continue; + } + + const minLength = MIN_LENGTHS[name]; + if (minLength && value.length < minLength) { + problems.push(`${name} must be at least ${minLength} characters long.`); + } + } + + if (problems.length > 0) { + throw new EnvValidationError(problems); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0c7f603..482516a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,13 +1,31 @@ import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; import helmet from 'helmet'; import { json, urlencoded } from 'express'; import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; +import { + EnvValidationError, + validateEnvironmentConfig, +} from './common/config/env-validation'; async function bootstrap() { + // Validate critical environment variables before starting the application. + // dotenv/config is imported at the top of this file, so .env is already + // loaded into process.env by the time this runs. + try { + validateEnvironmentConfig(); + } catch (err) { + if (err instanceof EnvValidationError) { + new Logger('EnvValidation').error(err.message); + process.exit(1); + } + throw err; + } + const app = await NestFactory.create(AppModule, { rawBody: true }); // ── Security headers ──────────────────────────────────────────────────────── diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh new file mode 100755 index 0000000..e581697 --- /dev/null +++ b/scripts/generate-secrets.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Secret generation script for Useroutr. +# Prints secure random values for the secrets the API validates at startup. +# Usage: ./scripts/generate-secrets.sh >> .env (or copy values manually) + +set -euo pipefail + +if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not installed." >&2 + echo "Install it using your package manager:" >&2 + echo " macOS: brew install openssl" >&2 + echo " Ubuntu/Debian: sudo apt-get install openssl" >&2 + echo " CentOS/RHEL: sudo yum install openssl" >&2 + exit 1 +fi + +# openssl wraps base64 output at 64 columns; strip newlines so every value +# stays on a single line and is safe to paste into a .env file. +rand_base64() { + openssl rand -base64 "$1" | tr -d '\n' +} + +echo "# Useroutr generated secrets - $(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "# Copy these values into your .env file. Never commit them." +echo "" + +# JWT signing secret (64 bytes = 512 bits) +echo "JWT_SECRET=\"$(rand_base64 64)\"" + +# Bank session encryption key (32 bytes for AES-256) +echo "BANK_SESSION_ENCRYPTION_KEY=\"$(rand_base64 32)\"" + +# Bank webhook verification secret +echo "BANK_WEBHOOK_SECRET=\"$(rand_base64 32)\"" + +echo "" +echo "# The following secrets cannot be generated locally and need manual setup:" +echo "# STELLAR_RELAY_KEYPAIR_SECRET='S...' # Generate via stellar-cli: stellar keys generate" +echo "# EVM_RELAY_PRIVATE_KEY='0x...' # Generate via ethers.js or a wallet" +echo "# CIRCLE_API_KEY='...' # Circle developer portal" +echo "# STRIPE_SECRET_KEY='sk_...' # Stripe dashboard" +echo "# STRIPE_WEBHOOK_SECRET='whsec_...' # Stripe webhook settings" +echo "# RESEND_API_KEY='re_...' # Resend dashboard" +echo "# CLOUDINARY_API_SECRET='...' # Cloudinary dashboard"