Skip to content

Commit ce32936

Browse files
authored
Merge pull request #37 from MarvyNwaokobia/feat/issue-14-startup-secret-validation
feat(config): validate secret format and length at startup
2 parents 443956a + 043e2be commit ce32936

4 files changed

Lines changed: 133 additions & 41 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ POOL_TOKEN_ADDRESS=
1919
STELLAR_USDC_ISSUER=
2020
STELLAR_EURC_ISSUER=
2121
STELLAR_PHP_ISSUER=
22-
# Secret key for the on-chain LoanManager admin account (G... / S...)
23-
LOAN_MANAGER_ADMIN_SECRET=
22+
# Secret key for the on-chain LoanManager admin account (must be a valid Stellar S... seed)
23+
LOAN_MANAGER_ADMIN_SECRET=SAWMKGQNIPJQM5F2LD6U3BZW7DR2PZFTPH2TX2JMCCA5CKHGXW25LX6T
2424
# Optional override for score reconciliation read calls
2525
SCORE_RECONCILIATION_SOURCE_SECRET=
2626

@@ -65,7 +65,7 @@ SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD=50
6565

6666
# Authentication
6767
JWT_SECRET=your-super-secret-jwt-key-change-in-production
68-
INTERNAL_API_KEY=change-me
68+
INTERNAL_API_KEY=replace-this-with-a-real-api-key-min-32-chars
6969

7070
# Webhooks
7171
WEBHOOK_REQUEST_TIMEOUT_MS=30000

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,29 +120,33 @@ With Docker Compose from the repo root, the `backend` service runs `migrate:up`
120120

121121
### Environment Variables
122122

123-
Create a `.env` file in the backend directory:
123+
Create a `.env` file in the backend directory (see `.env.example` for all available variables):
124124

125125
```env
126126
# Server Configuration
127127
PORT=3001
128128
129129
# CORS Configuration
130130
FRONTEND_URL=http://localhost:3000
131-
# Optional backward-compatible fallback for multiple allowed origins during migration
132-
# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001
133131
134132
# Stellar Configuration
135133
STELLAR_NETWORK=testnet
136134
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
137135
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
138136
LOAN_MANAGER_CONTRACT_ID=
139-
LOAN_MANAGER_ADMIN_SECRET=
140-
141-
# Future: Add API keys for remittance services
142-
# WISE_API_KEY=your_key_here
143-
# WESTERN_UNION_API_KEY=your_key_here
137+
LOAN_MANAGER_ADMIN_SECRET=S... # valid Stellar Ed25519 secret seed
144138
```
145139

140+
#### Secret format requirements
141+
142+
The server validates secret format at startup and exits if any check fails:
143+
144+
| Variable | Requirement |
145+
| --------------------------- | --------------------------------------------------------- |
146+
| `JWT_SECRET` | At least 32 characters |
147+
| `INTERNAL_API_KEY` | At least 32 characters |
148+
| `LOAN_MANAGER_ADMIN_SECRET` | Valid Stellar Ed25519 secret seed (`S...`, 56 characters) |
149+
146150
## Available Scripts
147151

