From 35e2d1493e7ee9d75a808d85dccfdf6124041268 Mon Sep 17 00:00:00 2001 From: marvy Date: Sun, 21 Jun 2026 12:16:42 +0100 Subject: [PATCH 1/3] feat(config): validate secret format and length at startup Enforce minimum 32-char length for JWT_SECRET and INTERNAL_API_KEY, and validate LOAN_MANAGER_ADMIN_SECRET as a valid Stellar secret key. Fails fast with a clear message naming the offending variable. Closes #14 --- src/config/env.ts | 58 ++++++++++++++++----- src/tests/envValidation.test.ts | 92 +++++++++++++++++++++++++++------ 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 5abcca3..5ef239f 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,32 @@ 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 +56,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(); + }); + }); }); From e6e4757809c8017b4357344f420d8c2c29a2a9a7 Mon Sep 17 00:00:00 2001 From: marvy Date: Mon, 22 Jun 2026 08:30:47 +0100 Subject: [PATCH 2/3] fix(config): update .env.example to pass startup validation and fix prettier .env.example INTERNAL_API_KEY was 8 chars (under the new 32-char min) and LOAN_MANAGER_ADMIN_SECRET was empty. Replace with valid dev defaults so a fresh checkout passes startup validation. Document the secret format requirements in the README. --- .env.example | 6 +++--- README.md | 20 ++++++++++++-------- src/config/env.ts | 4 +--- 3 files changed, 16 insertions(+), 14 deletions(-) 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..842e4a4 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 5ef239f..03ce9b3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -37,9 +37,7 @@ function validateSecretFormat(errors: string[]): void { 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`, - ); + errors.push(`LOAN_MANAGER_ADMIN_SECRET is not a valid Stellar secret key`); } } From 043e2be7254aa3709ca924d01ddec77fdf4f0b9d Mon Sep 17 00:00:00 2001 From: marvy Date: Tue, 23 Jun 2026 16:30:40 +0100 Subject: [PATCH 3/3] style(docs): format README secret-validation table with prettier Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 842e4a4..9c5ff95 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,10 @@ LOAN_MANAGER_ADMIN_SECRET=S... # valid Stellar Ed25519 secret seed 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 | +| 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