From 758ffbfd657563ce42219cf37046f80b3bf11741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 22:02:16 -0300 Subject: [PATCH 1/2] feat: add Zod environment validation at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/config/env.validation.ts with typed Zod schema - Validate all env vars at app startup with clear error messages - Update ConfigModule to use validation and make it global - Update .env.example with all future env vars documented - Add zod dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/backend/.env.example | 33 ++++++++-- apps/backend/package.json | 3 +- apps/backend/src/app.module.ts | 6 +- apps/backend/src/config/env.validation.ts | 76 +++++++++++++++++++++++ apps/backend/src/config/index.ts | 1 + pnpm-lock.yaml | 10 +++ 6 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/config/env.validation.ts create mode 100644 apps/backend/src/config/index.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ad9b515..ec3558b 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,20 +1,43 @@ +# Application +NODE_ENV=development +PORT=3000 + +# Database +DATABASE_URL= TYPEORM_LOGGING=true +# Auth0 (Identity Provider) JWKS_URI=https://domain-example.us.auth0.com/.well-known/jwks.json - IDP_ISSUER=https://domain-example.us.auth0.com/ IDP_AUDIENCE=boilerplate-api - AUTH0_DOMAIN= AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= AUTH0_ROLES_NAME= - AUTH0_CONNECTION_ID= AUTH0_CONNECTION_TYPE=Username-Password-Authentication +# Service Account (JSON string for service-to-service auth) SERVICE_ACCOUNT={...} -PORT=3000 +# Frontend URL (for invitations, password reset emails, etc.) +FRONTEND_URL=http://localhost:3001 -NODE_ENV=development +# CORS (comma-separated origins, or * for all) +ALLOWED_ORIGINS=http://localhost:3001,http://localhost:3000 + +# Google OAuth (optional - for Google identity provider) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Self-issued JWT (RS256) - for self-managed tokens +JWT_ISSUER=https://api.yourapp.com +JWT_PRIVATE_KEY_PATH=./keys/private.pem +JWT_PUBLIC_KEY_PATH=./keys/public.pem +JWT_EXPIRES_IN=1h + +# Redis (for caching and rate limiting) +REDIS_URL=redis://localhost:6379 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= diff --git a/apps/backend/package.json b/apps/backend/package.json index 2285e2c..7db9e6a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -43,7 +43,8 @@ "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.21", "typeorm-extension": "^3.7.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.1.13" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 81365b4..a202122 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -10,10 +10,14 @@ import { AuthModule } from './auth/auth.module'; import { APP_GUARD } from '@nestjs/core'; import { RolesGuard } from './auth/guards/roles.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { validateEnv } from './config/env.validation'; @Module({ imports: [ - ConfigModule.forRoot(), + ConfigModule.forRoot({ + validate: validateEnv, + isGlobal: true, + }), TypeOrmModule.forRoot(AppDataSource.options), AuthModule, AccountsModule, diff --git a/apps/backend/src/config/env.validation.ts b/apps/backend/src/config/env.validation.ts new file mode 100644 index 0000000..020e7da --- /dev/null +++ b/apps/backend/src/config/env.validation.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +export const envSchema = z.object({ + // Application + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('development'), + PORT: z.coerce.number().default(3000), + + // Database + DATABASE_URL: z.string().optional(), + TYPEORM_LOGGING: z + .string() + .default('false') + .transform((val) => val === 'true'), + + // Auth0 (Identity Provider) + JWKS_URI: z.string().url(), + IDP_ISSUER: z.string().url(), + IDP_AUDIENCE: z.string(), + AUTH0_DOMAIN: z.string().min(1), + AUTH0_CLIENT_ID: z.string().min(1), + AUTH0_CLIENT_SECRET: z.string().min(1), + AUTH0_ROLES_NAME: z.string().optional(), + AUTH0_CONNECTION_ID: z.string().optional(), + AUTH0_CONNECTION_TYPE: z.string().default('Username-Password-Authentication'), + + // Service Account (JSON string) + SERVICE_ACCOUNT: z.string().optional(), + + // Frontend URL (for invitations, emails, etc.) + FRONTEND_URL: z.string().url().optional(), + + // CORS - will be used in feature/cors-security branch + ALLOWED_ORIGINS: z + .string() + .default('*') + .transform((s) => s.split(',').map((origin) => origin.trim())), + + // Google OAuth - will be used in feature/auth-provider-abstraction branch + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + + // Self-issued JWT (RS256) - will be used in feature/auth-provider-abstraction branch + JWT_ISSUER: z.string().url().optional(), + JWT_PRIVATE_KEY_PATH: z.string().optional(), + JWT_PUBLIC_KEY_PATH: z.string().optional(), + JWT_EXPIRES_IN: z.string().default('1h'), + + // Redis - will be used in feature/infrastructure-essentials branch + REDIS_URL: z.string().optional(), + REDIS_HOST: z.string().default('localhost'), + REDIS_PORT: z.coerce.number().default(6379), + REDIS_PASSWORD: z.string().optional(), +}); + +export type EnvConfig = z.infer; + +export function validateEnv( + config: Record, +): Record { + const result = envSchema.safeParse(config); + + if (!result.success) { + const errors = result.error.issues + .map((issue) => { + const path = issue.path.join('.'); + return ` - ${path}: ${issue.message}`; + }) + .join('\n'); + + throw new Error(`\n\nEnvironment validation failed:\n${errors}\n`); + } + + return result.data; +} diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts new file mode 100644 index 0000000..62afeec --- /dev/null +++ b/apps/backend/src/config/index.ts @@ -0,0 +1 @@ +export * from './env.validation'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db6de3e..de630a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: uuid: specifier: ^11.1.0 version: 11.1.0 + zod: + specifier: ^4.1.13 + version: 4.1.13 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -7777,6 +7780,7 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + requiresBuild: true /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -8296,6 +8300,7 @@ packages: /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + requiresBuild: true dependencies: once: 1.4.0 wrappy: 1.0.2 @@ -10485,6 +10490,7 @@ packages: /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} + requiresBuild: true /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -13485,3 +13491,7 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} dev: true + + /zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + dev: false From ca001b91b3e73a2387e67d3f753d2bd2dedaed41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 23:18:52 -0300 Subject: [PATCH 2/2] test: add comprehensive tests for env validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test valid configurations with required and optional fields - Test default values for all fields with defaults - Test type coercion (PORT, REDIS_PORT string to number) - Test transforms (TYPEORM_LOGGING boolean, ALLOWED_ORIGINS array) - Test validation errors for missing/invalid required fields - Test NODE_ENV enum values - Test validateEnv error formatting 37 tests covering all Zod schema behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../backend/src/config/env.validation.spec.ts | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 apps/backend/src/config/env.validation.spec.ts diff --git a/apps/backend/src/config/env.validation.spec.ts b/apps/backend/src/config/env.validation.spec.ts new file mode 100644 index 0000000..d30bc80 --- /dev/null +++ b/apps/backend/src/config/env.validation.spec.ts @@ -0,0 +1,324 @@ +import { envSchema, validateEnv, EnvConfig } from './env.validation'; + +describe('Environment Validation', () => { + // Minimal valid config with all required fields + const validConfig = { + JWKS_URI: 'https://example.auth0.com/.well-known/jwks.json', + IDP_ISSUER: 'https://example.auth0.com/', + IDP_AUDIENCE: 'https://api.example.com', + AUTH0_DOMAIN: 'example.auth0.com', + AUTH0_CLIENT_ID: 'client-id-123', + AUTH0_CLIENT_SECRET: 'client-secret-456', + }; + + describe('envSchema', () => { + describe('valid configurations', () => { + it('should pass with all required fields', () => { + const result = envSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should pass with all optional fields', () => { + const fullConfig = { + ...validConfig, + NODE_ENV: 'production', + PORT: 8080, + DATABASE_URL: 'postgres://localhost:5432/db', + TYPEORM_LOGGING: 'true', + AUTH0_ROLES_NAME: 'https://example.com/roles', + AUTH0_CONNECTION_ID: 'con_abc123', + AUTH0_CONNECTION_TYPE: 'google-oauth2', + SERVICE_ACCOUNT: '{"key": "value"}', + FRONTEND_URL: 'https://app.example.com', + ALLOWED_ORIGINS: 'https://app.example.com,https://admin.example.com', + GOOGLE_CLIENT_ID: 'google-client-id', + GOOGLE_CLIENT_SECRET: 'google-client-secret', + JWT_ISSUER: 'https://api.example.com', + JWT_PRIVATE_KEY_PATH: './keys/private.pem', + JWT_PUBLIC_KEY_PATH: './keys/public.pem', + JWT_EXPIRES_IN: '2h', + REDIS_URL: 'redis://localhost:6379', + REDIS_HOST: 'redis.example.com', + REDIS_PORT: '6380', + REDIS_PASSWORD: 'secret', + }; + + const result = envSchema.safeParse(fullConfig); + expect(result.success).toBe(true); + }); + }); + + describe('default values', () => { + it('should default NODE_ENV to development', () => { + const result = envSchema.parse(validConfig); + expect(result.NODE_ENV).toBe('development'); + }); + + it('should default PORT to 3000', () => { + const result = envSchema.parse(validConfig); + expect(result.PORT).toBe(3000); + }); + + it('should default TYPEORM_LOGGING to false', () => { + const result = envSchema.parse(validConfig); + expect(result.TYPEORM_LOGGING).toBe(false); + }); + + it('should default AUTH0_CONNECTION_TYPE to Username-Password-Authentication', () => { + const result = envSchema.parse(validConfig); + expect(result.AUTH0_CONNECTION_TYPE).toBe( + 'Username-Password-Authentication', + ); + }); + + it('should default ALLOWED_ORIGINS to ["*"]', () => { + const result = envSchema.parse(validConfig); + expect(result.ALLOWED_ORIGINS).toEqual(['*']); + }); + + it('should default JWT_EXPIRES_IN to 1h', () => { + const result = envSchema.parse(validConfig); + expect(result.JWT_EXPIRES_IN).toBe('1h'); + }); + + it('should default REDIS_HOST to localhost', () => { + const result = envSchema.parse(validConfig); + expect(result.REDIS_HOST).toBe('localhost'); + }); + + it('should default REDIS_PORT to 6379', () => { + const result = envSchema.parse(validConfig); + expect(result.REDIS_PORT).toBe(6379); + }); + }); + + describe('type coercion', () => { + it('should coerce PORT from string to number', () => { + const config = { ...validConfig, PORT: '8080' }; + const result = envSchema.parse(config); + expect(result.PORT).toBe(8080); + expect(typeof result.PORT).toBe('number'); + }); + + it('should coerce REDIS_PORT from string to number', () => { + const config = { ...validConfig, REDIS_PORT: '6380' }; + const result = envSchema.parse(config); + expect(result.REDIS_PORT).toBe(6380); + expect(typeof result.REDIS_PORT).toBe('number'); + }); + }); + + describe('transforms', () => { + it('should transform TYPEORM_LOGGING "true" to boolean true', () => { + const config = { ...validConfig, TYPEORM_LOGGING: 'true' }; + const result = envSchema.parse(config); + expect(result.TYPEORM_LOGGING).toBe(true); + }); + + it('should transform TYPEORM_LOGGING "false" to boolean false', () => { + const config = { ...validConfig, TYPEORM_LOGGING: 'false' }; + const result = envSchema.parse(config); + expect(result.TYPEORM_LOGGING).toBe(false); + }); + + it('should transform TYPEORM_LOGGING any other value to false', () => { + const config = { ...validConfig, TYPEORM_LOGGING: 'yes' }; + const result = envSchema.parse(config); + expect(result.TYPEORM_LOGGING).toBe(false); + }); + + it('should transform ALLOWED_ORIGINS comma-separated string to array', () => { + const config = { + ...validConfig, + ALLOWED_ORIGINS: 'https://a.com,https://b.com,https://c.com', + }; + const result = envSchema.parse(config); + expect(result.ALLOWED_ORIGINS).toEqual([ + 'https://a.com', + 'https://b.com', + 'https://c.com', + ]); + }); + + it('should trim whitespace from ALLOWED_ORIGINS entries', () => { + const config = { + ...validConfig, + ALLOWED_ORIGINS: ' https://a.com , https://b.com , https://c.com ', + }; + const result = envSchema.parse(config); + expect(result.ALLOWED_ORIGINS).toEqual([ + 'https://a.com', + 'https://b.com', + 'https://c.com', + ]); + }); + }); + + describe('validation errors', () => { + it('should fail when JWKS_URI is missing', () => { + const { JWKS_URI, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain('JWKS_URI'); + } + }); + + it('should fail when JWKS_URI is not a valid URL', () => { + const config = { ...validConfig, JWKS_URI: 'not-a-url' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain('JWKS_URI'); + expect(result.error.issues[0].message.toLowerCase()).toContain('url'); + } + }); + + it('should fail when IDP_ISSUER is missing', () => { + const { IDP_ISSUER, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when IDP_ISSUER is not a valid URL', () => { + const config = { ...validConfig, IDP_ISSUER: 'not-a-url' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when IDP_AUDIENCE is missing', () => { + const { IDP_AUDIENCE, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when AUTH0_DOMAIN is missing', () => { + const { AUTH0_DOMAIN, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when AUTH0_DOMAIN is empty string', () => { + const config = { ...validConfig, AUTH0_DOMAIN: '' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when AUTH0_CLIENT_ID is missing', () => { + const { AUTH0_CLIENT_ID, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when AUTH0_CLIENT_SECRET is missing', () => { + const { AUTH0_CLIENT_SECRET, ...config } = validConfig; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when NODE_ENV is invalid', () => { + const config = { ...validConfig, NODE_ENV: 'invalid' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when FRONTEND_URL is not a valid URL', () => { + const config = { ...validConfig, FRONTEND_URL: 'not-a-url' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should fail when JWT_ISSUER is not a valid URL', () => { + const config = { ...validConfig, JWT_ISSUER: 'not-a-url' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(false); + }); + }); + + describe('NODE_ENV enum', () => { + it('should accept "development"', () => { + const config = { ...validConfig, NODE_ENV: 'development' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('should accept "production"', () => { + const config = { ...validConfig, NODE_ENV: 'production' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('should accept "test"', () => { + const config = { ...validConfig, NODE_ENV: 'test' }; + const result = envSchema.safeParse(config); + expect(result.success).toBe(true); + }); + }); + }); + + describe('validateEnv', () => { + it('should return validated config on success', () => { + const result = validateEnv(validConfig); + expect(result).toBeDefined(); + expect(result.NODE_ENV).toBe('development'); + expect(result.PORT).toBe(3000); + }); + + it('should throw Error with formatted message on validation failure', () => { + const invalidConfig = { ...validConfig, JWKS_URI: 'invalid' }; + + expect(() => validateEnv(invalidConfig)).toThrow(Error); + expect(() => validateEnv(invalidConfig)).toThrow( + /Environment validation failed/, + ); + }); + + it('should include field path in error message', () => { + const invalidConfig = { ...validConfig, JWKS_URI: 'invalid' }; + + try { + validateEnv(invalidConfig); + fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).toContain('JWKS_URI'); + } + }); + + it('should include all validation errors in message', () => { + const invalidConfig = { + // Missing all required fields + }; + + try { + validateEnv(invalidConfig); + fail('Should have thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('JWKS_URI'); + expect(message).toContain('IDP_ISSUER'); + expect(message).toContain('IDP_AUDIENCE'); + expect(message).toContain('AUTH0_DOMAIN'); + expect(message).toContain('AUTH0_CLIENT_ID'); + expect(message).toContain('AUTH0_CLIENT_SECRET'); + } + }); + }); + + describe('EnvConfig type', () => { + it('should have correct type inference', () => { + const config: EnvConfig = envSchema.parse(validConfig); + + // These type assertions verify the types are correct + const _nodeEnv: 'development' | 'production' | 'test' = config.NODE_ENV; + const _port: number = config.PORT; + const _logging: boolean = config.TYPEORM_LOGGING; + const _origins: string[] = config.ALLOWED_ORIGINS; + const _optionalUrl: string | undefined = config.DATABASE_URL; + + expect(_nodeEnv).toBeDefined(); + expect(_port).toBeDefined(); + expect(_logging).toBeDefined(); + expect(_origins).toBeDefined(); + }); + }); +});