148152
```bash

src/config/env.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1+
import { StrKey } from "@stellar/stellar-sdk";
12
import logger from "../utils/logger.js";
23

3-
/**
4-
* List of environment variables required for the application to function.
5-
* If any of these are missing or empty on startup, the server will exit immediately
6-
* with a clear error message.
7-
*/
84
const REQUIRED_ENV_VARS = [
95
"DATABASE_URL",
106
"REDIS_URL",
@@ -22,12 +18,30 @@ const REQUIRED_ENV_VARS = [
2218
"SCORE_DELTA_LATE",
2319
];
2420

25-
/**
26-
* Validates that all critical environment variables are set and non-empty.
27-
* Logs a clear error message and halts the process if any requirements are unmet.
28-
*/
21+
const MIN_SECRET_LENGTH = 32;
22+
23+
function validateSecretFormat(errors: string[]): void {
24+
const jwtSecret = process.env.JWT_SECRET?.trim();
25+
if (jwtSecret && jwtSecret.length < MIN_SECRET_LENGTH) {
26+
errors.push(
27+
`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${jwtSecret.length})`,
28+
);
29+
}
30+
31+
const apiKey = process.env.INTERNAL_API_KEY?.trim();
32+
if (apiKey && apiKey.length < MIN_SECRET_LENGTH) {
33+
errors.push(
34+
`INTERNAL_API_KEY must be at least ${MIN_SECRET_LENGTH} characters (got ${apiKey.length})`,
35+
);
36+
}
37+
38+
const adminSecret = process.env.LOAN_MANAGER_ADMIN_SECRET?.trim();
39+
if (adminSecret && !StrKey.isValidEd25519SecretSeed(adminSecret)) {
40+
errors.push(`LOAN_MANAGER_ADMIN_SECRET is not a valid Stellar secret key`);
41+
}
42+
}
43+
2944
export function validateEnvVars(): void {
30-
// Filter for variables that are either absent OR just whitespace
3145
const missing = REQUIRED_ENV_VARS.filter(
3246
(key) => !process.env[key] || process.env[key]!.trim() === "",
3347
);
@@ -40,16 +54,32 @@ export function validateEnvVars(): void {
4054
const missingVarMsg = `Missing or empty required variables: ${bold(missing.join(", "))}`;
4155
const actionMsg = `Please verify these variables in your \x1b[4m.env\x1b[0m file or deployment environment.`;
4256

43-
// Direct console error for immediate visibility during startup failure
4457
console.error(`\n${errorPrefix}\n${missingVarMsg}\n${actionMsg}\n`);
4558

46-
// Structured log for persistent logs (e.g., Sentry, CloudWatch, etc.)
4759
logger.error("Environment validation failure", {
4860
missing,
4961
node_env: process.env.NODE_ENV,
5062
});
5163

52-
// Stop execution immediately
64+
process.exit(1);
65+
}
66+
67+
const formatErrors: string[] = [];
68+
validateSecretFormat(formatErrors);
69+
70+
if (formatErrors.length > 0) {
71+
const boldRed = (msg: string) => `\x1b[1;31m${msg}\x1b[0m`;
72+
73+
const errorPrefix = boldRed("FATAL ERROR: Environment validation failed");
74+
const details = formatErrors.map((e) => ` - ${e}`).join("\n");
75+
76+
console.error(`\n${errorPrefix}\n${details}\n`);
77+
78+
logger.error("Environment format validation failure", {
79+
errors: formatErrors,
80+
node_env: process.env.NODE_ENV,
81+
});
82+
5383
process.exit(1);
5484
}
5585

src/tests/envValidation.test.ts

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@ import { jest } from "@jest/globals";
33

44
jest.mock("../utils/logger.js");
55

6+
const VALID_STELLAR_SECRET =
7+
"SBJ6ZXIH5JXKHXJUDF7DUX2HY5Q3SOOAUCQ3OUO5TIIJMOYIGPPP6Q6W";
8+
9+
function setValidEnv(): void {
10+
process.env.DATABASE_URL = "postgres://localhost";
11+
process.env.REDIS_URL = "redis://localhost";
12+
process.env.JWT_SECRET = "a".repeat(32);
13+
process.env.STELLAR_RPC_URL = "http://localhost";
14+
process.env.STELLAR_NETWORK_PASSPHRASE = "test";
15+
process.env.LOAN_MANAGER_CONTRACT_ID = "C1";
16+
process.env.LENDING_POOL_CONTRACT_ID = "C2";
17+
process.env.POOL_TOKEN_ADDRESS = "T1";
18+
process.env.LOAN_MANAGER_ADMIN_SECRET = VALID_STELLAR_SECRET;
19+
process.env.INTERNAL_API_KEY = "b".repeat(32);
20+
process.env.FRONTEND_URL = "http://localhost:3000";
21+
process.env.SCORE_DELTA_REPAY = "15";
22+
process.env.SCORE_DELTA_DEFAULT = "50";
23+
process.env.SCORE_DELTA_LATE = "5";
24+
}
25+
626
describe("Environment Variable Validation", () => {
727
const originalEnv = process.env;
828
let mockExit: any;
@@ -26,39 +46,77 @@ describe("Environment Variable Validation", () => {
2646
mockExit.mockRestore();
2747
});
2848

29-
it("should not exit if all required variables are present", () => {
30-
// All required variables are expected to be in originalEnv/process.env
31-
// or we set them here for the test
32-
process.env.DATABASE_URL = "postgres://localhost";
33-
process.env.REDIS_URL = "redis://localhost";
34-
process.env.JWT_SECRET = "secret";
35-
process.env.STELLAR_RPC_URL = "http://localhost";
36-
process.env.STELLAR_NETWORK_PASSPHRASE = "test";
37-
process.env.LOAN_MANAGER_CONTRACT_ID = "C1";
38-
process.env.LENDING_POOL_CONTRACT_ID = "C2";
39-
process.env.POOL_TOKEN_ADDRESS = "T1";
40-
process.env.LOAN_MANAGER_ADMIN_SECRET = "S1";
41-
process.env.INTERNAL_API_KEY = "K1";
42-
process.env.FRONTEND_URL = "http://localhost:3000";
43-
process.env.SCORE_DELTA_REPAY = "15";
44-
process.env.SCORE_DELTA_DEFAULT = "50";
45-
process.env.SCORE_DELTA_LATE = "5";
49+
it("should not exit if all required variables are present and valid", () => {
50+
setValidEnv();
4651

4752
expect(() => validateEnvVars()).not.toThrow();
4853
expect(mockExit).not.toHaveBeenCalled();
4954
});
5055

5156
it("should exit with code 1 if a required variable is missing", () => {
57+
setValidEnv();
5258
delete process.env.DATABASE_URL;
5359

5460
expect(() => validateEnvVars()).toThrow("Process.exit called with 1");
5561
expect(mockExit).toHaveBeenCalledWith(1);
5662
});
5763

5864
it("should exit with code 1 if a required variable is empty string", () => {
65+
setValidEnv();
5966
process.env.DATABASE_URL = " ";
6067

6168
expect(() => validateEnvVars()).toThrow("Process.exit called with 1");
6269
expect(mockExit).toHaveBeenCalledWith(1);
6370
});
71+
72+
describe("JWT_SECRET format validation", () => {
73+
it("should exit if JWT_SECRET is shorter than 32 characters", () => {
74+
setValidEnv();
75+
process.env.JWT_SECRET = "short";
76+
77+
expect(() => validateEnvVars()).toThrow("Process.exit called with 1");
78+
expect(mockExit).toHaveBeenCalledWith(1);
79+
});
80+
81+
it("should pass if JWT_SECRET is exactly 32 characters", () => {
82+
setValidEnv();
83+
process.env.JWT_SECRET = "a".repeat(32);
84+
85+
expect(() => validateEnvVars()).not.toThrow();
86+
});
87+
});
88+
89+
describe("INTERNAL_API_KEY format validation", () => {
90+
it("should exit if INTERNAL_API_KEY is shorter than 32 characters", () => {
91+
setValidEnv();
92+
process.env.INTERNAL_API_KEY = "short";
93+
94+
expect(() => validateEnvVars()).toThrow("Process.exit called with 1");
95+
expect(mockExit).toHaveBeenCalledWith(1);
96+
});
97+
98+
it("should pass if INTERNAL_API_KEY is exactly 32 characters", () => {
99+
setValidEnv();
100+
process.env.INTERNAL_API_KEY = "x".repeat(32);
101+
102+
expect(() => validateEnvVars()).not.toThrow();
103+
});
104+
});
105+
106+
describe("LOAN_MANAGER_ADMIN_SECRET format validation", () => {
107+
it("should exit if LOAN_MANAGER_ADMIN_SECRET is not a valid Stellar secret key", () => {
108+
setValidEnv();
109+
process.env.LOAN_MANAGER_ADMIN_SECRET = "not-a-stellar-key";
110+
111+
expect(() => validateEnvVars()).toThrow("Process.exit called with 1");
112+
expect(mockExit).toHaveBeenCalledWith(1);
113+
});
114+
115+
it("should pass if LOAN_MANAGER_ADMIN_SECRET is a valid Stellar secret key", () => {
116+
setValidEnv();
117+
process.env.LOAN_MANAGER_ADMIN_SECRET = VALID_STELLAR_SECRET;
118+
119+
expect(() => validateEnvVars()).not.toThrow();
120+
});
121+
});
64122
});

0 commit comments

Comments
 (0)