diff --git a/.env.example b/.env.example index 4e2e5bf..b8ebcd6 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schem # Redis REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_REQUIRED=false +REDIS_CONNECT_TIMEOUT_MS=5000 +REDIS_STARTUP_RETRIES=10 # JWT Configuration JWT_SECRET=replace-with-strong-random-secret diff --git a/src/app.module.ts b/src/app.module.ts index 4ddacf7..2f66ac4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,13 +12,24 @@ import { RedisModule } from './database/redis.module'; import { WsModule } from './ws/ws.module'; import { FriendsModule } from './friends/friends.module'; import { DollsModule } from './dolls/dolls.module'; +import { parseRedisRequired } from './common/config/env.utils'; /** * Validates required environment variables. * Throws an error if any required variables are missing or invalid. * Returns the validated config. */ -function validateEnvironment(config: Record): Record { +function getOptionalEnvString( + config: Record, + key: string, +): string | undefined { + const value = config[key]; + return typeof value === 'string' ? value : undefined; +} + +function validateEnvironment( + config: Record, +): Record { const requiredVars = ['JWT_SECRET', 'DATABASE_URL']; const missingVars = requiredVars.filter((varName) => !config[varName]); @@ -30,10 +41,44 @@ function validateEnvironment(config: Record): Record { } // Validate PORT if provided - if (config.PORT && isNaN(Number(config.PORT))) { + if (config.PORT !== undefined && !Number.isFinite(Number(config.PORT))) { throw new Error('PORT must be a valid number'); } + if (config.NODE_ENV === 'production') { + if ( + typeof config.JWT_SECRET !== 'string' || + config.JWT_SECRET.length < 32 + ) { + throw new Error( + 'JWT_SECRET must be at least 32 characters in production', + ); + } + } + + const redisRequired = parseRedisRequired({ + nodeEnv: getOptionalEnvString(config, 'NODE_ENV'), + redisRequired: getOptionalEnvString(config, 'REDIS_REQUIRED'), + }); + + if (redisRequired && !config.REDIS_HOST) { + throw new Error( + 'REDIS_REQUIRED is enabled but REDIS_HOST is not configured', + ); + } + + const redisConnectTimeout = getOptionalEnvString( + config, + 'REDIS_CONNECT_TIMEOUT_MS', + ); + if ( + redisConnectTimeout !== undefined && + (!Number.isFinite(Number(redisConnectTimeout)) || + Number(redisConnectTimeout) <= 0) + ) { + throw new Error('REDIS_CONNECT_TIMEOUT_MS must be a positive number'); + } + validateOptionalProvider(config, 'GOOGLE'); validateOptionalProvider(config, 'DISCORD'); @@ -41,7 +86,7 @@ function validateEnvironment(config: Record): Record { } function validateOptionalProvider( - config: Record, + config: Record, provider: 'GOOGLE' | 'DISCORD', ): void { const vars = [ diff --git a/src/common/config/env.utils.ts b/src/common/config/env.utils.ts new file mode 100644 index 0000000..ac8eba2 --- /dev/null +++ b/src/common/config/env.utils.ts @@ -0,0 +1,66 @@ +export function parseBoolean( + value: string | undefined, + fallback: boolean, +): boolean { + if (value === undefined) { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + + return fallback; +} + +export function parsePositiveInteger( + value: string | undefined, + fallback: number, +): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return Math.floor(parsed); +} + +export function parseCsvList(value: string | undefined): string[] { + if (!value) { + return []; + } + + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function isLikelyHttpOrigin(origin: string): boolean { + try { + const parsed = new URL(origin); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +export function parseRedisRequired(config: { + nodeEnv?: string; + redisRequired?: string; +}): boolean { + if (config.redisRequired === undefined) { + return config.nodeEnv === 'production'; + } + + return parseBoolean(config.redisRequired, false); +}