Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,29 +120,33 @@ 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
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
Expand Down
56 changes: 43 additions & 13 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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() === "",
);
Expand All @@ -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);
}

Expand Down
92 changes: 75 additions & 17 deletions src/tests/envValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,39 +46,77 @@ 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");
expect(mockExit).toHaveBeenCalledWith(1);
});

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();
});
});
});