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 53b1d71..5f8be52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "drizzle-orm": "^0.45.1", "express": "^4.21.0", "pg": "^8.20.0", - "prom-client": "^15.1.3" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -2473,7 +2473,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", "peer": true, "dependencies": { @@ -2484,7 +2484,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", "peer": true, "dependencies": { @@ -8341,7 +8341,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": { @@ -8593,6 +8593,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 3937a82..00b9375 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "drizzle-orm": "^0.45.1", "express": "^4.21.0", "pg": "^8.20.0", - "prom-client": "^15.1.3" + "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 b3f0f1a..aaeb355 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,10 @@ import v1Router from "./api/v1/router"; 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.get("/metrics", metricsHandler); app.use(metricsMiddleware);