From 9f949767aa2f465339b5dfb156aa9261864fef73 Mon Sep 17 00:00:00 2001 From: devbyte Date: Wed, 25 Mar 2026 10:58:53 -0400 Subject: [PATCH] chore(backend): validate required environment variables at startup Implement Zod schema for PORT, DATABASE_URL, JWT_SECRET, RPC_URL, and NODE_ENV - Integrate fail-fast validation in app entry point - Create .env.example with required configuration template - Add comprehensive suite of 8 unit tests for environment validation - Ensure 100% test and linting compliance closes #15 --- .env.example | 7 ++++ package-lock.json | 18 +++++++--- package.json | 3 +- src/config/env.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++ src/config/env.ts | 35 ++++++++++++++++++ src/index.ts | 4 ++- 6 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 src/config/env.test.ts create mode 100644 src/config/env.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16276f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# StreamPay Backend Environment Variables + +PORT=3001 +DATABASE_URL=postgres://user:password@localhost:5432/streampay +JWT_SECRET=your_super_secret_jwt_key_at_least_32_chars_long +RPC_URL=https://api.mainnet-beta.solana.com +NODE_ENV=development diff --git a/package-lock.json b/package-lock.json index c821899..f37cc8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "express": "^4.21.0", - "pg": "^8.20.0" + "pg": "^8.20.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -2461,7 +2462,7 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2471,7 +2472,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -8290,7 +8291,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -8542,6 +8543,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1b154fa..00b9375 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "express": "^4.21.0", - "pg": "^8.20.0" + "pg": "^8.20.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/config/env.test.ts b/src/config/env.test.ts new file mode 100644 index 0000000..605caee --- /dev/null +++ b/src/config/env.test.ts @@ -0,0 +1,82 @@ +import { envSchema, validateEnv } from "./env"; + +describe("Environment Configuration Schema", () => { + beforeAll(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + const validEnv = { + PORT: "3001", + DATABASE_URL: "postgres://localhost:5432/db", + JWT_SECRET: "a_very_long_secret_that_is_at_least_32_characters", + RPC_URL: "https://api.mainnet-beta.solana.com", + NODE_ENV: "development", + }; + + it("should validate a correct configuration", () => { + const result = envSchema.safeParse(validEnv); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.PORT).toBe(3001); + expect(result.data.NODE_ENV).toBe("development"); + } + }); + + it("should fail if DATABASE_URL is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { DATABASE_URL, ...invalidEnv } = validEnv; + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors).toHaveProperty("DATABASE_URL"); + } + }); + + it("should fail if JWT_SECRET is too short", () => { + const invalidEnv = { ...validEnv, JWT_SECRET: "short" }; + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.JWT_SECRET).toContain("JWT_SECRET must be at least 32 characters"); + } + }); + + it("should fail if RPC_URL is not a valid URL", () => { + const invalidEnv = { ...validEnv, RPC_URL: "not-a-url" }; + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors).toHaveProperty("RPC_URL"); + } + }); + + it("should default PORT to 3001 if missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { PORT, ...envWithoutPort } = validEnv; + const result = envSchema.safeParse(envWithoutPort); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.PORT).toBe(3001); + } + }); + + it("should default NODE_ENV to development if missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { NODE_ENV, ...envWithoutNodeEnv } = validEnv; + const result = envSchema.safeParse(envWithoutNodeEnv); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.NODE_ENV).toBe("development"); + } + }); + + describe("validateEnv function", () => { + it("should throw in test environment if validation fails", () => { + expect(() => validateEnv({})).toThrow("Invalid environment variables"); + }); + + it("should return validated data if validation succeeds", () => { + const data = validateEnv(validEnv); + expect(data.PORT).toBe(3001); + }); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..9e6cfce --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import dotenv from "dotenv"; + +// Load .env file +dotenv.config(); + +export const envSchema = z.object({ + PORT: z.coerce.number().default(3001), + DATABASE_URL: z.string().url(), + JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), + RPC_URL: z.string().url(), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), +}); + +export type Env = z.infer; + +export const validateEnv = (config: NodeJS.ProcessEnv): Env => { + const result = envSchema.safeParse(config); + + if (!result.success) { + if (process.env.NODE_ENV !== "test") { + console.error("❌ Invalid environment variables:"); + console.error(JSON.stringify(result.error.flatten().fieldErrors, null, 2)); + process.exit(1); + } + throw new Error("Invalid environment variables: " + JSON.stringify(result.error.flatten().fieldErrors)); + } + + return result.data; +}; + +// Fail fast at startup, but skip during tests to allow manual validation testing +export const env = process.env.NODE_ENV === "test" + ? ({} as unknown as Env) + : validateEnv(process.env); diff --git a/src/index.ts b/src/index.ts index 167efb0..cc0b647 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,10 @@ import streamRoutes from "./api/v1/streams"; import indexerWebhookRouter from "./routes/webhooks/indexer"; +import { env } from "./config/env"; + const app = express(); -const PORT = process.env.PORT ?? 3001; +const PORT = env.PORT; app.use(cors()); app.use("/webhooks/indexer", express.raw({ type: "application/json" }), indexerWebhookRouter);