const MIN_PORT = 1; const MIN_REQUESTS_PER_SECOND = 1; const DEFAULT_PORT = 3000; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const MAX_PORT = 65_535; const MIN_JWT_SECRET_LENGTH = 32; const TOKEN_KEY_BYTES = 32; export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; JWT_SECRET: string; LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn'; PORT: number; TBANK_CA_CERT_PATH?: string; TBANK_GRPC_ENDPOINT: string; TBANK_RATE_LIMIT_PER_SECOND: number; TOKEN_ENCRYPTION_KEY: string; } export function validateEnv(config: Record): Environment { const databaseUrl = config['DATABASE_URL']; const frontendOrigin = config['FRONTEND_ORIGIN']; const port = config['PORT'] ?? DEFAULT_PORT; const jwtSecret = config['JWT_SECRET']; const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY']; const tbankEndpoint = config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443'; const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? DEFAULT_TBANK_RATE_LIMIT_PER_SECOND; const tbankCaCertPath = config['TBANK_CA_CERT_PATH']; const logLevel = config['LOG_LEVEL'] ?? 'info'; if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) { throw new Error('DATABASE_URL must be a SQLite file URL'); } if (typeof frontendOrigin !== 'string') { throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin'); } let parsedOrigin: URL; try { parsedOrigin = new URL(frontendOrigin); } catch { throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin'); } if ( (parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') || parsedOrigin.pathname !== '/' || parsedOrigin.search || parsedOrigin.hash ) { throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin'); } const parsedPort = Number(port); if ( !Number.isInteger(parsedPort) || parsedPort < MIN_PORT || parsedPort > MAX_PORT ) { throw new Error('PORT must be a valid port number'); } if ( typeof jwtSecret !== 'string' || jwtSecret.length < MIN_JWT_SECRET_LENGTH ) { throw new Error('JWT_SECRET must be at least 32 characters long'); } if ( typeof tokenEncryptionKey !== 'string' || !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) || Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES ) { throw new Error( 'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key', ); } if (typeof tbankEndpoint !== 'string' || !tbankEndpoint) { throw new Error('TBANK_GRPC_ENDPOINT must be a non-empty endpoint'); } const parsedTbankRateLimit = Number(tbankRateLimit); if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) { throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); } if (tbankCaCertPath !== undefined && typeof tbankCaCertPath !== 'string') { throw new Error('TBANK_CA_CERT_PATH must be a file path'); } if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) { throw new Error('LOG_LEVEL must be debug, info, warn, or error'); } return { DATABASE_URL: databaseUrl, FRONTEND_ORIGIN: parsedOrigin.origin, JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? { TBANK_CA_CERT_PATH: tbankCaCertPath } : {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], }; }