Skip to content

Commit d33a803

Browse files
Merge pull request #166 from temisan0x/feature/env-config
feat: add environment configuration and validation with Zod (#2)
2 parents 64c1cff + 5290e97 commit d33a803

File tree

6 files changed

+247
-39
lines changed

6 files changed

+247
-39
lines changed

.env.example

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# =============================================================================
2+
# Callora Backend — Environment Variables
3+
# Copy this file to .env and fill in your values.
4+
# Never commit .env to version control.
5+
# =============================================================================
6+
7+
# -----------------------------------------------------------------------------
8+
# Server
9+
# -----------------------------------------------------------------------------
10+
PORT=3000
11+
NODE_ENV=development # development | production | test
12+
13+
# -----------------------------------------------------------------------------
14+
# Database — primary connection string (used by Prisma / pg.Pool)
15+
# -----------------------------------------------------------------------------
16+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/callora?schema=public
17+
18+
# -----------------------------------------------------------------------------
19+
# Database — individual fields (used by health checks and direct Pool creation)
20+
# -----------------------------------------------------------------------------
21+
DB_HOST=localhost
22+
DB_PORT=5432
23+
DB_USER=postgres
24+
DB_PASSWORD=postgres
25+
DB_NAME=callora
26+
27+
# -----------------------------------------------------------------------------
28+
# Database — connection pool tuning
29+
# -----------------------------------------------------------------------------
30+
DB_POOL_MAX=10
31+
DB_IDLE_TIMEOUT_MS=30000
32+
DB_CONN_TIMEOUT_MS=2000
33+
34+
# -----------------------------------------------------------------------------
35+
# Auth — REQUIRED, app will not start without these
36+
# -----------------------------------------------------------------------------
37+
JWT_SECRET=your-jwt-secret-here
38+
ADMIN_API_KEY=your-admin-api-key-here
39+
METRICS_API_KEY=your-metrics-api-key-here
40+
41+
# -----------------------------------------------------------------------------
42+
# Proxy / Gateway
43+
# -----------------------------------------------------------------------------
44+
UPSTREAM_URL=http://localhost:4000
45+
PROXY_TIMEOUT_MS=30000
46+
47+
# -----------------------------------------------------------------------------
48+
# CORS — comma-separated list of allowed origins
49+
# -----------------------------------------------------------------------------
50+
CORS_ALLOWED_ORIGINS=http://localhost:5173
51+
52+
# -----------------------------------------------------------------------------
53+
# Soroban RPC (optional — set SOROBAN_RPC_ENABLED=true to activate)
54+
# -----------------------------------------------------------------------------
55+
SOROBAN_RPC_ENABLED=false
56+
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
57+
SOROBAN_RPC_TIMEOUT=2000
58+
59+
# -----------------------------------------------------------------------------
60+
# Horizon (optional — set HORIZON_ENABLED=true to activate)
61+
# -----------------------------------------------------------------------------
62+
HORIZON_ENABLED=false
63+
HORIZON_URL=https://horizon-testnet.stellar.org
64+
HORIZON_TIMEOUT=2000
65+
66+
# -----------------------------------------------------------------------------
67+
# Health checks
68+
# -----------------------------------------------------------------------------
69+
HEALTH_CHECK_DB_TIMEOUT=2000
70+
APP_VERSION=1.0.0
71+
72+
# -----------------------------------------------------------------------------
73+
# Logging
74+
# -----------------------------------------------------------------------------
75+
LOG_LEVEL=info
76+
77+
# -----------------------------------------------------------------------------
78+
# Profiling
79+
# -----------------------------------------------------------------------------
80+
GATEWAY_PROFILING_ENABLED=false

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ package-lock.json
1414
database.db
1515
database.db-*
1616
coverage/
17+
18+
!.env.example

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,42 @@ callora-backend/
8080

8181
## Environment
8282

83-
- `PORT` — HTTP port (default: 3000). Optional for local dev.
83+
Copy `.env.example` to `.env` and fill in your values before running locally:
84+
85+
```bash
86+
cp .env.example .env
87+
```
88+
89+
The app validates all environment variables at startup using [Zod](https://zod.dev). If a required variable is missing, the app will exit immediately with a clear error message.
90+
91+
| Variable | Required | Default | Description |
92+
|---|---|---|---|
93+
| `PORT` | No | `3000` | HTTP port |
94+
| `NODE_ENV` | No | `development` | `development` / `production` / `test` |
95+
| `DATABASE_URL` | No | local postgres | Primary PostgreSQL connection string |
96+
| `DB_HOST` | No | `localhost` | Database host |
97+
| `DB_PORT` | No | `5432` | Database port |
98+
| `DB_USER` | No | `postgres` | Database user |
99+
| `DB_PASSWORD` | No | `postgres` | Database password |
100+
| `DB_NAME` | No | `callora` | Database name |
101+
| `DB_POOL_MAX` | No | `10` | Max pool connections |
102+
| `DB_IDLE_TIMEOUT_MS` | No | `30000` | Pool idle timeout (ms) |
103+
| `DB_CONN_TIMEOUT_MS` | No | `2000` | Pool connection timeout (ms) |
104+
| `JWT_SECRET` | **Yes** || Secret for signing JWTs |
105+
| `ADMIN_API_KEY` | **Yes** || Key for admin endpoints |
106+
| `METRICS_API_KEY` | **Yes** || Key for `/api/metrics` in production |
107+
| `UPSTREAM_URL` | No | `http://localhost:4000` | Gateway upstream URL |
108+
| `PROXY_TIMEOUT_MS` | No | `30000` | Proxy request timeout (ms) |
109+
| `CORS_ALLOWED_ORIGINS` | No | `http://localhost:5173` | Comma-separated allowed origins |
110+
| `SOROBAN_RPC_ENABLED` | No | `false` | Enable Soroban RPC health check |
111+
| `SOROBAN_RPC_URL` | If `SOROBAN_RPC_ENABLED=true` || Soroban RPC endpoint URL |
112+
| `SOROBAN_RPC_TIMEOUT` | No | `2000` | Soroban RPC timeout (ms) |
113+
| `HORIZON_ENABLED` | No | `false` | Enable Horizon health check |
114+
| `HORIZON_URL` | If `HORIZON_ENABLED=true` || Horizon endpoint URL |
115+
| `HORIZON_TIMEOUT` | No | `2000` | Horizon timeout (ms) |
116+
| `HEALTH_CHECK_DB_TIMEOUT` | No | `2000` | DB health check timeout (ms) |
117+
| `APP_VERSION` | No | `1.0.0` | Reported in health check responses |
118+
| `LOG_LEVEL` | No | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal` |
119+
| `GATEWAY_PROFILING_ENABLED` | No | `false` | Enable request profiling |
84120

85121
This repo is part of [Callora](https://github.com/your-org/callora). Frontend: `callora-frontend`. Contracts: `callora-contracts`.

src/config/env.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import 'dotenv/config';
2+
import { z } from 'zod';
3+
4+
const envSchema = z
5+
.object({
6+
// Server
7+
PORT: z.coerce.number().default(3000),
8+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
9+
10+
// Database (primary connection string)
11+
DATABASE_URL: z
12+
.string()
13+
.default('postgresql://postgres:postgres@localhost:5432/callora?schema=public'),
14+
15+
// Database pool
16+
DB_POOL_MAX: z.coerce.number().default(10),
17+
DB_IDLE_TIMEOUT_MS: z.coerce.number().default(30_000),
18+
DB_CONN_TIMEOUT_MS: z.coerce.number().default(2_000),
19+
20+
// Database (individual fields for health checks)
21+
DB_HOST: z.string().default('localhost'),
22+
DB_PORT: z.coerce.number().default(5432),
23+
DB_USER: z.string().default('postgres'),
24+
DB_PASSWORD: z.string().default('postgres'),
25+
DB_NAME: z.string().default('callora'),
26+
27+
// Auth
28+
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
29+
ADMIN_API_KEY: z.string().min(1, 'ADMIN_API_KEY is required'),
30+
METRICS_API_KEY: z.string().min(1, 'METRICS_API_KEY is required'),
31+
32+
// Proxy / Gateway
33+
UPSTREAM_URL: z.string().url().default('http://localhost:4000'),
34+
PROXY_TIMEOUT_MS: z.coerce.number().default(30_000),
35+
36+
// CORS
37+
CORS_ALLOWED_ORIGINS: z.string().default('http://localhost:5173'),
38+
39+
// Soroban RPC (optional)
40+
SOROBAN_RPC_ENABLED: z
41+
.string()
42+
.transform((v) => v === 'true')
43+
.default('false'),
44+
SOROBAN_RPC_URL: z.string().url().optional(),
45+
SOROBAN_RPC_TIMEOUT: z.coerce.number().default(2_000),
46+
47+
// Horizon (optional)
48+
HORIZON_ENABLED: z
49+
.string()
50+
.transform((v) => v === 'true')
51+
.default('false'),
52+
HORIZON_URL: z.string().url().optional(),
53+
HORIZON_TIMEOUT: z.coerce.number().default(2_000),
54+
55+
// Health check
56+
HEALTH_CHECK_DB_TIMEOUT: z.coerce.number().default(2_000),
57+
APP_VERSION: z.string().default('1.0.0'),
58+
59+
// Logging
60+
LOG_LEVEL: z
61+
.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal'])
62+
.default('info'),
63+
64+
// Profiling
65+
GATEWAY_PROFILING_ENABLED: z
66+
.string()
67+
.transform((v) => v === 'true')
68+
.default('false'),
69+
})
70+
.superRefine((values, ctx) => {
71+
if (values.SOROBAN_RPC_ENABLED && !values.SOROBAN_RPC_URL) {
72+
ctx.addIssue({
73+
code: z.ZodIssueCode.custom,
74+
path: ['SOROBAN_RPC_URL'],
75+
message: 'SOROBAN_RPC_URL is required when SOROBAN_RPC_ENABLED=true',
76+
});
77+
}
78+
79+
if (values.HORIZON_ENABLED && !values.HORIZON_URL) {
80+
ctx.addIssue({
81+
code: z.ZodIssueCode.custom,
82+
path: ['HORIZON_URL'],
83+
message: 'HORIZON_URL is required when HORIZON_ENABLED=true',
84+
});
85+
}
86+
});
87+
88+
const parsed = envSchema.safeParse(process.env);
89+
90+
if (!parsed.success) {
91+
console.error('❌ Invalid environment configuration:');
92+
parsed.error.issues.forEach((issue) => {
93+
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
94+
});
95+
process.exit(1);
96+
}
97+
98+
export const env = parsed.data;

src/config/health.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,71 +5,61 @@
55
*/
66

77
import { Pool } from 'pg';
8+
import { env } from './env.js';
89
import type { HealthCheckConfig } from '../services/healthCheck.js';
910

1011
let dbPool: Pool | null = null;
1112

12-
/**
13-
* Creates or returns existing database connection pool
14-
*/
1513
function getDbPool(): Pool {
1614
if (!dbPool) {
1715
dbPool = new Pool({
18-
host: process.env.DB_HOST || 'localhost',
19-
port: parseInt(process.env.DB_PORT || '5432', 10),
20-
user: process.env.DB_USER || 'postgres',
21-
password: process.env.DB_PASSWORD || 'postgres',
22-
database: process.env.DB_NAME || 'callora',
23-
max: 10,
24-
idleTimeoutMillis: 30000,
25-
connectionTimeoutMillis: 5000,
16+
host: env.DB_HOST,
17+
port: env.DB_PORT,
18+
user: env.DB_USER,
19+
password: env.DB_PASSWORD,
20+
database: env.DB_NAME,
21+
max: env.DB_POOL_MAX,
22+
idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
23+
connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS,
2624
});
2725
}
2826
return dbPool;
2927
}
3028

31-
/**
32-
* Builds health check configuration from environment variables
33-
*/
3429
export function buildHealthCheckConfig(): HealthCheckConfig | undefined {
35-
// Only enable detailed health checks if database is configured
36-
if (!process.env.DB_HOST && !process.env.DB_NAME) {
30+
// Only enable detailed health checks if database is explicitly configured
31+
if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') {
3732
return undefined;
3833
}
3934

40-
const config: HealthCheckConfig = {
41-
version: process.env.APP_VERSION || '1.0.0',
35+
const healthConfig: HealthCheckConfig = {
36+
version: env.APP_VERSION,
4237
database: {
4338
pool: getDbPool(),
44-
timeout: parseInt(process.env.HEALTH_CHECK_DB_TIMEOUT || '2000', 10),
39+
timeout: env.HEALTH_CHECK_DB_TIMEOUT,
4540
},
4641
};
4742

48-
// Add Soroban RPC if enabled
49-
if (process.env.SOROBAN_RPC_ENABLED === 'true' && process.env.SOROBAN_RPC_URL) {
50-
config.sorobanRpc = {
51-
url: process.env.SOROBAN_RPC_URL,
52-
timeout: parseInt(process.env.SOROBAN_RPC_TIMEOUT || '2000', 10),
43+
if (env.SOROBAN_RPC_ENABLED && env.SOROBAN_RPC_URL) {
44+
healthConfig.sorobanRpc = {
45+
url: env.SOROBAN_RPC_URL,
46+
timeout: env.SOROBAN_RPC_TIMEOUT,
5347
};
5448
}
5549

56-
// Add Horizon if enabled
57-
if (process.env.HORIZON_ENABLED === 'true' && process.env.HORIZON_URL) {
58-
config.horizon = {
59-
url: process.env.HORIZON_URL,
60-
timeout: parseInt(process.env.HORIZON_TIMEOUT || '2000', 10),
50+
if (env.HORIZON_ENABLED && env.HORIZON_URL) {
51+
healthConfig.horizon = {
52+
url: env.HORIZON_URL,
53+
timeout: env.HORIZON_TIMEOUT,
6154
};
6255
}
6356

64-
return config;
57+
return healthConfig;
6558
}
6659

67-
/**
68-
* Closes database pool gracefully
69-
*/
7060
export async function closeDbPool(): Promise<void> {
7161
if (dbPool) {
7262
await dbPool.end();
7363
dbPool = null;
7464
}
75-
}
65+
}

src/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import './config/env.js'
12
import express from 'express';
23
import { initializeDb, closeDb } from './db/index.js';
34
import { type AuthenticatedLocals } from './middleware/requireAuth.js';
@@ -14,6 +15,7 @@ import { createUsageStore } from './services/usageStore.js';
1415
import { createSettlementStore } from './services/settlementStore.js';
1516
import { createApiRegistry } from './data/apiRegistry.js';
1617
import { ApiKey } from './types/gateway.js';
18+
import { config } from './config/index.js';
1719

1820

1921

@@ -59,7 +61,7 @@ if (isDirectExecution) {
5961
billing,
6062
rateLimiter,
6163
usageStore,
62-
upstreamUrl: process.env.UPSTREAM_URL ?? 'http://localhost:4000',
64+
upstreamUrl: config.proxy.upstreamUrl,
6365
apiKeys,
6466
});
6567
app.use('/api/gateway', gatewayRouter);
@@ -72,7 +74,7 @@ if (isDirectExecution) {
7274
registry,
7375
apiKeys,
7476
proxyConfig: {
75-
timeoutMs: parseInt(process.env.PROXY_TIMEOUT_MS ?? '30000', 10),
77+
timeoutMs: config.proxy.timeoutMs,
7678
},
7779
});
7880
app.use('/v1/call', proxyRouter);
@@ -83,7 +85,7 @@ if (isDirectExecution) {
8385
// Global error handler (must be after all routes)
8486
app.use(errorHandler);
8587

86-
const PORT = process.env.PORT ?? 3000;
88+
const PORT = config.port;
8789

8890
// Initialize database and start server
8991
async function startServer() {
@@ -158,4 +160,4 @@ if (isDirectExecution) {
158160
startServer();
159161
}
160162

161-
export default app;
163+
export default app;

0 commit comments

Comments
 (0)