From 128649a14b03a07ee6b4587a54a71c0699704799 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Sat, 28 Mar 2026 19:55:20 +0100 Subject: [PATCH] feat(backend): add environment variable validation using zod --- ENV_VARS.md | 20 ++++--- backend/src/config/__tests__/env.test.ts | 67 ++++++++++++++++++++++++ backend/src/config/env.ts | 46 ++++++++++++++++ backend/src/index.ts | 6 ++- backend/src/services/invoice.ts | 8 +-- backend/src/services/stellar.ts | 4 +- backend/src/services/verification.ts | 8 +-- 7 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 backend/src/config/__tests__/env.test.ts create mode 100644 backend/src/config/env.ts diff --git a/ENV_VARS.md b/ENV_VARS.md index fe60a7a..6344033 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -2,12 +2,13 @@ ## Backend -| Variable | Description | Default | -| -------------------- | ----------------------------------- | ------- | -| PORT | Server port | 3001 | -| CORS_ALLOWED_ORIGINS | Allowed origins for CORS | \* | -| JOBS_ENABLED | Enable/disable background jobs | true | -| STELLAR_NETWORK | Stellar network (testnet or public) | testnet | +| Variable | Description | Default | Required | +| -------------------- | ----------------------------------- | ------- | -------- | +| PORT | Server port | 3001 | No | +| CORS_ALLOWED_ORIGINS | Allowed origins for CORS | \* | No | +| JOBS_ENABLED | Enable/disable background jobs | true | No | +| STELLAR_NETWORK | Stellar network (testnet or public) | testnet | No | +| OPENAI_API_KEY | OpenAI API key for AI services | - | **Yes** | ## Frontend @@ -18,6 +19,13 @@ ## Environment Files +- `.env.example` +PORT=3001 +CORS_ALLOWED_ORIGINS=http://localhost:3000 +JOBS_ENABLED=true +STELLAR_NETWORK=testnet +OPENAI_API_KEY=sk-your-openai-api-key + - `.env.development` — local development - `.env.staging` — staging environment - `.env.production` — production environment diff --git a/backend/src/config/__tests__/env.test.ts b/backend/src/config/__tests__/env.test.ts new file mode 100644 index 0000000..7cc8d39 --- /dev/null +++ b/backend/src/config/__tests__/env.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { validateEnv, config } from '../env.js'; +import { z } from 'zod'; + +describe('Environment Validation', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + // Clear the cached config + vi.stubGlobal('process', { + ...process, + exit: vi.fn() as any, + }); + }); + + afterEach(() => { + process.env = originalEnv; + vi.unstubAllGlobals(); + }); + + it('throws and exits when OPENAI_API_KEY is missing', () => { + delete process.env.OPENAI_API_KEY; + + // We expect process.exit(1) to be called + validateEnv(); + + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('successfully parses valid environment variables', () => { + process.env.OPENAI_API_KEY = 'test-key'; + process.env.PORT = '4000'; + process.env.STELLAR_NETWORK = 'public'; + + const parsed = validateEnv(); + + expect(parsed.OPENAI_API_KEY).toBe('test-key'); + expect(parsed.PORT).toBe(4000); + expect(parsed.STELLAR_NETWORK).toBe('public'); + }); + + it('uses default values for optional variables', () => { + process.env.OPENAI_API_KEY = 'test-key'; + delete process.env.PORT; + delete process.env.STELLAR_NETWORK; + + const parsed = validateEnv(); + + expect(parsed.PORT).toBe(3001); + expect(parsed.STELLAR_NETWORK).toBe('testnet'); + }); + + it('transforms JOBS_ENABLED correctly', () => { + process.env.OPENAI_API_KEY = 'test-key'; + + process.env.JOBS_ENABLED = 'false'; + expect(validateEnv().JOBS_ENABLED).toBe(false); + + process.env.JOBS_ENABLED = 'true'; + expect(validateEnv().JOBS_ENABLED).toBe(true); + + process.env.JOBS_ENABLED = 'any-other-string'; + expect(validateEnv().JOBS_ENABLED).toBe(true); + }); +}); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..0bf7b87 --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().default(3001), + CORS_ALLOWED_ORIGINS: z.string().default('*'), + STELLAR_NETWORK: z.enum(['testnet', 'public']).default('testnet'), + OPENAI_API_KEY: z.string({ + required_error: 'OPENAI_API_KEY is required for verification and invoicing services', + }).min(1, 'OPENAI_API_KEY cannot be empty'), + JOBS_ENABLED: z.coerce.string().transform((val) => val !== 'false').default('true'), + QUEUE_ENABLED: z.coerce.string().transform((val) => val !== 'false').default('true'), + RATE_LIMIT_FREE: z.coerce.number().default(100), + RATE_LIMIT_PRO: z.coerce.number().default(300), + RATE_LIMIT_ENTERPRISE: z.coerce.number().default(1000), + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000), +}); + +export type Env = z.infer; + +let _config: Env | undefined; + +export const validateEnv = (): Env => { + try { + _config = envSchema.parse(process.env); + return _config; + } catch (error: unknown) { + if (error instanceof z.ZodError) { + const missingVars = error.errors.map((err: z.ZodIssue) => `${err.path.join('.')}: ${err.message}`); + console.error('❌ Invalid environment variables:'); + missingVars.forEach((msg: string) => console.error(` - ${msg}`)); + process.exit(1); + } + throw error; + } +}; + +export const config = (): Env => { + if (!_config) { + return validateEnv(); + } + return _config; +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 47b8554..add0291 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,7 +2,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; -import dotenv from 'dotenv'; import rateLimit from 'express-rate-limit'; import { verificationRouter } from './routes/verification.js'; import { invoiceRouter } from './routes/invoice.js'; @@ -17,8 +16,11 @@ import { errorHandler, notFoundHandler, AppError } from './middleware/errorHandl import { messageQueue } from './services/queue.js'; import { registerDefaultProcessors } from './services/queue-producers.js'; import { slaTrackingMiddleware } from './middleware/slaTracking.js'; +import { validateEnv, config } from './config/env.js'; -dotenv.config(); +// Validate environment variables at startup +validateEnv(); +const env = config(); const traceStorage = new AsyncLocalStorage(); diff --git a/backend/src/services/invoice.ts b/backend/src/services/invoice.ts index 965a406..b028427 100644 --- a/backend/src/services/invoice.ts +++ b/backend/src/services/invoice.ts @@ -1,14 +1,10 @@ import OpenAI from 'openai'; +import { config } from '../config/env.js'; let openaiClient: OpenAI | null = null; const getOpenAIClient = () => { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error( - 'The OPENAI_API_KEY environment variable is missing or empty; provide it to generate invoices.' - ); - } + const apiKey = config().OPENAI_API_KEY; if (!openaiClient) { openaiClient = new OpenAI({ apiKey }); diff --git a/backend/src/services/stellar.ts b/backend/src/services/stellar.ts index 973c465..073ba7d 100644 --- a/backend/src/services/stellar.ts +++ b/backend/src/services/stellar.ts @@ -1,6 +1,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'; +import { config } from '../config/env.js'; -const NETWORK = process.env.STELLAR_NETWORK || 'testnet'; +const NETWORK = config().STELLAR_NETWORK; const HORIZON_URL = NETWORK === 'public' ? 'https://horizon.stellar.org' @@ -8,6 +9,7 @@ const HORIZON_URL = export const server = new StellarSdk.Horizon.Server(HORIZON_URL); + export class ValidationError extends Error { statusCode: number; diff --git a/backend/src/services/verification.ts b/backend/src/services/verification.ts index 786f3dd..45041d9 100644 --- a/backend/src/services/verification.ts +++ b/backend/src/services/verification.ts @@ -1,14 +1,10 @@ import OpenAI from 'openai'; +import { config } from '../config/env.js'; let openaiClient: OpenAI | null = null; const getOpenAIClient = () => { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error( - 'The OPENAI_API_KEY environment variable is missing or empty; provide it to run verification.' - ); - } + const apiKey = config().OPENAI_API_KEY; if (!openaiClient) { openaiClient = new OpenAI({ apiKey });