Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
35 changes: 35 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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<typeof envSchema>;

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);
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

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);

Check failure on line 16 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-test

Cannot find name 'metricsHandler'.
app.use(metricsMiddleware);

Check failure on line 17 in src/index.ts

View workflow job for this annotation

GitHub Actions / build-test

Cannot find name 'metricsMiddleware'.

app.use(cors());
app.use("/webhooks/indexer", express.raw({ type: "application/json" }), indexerWebhookRouter);
Expand Down
Loading