diff --git a/.env.example b/.env.example index b21f2fe..4bfc7c4 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,8 @@ POOL_TOKEN_ADDRESS= STELLAR_USDC_ISSUER= STELLAR_EURC_ISSUER= STELLAR_PHP_ISSUER= -# Secret key for the on-chain LoanManager admin account (G... / S...) -LOAN_MANAGER_ADMIN_SECRET= +# Secret key for the on-chain LoanManager admin account (must be a valid Stellar S... seed) +LOAN_MANAGER_ADMIN_SECRET=SAWMKGQNIPJQM5F2LD6U3BZW7DR2PZFTPH2TX2JMCCA5CKHGXW25LX6T # Optional override for score reconciliation read calls SCORE_RECONCILIATION_SOURCE_SECRET= @@ -65,7 +65,7 @@ SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD=50 # Authentication JWT_SECRET=your-super-secret-jwt-key-change-in-production -INTERNAL_API_KEY=change-me +INTERNAL_API_KEY=replace-this-with-a-real-api-key-min-32-chars # Webhooks WEBHOOK_REQUEST_TIMEOUT_MS=30000 diff --git a/README.md b/README.md index 56ee6f7..9c5ff95 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ With Docker Compose from the repo root, the `backend` service runs `migrate:up` ### Environment Variables -Create a `.env` file in the backend directory: +Create a `.env` file in the backend directory (see `.env.example` for all available variables): ```env # Server Configuration @@ -128,21 +128,25 @@ PORT=3001 # CORS Configuration FRONTEND_URL=http://localhost:3000 -# Optional backward-compatible fallback for multiple allowed origins during migration -# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 # Stellar Configuration STELLAR_NETWORK=testnet STELLAR_RPC_URL=https://soroban-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 LOAN_MANAGER_CONTRACT_ID= -LOAN_MANAGER_ADMIN_SECRET= - -# Future: Add API keys for remittance services -# WISE_API_KEY=your_key_here -# WESTERN_UNION_API_KEY=your_key_here +LOAN_MANAGER_ADMIN_SECRET=S... # valid Stellar Ed25519 secret seed ``` +#### Secret format requirements + +The server validates secret format at startup and exits if any check fails: + +| Variable | Requirement | +| --------------------------- | --------------------------------------------------------- | +| `JWT_SECRET` | At least 32 characters | +| `INTERNAL_API_KEY` | At least 32 characters | +| `LOAN_MANAGER_ADMIN_SECRET` | Valid Stellar Ed25519 secret seed (`S...`, 56 characters) | + ## Available Scripts ```bash diff --git a/src/config/env.ts b/src/config/env.ts index 5abcca3..03ce9b3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,10 +1,6 @@ +import { StrKey } from "@stellar/stellar-sdk"; import logger from "../utils/logger.js"; -/** - * List of environment variables required for the application to function. - * If any of these are missing or empty on startup, the server will exit immediately - * with a clear error message. - */ const REQUIRED_ENV_VARS = [ "DATABASE_URL", "REDIS_URL", @@ -22,12 +18,30 @@ const REQUIRED_ENV_VARS = [ "SCORE_DELTA_LATE", ]; -/** - * Validates that all critical environment variables are set and non-empty. - * Logs a clear error message and halts the process if any requirements are unmet. - */ +const MIN_SECRET_LENGTH = 32; + +function validateSecretFormat(errors: string[]): void { + const jwtSecret = process.env.JWT_SECRET?.trim(); + if (jwtSecret && jwtSecret.length < MIN_SECRET_LENGTH) { + errors.push( + `JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${jwtSecret.length})`, + ); + } + + const apiKey = process.env.INTERNAL_API_KEY?.trim(); + if (apiKey && apiKey.length < MIN_SECRET_LENGTH) { + errors.push( + `INTERNAL_API_KEY must be at least ${MIN_SECRET_LENGTH} characters (got ${apiKey.length})`, + ); + } + + const adminSecret = process.env.LOAN_MANAGER_ADMIN_SECRET?.trim(); + if (adminSecret && !StrKey.isValidEd25519SecretSeed(adminSecret)) { + errors.push(`LOAN_MANAGER_ADMIN_SECRET is not a valid Stellar secret key`); + } +} + export function validateEnvVars(): void { - // Filter for variables that are either absent OR just whitespace const missing = REQUIRED_ENV_VARS.filter( (key) => !process.env[key] || process.env[key]!.trim() === "", ); @@ -40,16 +54,32 @@ export function validateEnvVars(): void { const missingVarMsg = `Missing or empty required variables: ${bold(missing.join(", "))}`; const actionMsg = `Please verify these variables in your \x1b[4m.env\x1b[0m file or deployment environment.`; - // Direct console error for immediate visibility during startup failure console.error(`\n${errorPrefix}\n${missingVarMsg}\n${actionMsg}\n`); - // Structured log for persistent logs (e.g., Sentry, CloudWatch, etc.) logger.error("Environment validation failure", { missing, node_env: process.env.NODE_ENV, }); - // Stop execution immediately + process.exit(1); + } + + const formatErrors: string[] = []; + validateSecretFormat(formatErrors); + + if (formatErrors.length > 0) { + const boldRed = (msg: string) => `\x1b[1;31m${msg}\x1b[0m`; + + const errorPrefix = boldRed("FATAL ERROR: Environment validation failed"); + const details = formatErrors.map((e) => ` - ${e}`).join("\n"); + + console.error(`\n${errorPrefix}\n${details}\n`); + + logger.error("Environment format validation failure", { + errors: formatErrors, + node_env: process.env.NODE_ENV, + }); + process.exit(1); } diff --git a/src/tests/envValidation.test.ts b/src/tests/envValidation.test.ts index 64260e7..02a830d 100644 --- a/src/tests/envValidation.test.ts +++ b/src/tests/envValidation.test.ts @@ -3,6 +3,26 @@ import { jest } from "@jest/globals"; jest.mock("../utils/logger.js"); +const VALID_STELLAR_SECRET = + "SBJ6ZXIH5JXKHXJUDF7DUX2HY5Q3SOOAUCQ3OUO5TIIJMOYIGPPP6Q6W"; + +function setValidEnv(): void { + process.env.DATABASE_URL = "postgres://localhost"; + process.env.REDIS_URL = "redis://localhost"; + process.env.JWT_SECRET = "a".repeat(32); + process.env.STELLAR_RPC_URL = "http://localhost"; + process.env.STELLAR_NETWORK_PASSPHRASE = "test"; + process.env.LOAN_MANAGER_CONTRACT_ID = "C1"; + process.env.LENDING_POOL_CONTRACT_ID = "C2"; + process.env.POOL_TOKEN_ADDRESS = "T1"; + process.env.LOAN_MANAGER_ADMIN_SECRET = VALID_STELLAR_SECRET; + process.env.INTERNAL_API_KEY = "b".repeat(32); + process.env.FRONTEND_URL = "http://localhost:3000"; + process.env.SCORE_DELTA_REPAY = "15"; + process.env.SCORE_DELTA_DEFAULT = "50"; + process.env.SCORE_DELTA_LATE = "5"; +} + describe("Environment Variable Validation", () => { const originalEnv = process.env; let mockExit: any; @@ -26,29 +46,15 @@ describe("Environment Variable Validation", () => { mockExit.mockRestore(); }); - it("should not exit if all required variables are present", () => { - // All required variables are expected to be in originalEnv/process.env - // or we set them here for the test - process.env.DATABASE_URL = "postgres://localhost"; - process.env.REDIS_URL = "redis://localhost"; - process.env.JWT_SECRET = "secret"; - process.env.STELLAR_RPC_URL = "http://localhost"; - process.env.STELLAR_NETWORK_PASSPHRASE = "test"; - process.env.LOAN_MANAGER_CONTRACT_ID = "C1"; - process.env.LENDING_POOL_CONTRACT_ID = "C2"; - process.env.POOL_TOKEN_ADDRESS = "T1"; - process.env.LOAN_MANAGER_ADMIN_SECRET = "S1"; - process.env.INTERNAL_API_KEY = "K1"; - process.env.FRONTEND_URL = "http://localhost:3000"; - process.env.SCORE_DELTA_REPAY = "15"; - process.env.SCORE_DELTA_DEFAULT = "50"; - process.env.SCORE_DELTA_LATE = "5"; + it("should not exit if all required variables are present and valid", () => { + setValidEnv(); expect(() => validateEnvVars()).not.toThrow(); expect(mockExit).not.toHaveBeenCalled(); }); it("should exit with code 1 if a required variable is missing", () => { + setValidEnv(); delete process.env.DATABASE_URL; expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); @@ -56,9 +62,61 @@ describe("Environment Variable Validation", () => { }); it("should exit with code 1 if a required variable is empty string", () => { + setValidEnv(); process.env.DATABASE_URL = " "; expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); expect(mockExit).toHaveBeenCalledWith(1); }); + + describe("JWT_SECRET format validation", () => { + it("should exit if JWT_SECRET is shorter than 32 characters", () => { + setValidEnv(); + process.env.JWT_SECRET = "short"; + + expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("should pass if JWT_SECRET is exactly 32 characters", () => { + setValidEnv(); + process.env.JWT_SECRET = "a".repeat(32); + + expect(() => validateEnvVars()).not.toThrow(); + }); + }); + + describe("INTERNAL_API_KEY format validation", () => { + it("should exit if INTERNAL_API_KEY is shorter than 32 characters", () => { + setValidEnv(); + process.env.INTERNAL_API_KEY = "short"; + + expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("should pass if INTERNAL_API_KEY is exactly 32 characters", () => { + setValidEnv(); + process.env.INTERNAL_API_KEY = "x".repeat(32); + + expect(() => validateEnvVars()).not.toThrow(); + }); + }); + + describe("LOAN_MANAGER_ADMIN_SECRET format validation", () => { + it("should exit if LOAN_MANAGER_ADMIN_SECRET is not a valid Stellar secret key", () => { + setValidEnv(); + process.env.LOAN_MANAGER_ADMIN_SECRET = "not-a-stellar-key"; + + expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("should pass if LOAN_MANAGER_ADMIN_SECRET is a valid Stellar secret key", () => { + setValidEnv(); + process.env.LOAN_MANAGER_ADMIN_SECRET = VALID_STELLAR_SECRET; + + expect(() => validateEnvVars()).not.toThrow(); + }); + }); });