diff --git a/src/app.module.ts b/src/app.module.ts index 08289a8..7585874 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AppConfigModule } from './config/config.module'; +import { AppConfigService } from './config/app-config.service'; import { ThrottlerModule, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerRedisModule } from './common/throttler/throttler-redis.module'; @@ -34,20 +35,25 @@ import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + AppConfigModule, EventEmitterModule.forRoot(), ScheduleModule.forRoot(), - BullModule.forRoot({ - connection: { - host: process.env.REDIS_HOST ?? 'localhost', - port: parseInt(process.env.REDIS_PORT ?? '6379', 10), - }, + BullModule.forRootAsync({ + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + connection: { + host: appConfig.redisHost, + port: appConfig.redisPort, + password: appConfig.redisPassword, + }, + }), }), PrismaModule, ThrottlerModule.forRootAsync({ - imports: [ConfigModule, ThrottlerRedisModule], - inject: [ConfigService, ThrottlerStorage], - useFactory: (config: ConfigService, storage: ThrottlerStorage) => ({ + imports: [AppConfigModule, ThrottlerRedisModule], + inject: [AppConfigService, ThrottlerStorage], + useFactory: (appConfig: AppConfigService, storage: ThrottlerStorage) => ({ storage, throttlers: [ { @@ -98,9 +104,8 @@ import { ScheduleModule } from '@nestjs/schedule'; ], skipIf: (context) => { const request = context.switchToHttp().getRequest(); - const whitelist = config.get('THROTTLER_WHITELIST'); - if (!whitelist) return false; - const whitelistedIps = whitelist.split(',').map((ip) => ip.trim()); + const whitelistedIps = appConfig.throttlerWhitelist; + if (whitelistedIps.length === 0) return false; return whitelistedIps.includes(request.ip); }, }), diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 33f7627..edefd20 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { GoogleStrategy } from './strategies/google.strategy'; @@ -20,25 +19,23 @@ import { EmailDeliveryProcessor } from './email-delivery.processor'; import { EncryptionModule } from '../encryption/encryption.module'; import { StellarModule } from '../stellar/stellar.module'; import { AdminGuard } from './guards/admin.guard'; +import { AppConfigModule } from '../config/config.module'; +import { AppConfigService } from '../config/app-config.service'; @Module({ imports: [ - ConfigModule, + AppConfigModule, PrismaModule, EncryptionModule, StellarModule, PassportModule.register({ session: false }), JwtModule.registerAsync({ - useFactory: () => { - const expires = - Number(process.env.JWT_EXPIRES) && Number(process.env.JWT_EXPIRES) > 0 - ? Number(process.env.JWT_EXPIRES) - : 3600; - return { - secret: process.env.JWT_SECRET || 'dev_jwt_secret', - signOptions: { expiresIn: expires }, - }; - }, + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + secret: appConfig.jwtSecret, + signOptions: { expiresIn: appConfig.jwtExpires }, + }), }), CsrfModule, BullModule.registerQueue({ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bfa8b8f..b9d3cd3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -22,6 +22,7 @@ import { LoginDto } from './dto/login.dto'; import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; import { StellarService } from '../stellar/stellar.service'; +import { AppConfigService } from '../config/app-config.service'; type JwtUser = { id: number; @@ -29,14 +30,10 @@ type JwtUser = { emailVerified?: Date | null; }; -const REFRESH_TOKEN_EXPIRES_DAYS = - Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0 - ? Number(process.env.JWT_REFRESH_EXPIRES_DAYS) - : 14; - @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + private readonly refreshTokenExpiresDays: number; constructor( private readonly jwtService: JwtService, @@ -46,7 +43,10 @@ export class AuthService { private readonly bruteForceService: BruteForceProtectionService, private readonly encryption: EncryptionService, private readonly stellarService: StellarService, - ) {} + appConfig: AppConfigService, + ) { + this.refreshTokenExpiresDays = appConfig.jwtRefreshExpiresDays; + } /** Generate a custodial Stellar keypair and persist it on the user record. */ private async assignStellarWallet(userId: number): Promise { @@ -146,7 +146,7 @@ export class AuthService { .update(rawToken) .digest('hex'); const expiresAt = new Date( - Date.now() + REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000, + Date.now() + this.refreshTokenExpiresDays * 24 * 60 * 60 * 1000, ); await this.prisma.refreshToken.create({ diff --git a/src/auth/brute-force-protection.service.ts b/src/auth/brute-force-protection.service.ts index 0bdba76..df24e8a 100644 --- a/src/auth/brute-force-protection.service.ts +++ b/src/auth/brute-force-protection.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { RedisService } from '../redis/redis.service'; +import { AppConfigService } from '../config/app-config.service'; export interface BruteForceConfig { maxAttempts: number; @@ -15,23 +15,14 @@ export class BruteForceProtectionService { private readonly redis: ReturnType; constructor( - private configService: ConfigService, + appConfig: AppConfigService, private redisService: RedisService, ) { this.redis = this.redisService.getClient(); this.config = { - maxAttempts: this.configService.get( - 'BRUTE_FORCE_MAX_ATTEMPTS', - 5, - ), - lockoutDuration: this.configService.get( - 'BRUTE_FORCE_LOCKOUT_DURATION', - 900, - ), // 15 minutes - windowDuration: this.configService.get( - 'BRUTE_FORCE_WINDOW_DURATION', - 900, - ), // 15 minutes + maxAttempts: appConfig.bruteForceMaxAttempts, + lockoutDuration: appConfig.bruteForceLockoutDuration, + windowDuration: appConfig.bruteForceWindowDuration, }; } diff --git a/src/auth/cookie.service.ts b/src/auth/cookie.service.ts index c6fe841..7a5bd9e 100644 --- a/src/auth/cookie.service.ts +++ b/src/auth/cookie.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Response } from 'express'; +import { AppConfigService } from '../config/app-config.service'; export interface CookieOptions { httpOnly: boolean; @@ -16,22 +17,11 @@ export class CookieService { private readonly accessTokenTtlMs: number; private readonly refreshTokenTtlMs: number; - constructor() { - this.useSecure = process.env.COOKIE_SECURE !== 'false'; // default true - const raw = (process.env.COOKIE_SAME_SITE ?? 'lax').toLowerCase(); - this.sameSite = raw === 'strict' || raw === 'none' ? raw : 'lax'; - - const jwtExpires = - Number(process.env.JWT_EXPIRES) > 0 - ? Number(process.env.JWT_EXPIRES) - : 3600; - const refreshDays = - Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0 - ? Number(process.env.JWT_REFRESH_EXPIRES_DAYS) - : 14; - - this.accessTokenTtlMs = jwtExpires * 1000; - this.refreshTokenTtlMs = refreshDays * 24 * 60 * 60 * 1000; + constructor(private readonly appConfig: AppConfigService) { + this.useSecure = appConfig.cookieSecure; + this.sameSite = appConfig.cookieSameSite; + this.accessTokenTtlMs = appConfig.jwtExpires * 1000; + this.refreshTokenTtlMs = appConfig.jwtRefreshExpiresDays * 24 * 60 * 60 * 1000; } private baseOptions(maxAge: number): CookieOptions { diff --git a/src/auth/email-delivery.processor.ts b/src/auth/email-delivery.processor.ts index 49dbc2a..6e430a3 100644 --- a/src/auth/email-delivery.processor.ts +++ b/src/auth/email-delivery.processor.ts @@ -1,6 +1,5 @@ import { Logger } from '@nestjs/common'; import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; -import { ConfigService } from '@nestjs/config'; import { Job } from 'bullmq'; import { MailService } from './mail.service'; import { MetricsService } from '../metrics/metrics.service'; @@ -17,7 +16,7 @@ import { getBullMQWorkerConfig } from '../config/bullmq.config'; * Default: 5 concurrent jobs (email sending is I/O-bound and can handle more parallelism) */ @Processor(EMAIL_DELIVERY_QUEUE, { - concurrency: getBullMQWorkerConfig(new ConfigService()).emailDeliveryConcurrency, + concurrency: getBullMQWorkerConfig().emailDeliveryConcurrency, }) export class EmailDeliveryProcessor extends WorkerHost { private readonly logger = new Logger(EmailDeliveryProcessor.name); @@ -27,7 +26,7 @@ export class EmailDeliveryProcessor extends WorkerHost { private readonly metricsService: MetricsService, ) { super(); - const config = getBullMQWorkerConfig(configService); + const config = getBullMQWorkerConfig(); this.logger.log( `Email delivery worker initialized with concurrency: ${config.emailDeliveryConcurrency}`, ); diff --git a/src/auth/mail.service.ts b/src/auth/mail.service.ts index ef7ed4d..39abcfa 100644 --- a/src/auth/mail.service.ts +++ b/src/auth/mail.service.ts @@ -1,20 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; import * as nodemailer from 'nodemailer'; import { EmailDeliveryJobData } from './email-delivery.queue'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); private transporter: nodemailer.Transporter; - constructor() { + constructor(private readonly appConfig: AppConfigService) { this.transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.ethereal.email', - port: Number(process.env.SMTP_PORT) || 587, - secure: process.env.SMTP_SECURE === 'true', + host: appConfig.smtpHost, + port: appConfig.smtpPort, + secure: appConfig.smtpSecure, auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, + user: appConfig.smtpUser, + pass: appConfig.smtpPass, }, }); } @@ -22,7 +23,7 @@ export class MailService { async sendTemplatedEmail(job: EmailDeliveryJobData): Promise { const content = this.buildTemplate(job.template, job.context.token); const info = await this.transporter.sendMail({ - from: process.env.SMTP_FROM || '"Clips App" ', + from: this.appConfig.smtpFrom, to: job.to, subject: job.subject, text: content.text, @@ -41,7 +42,7 @@ export class MailService { html?: string; }): Promise { const info = await this.transporter.sendMail({ - from: process.env.SMTP_FROM || '"Clips App" ', + from: this.appConfig.smtpFrom, to: options.to, subject: options.subject, text: options.text, @@ -81,7 +82,7 @@ export class MailService { } private buildTemplate(template: EmailDeliveryJobData['template'], token: string) { - const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000'; + const baseUrl = this.appConfig.appBaseUrl; if (template === 'magic-link') { const link = `${baseUrl}/auth/verify-magic?token=${token}`; return { diff --git a/src/auth/strategies/google.strategy.ts b/src/auth/strategies/google.strategy.ts index bfaa4e7..4bbf5cd 100644 --- a/src/auth/strategies/google.strategy.ts +++ b/src/auth/strategies/google.strategy.ts @@ -2,16 +2,18 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, Profile } from 'passport-google-oauth20'; import { AuthService } from '../auth.service'; +import { AppConfigService } from '../../config/app-config.service'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { - constructor(private readonly authService: AuthService) { + constructor( + private readonly authService: AuthService, + appConfig: AppConfigService, + ) { super({ - clientID: process.env.GOOGLE_CLIENT_ID || 'google-client-id', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'google-client-secret', - callbackURL: - process.env.GOOGLE_CALLBACK_URL || - 'http://localhost:3000/auth/google/callback', + clientID: appConfig.googleClientId, + clientSecret: appConfig.googleClientSecret, + callbackURL: appConfig.googleCallbackUrl, scope: ['profile', 'email'], passReqToCallback: false, }); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 9cb394d..70bb3e0 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AppConfigService } from '../../config/app-config.service'; export type JwtPayload = { sub: number; @@ -10,11 +11,11 @@ export type JwtPayload = { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor() { + constructor(appConfig: AppConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET || 'dev_jwt_secret', + secretOrKey: appConfig.jwtSecret, }); } diff --git a/src/clips/ayrshare.service.ts b/src/clips/ayrshare.service.ts index f3ea904..f83769c 100644 --- a/src/clips/ayrshare.service.ts +++ b/src/clips/ayrshare.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service'; export interface AyrsharePostResult { platform: string; @@ -16,9 +17,13 @@ export interface AyrsharePostResult { @Injectable() export class AyrshareService { private readonly logger = new Logger(AyrshareService.name); - private readonly apiKey = process.env.AYRSHARE_API_KEY ?? ''; + private readonly apiKey: string; private readonly baseUrl = 'https://app.ayrshare.com/api'; + constructor(appConfig: AppConfigService) { + this.apiKey = appConfig.ayrshareApiKey; + } + async post( mediaUrl: string, caption: string, diff --git a/src/clips/clip-generation.processor.ts b/src/clips/clip-generation.processor.ts index 6e75635..56ecdab 100644 --- a/src/clips/clip-generation.processor.ts +++ b/src/clips/clip-generation.processor.ts @@ -1,6 +1,5 @@ import { Logger } from '@nestjs/common'; import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; -import { ConfigService } from '@nestjs/config'; import { Job, UnrecoverableError } from 'bullmq'; import { EventEmitter2 } from '@nestjs/event-emitter'; import type { Clip } from './clip.entity'; @@ -80,7 +79,7 @@ const JOB_TIMEOUT_MS = 30 * 60 * 1000; * 100% → done (DB updated, all done) */ @Processor(CLIP_GENERATION_QUEUE, { - concurrency: getBullMQWorkerConfig(new ConfigService()).clipGenerationConcurrency, + concurrency: getBullMQWorkerConfig().clipGenerationConcurrency, }) export class ClipGenerationProcessor extends WorkerHost { private readonly logger = new Logger(ClipGenerationProcessor.name); @@ -94,7 +93,7 @@ export class ClipGenerationProcessor extends WorkerHost { private readonly prisma: PrismaService, ) { super(); - const config = getBullMQWorkerConfig(new ConfigService()); + const config = getBullMQWorkerConfig(); this.logger.log( `Clip generation worker initialized with concurrency: ${config.clipGenerationConcurrency}`, ); diff --git a/src/clips/clips.gateway.ts b/src/clips/clips.gateway.ts index 4212e91..3a99f39 100644 --- a/src/clips/clips.gateway.ts +++ b/src/clips/clips.gateway.ts @@ -13,6 +13,7 @@ import type { ClipFailedPayload, } from './clips.events'; import { WS_CLIP_PROGRESS, WS_CLIP_COMPLETED, WS_CLIP_FAILED } from './clips.events'; +import { getAllowedOrigins } from '../config/env.validation'; /** * WebSocket gateway for real-time clip-generation progress. @@ -32,7 +33,7 @@ import { WS_CLIP_PROGRESS, WS_CLIP_COMPLETED, WS_CLIP_FAILED } from './clips.eve @WebSocketGateway({ namespace: '/clips', cors: { - origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'], + origin: getAllowedOrigins(), credentials: true, }, }) diff --git a/src/clips/clips.module.ts b/src/clips/clips.module.ts index b6e70ee..aa85cd1 100644 --- a/src/clips/clips.module.ts +++ b/src/clips/clips.module.ts @@ -20,6 +20,8 @@ import { ClipPublishService } from './clip-publish.service'; import { RedisModule } from '../redis/redis.module'; import { QueueRateLimitGuard } from '../common/guards/queue-rate-limit.guard'; import { UserPlatformModule } from '../user-platform/user-platform.module'; +import { AppConfigModule } from '../config/config.module'; +import { AppConfigService } from '../config/app-config.service'; @Module({ imports: [ @@ -56,9 +58,13 @@ import { UserPlatformModule } from '../user-platform/user-platform.module'; StellarModule, CircuitBreakerModule, // JwtModule used by ClipsGateway to verify WebSocket handshake tokens - JwtModule.register({ - secret: process.env.JWT_SECRET ?? 'dev_jwt_secret', - signOptions: { expiresIn: '7d' }, + JwtModule.registerAsync({ + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + secret: appConfig.jwtSecret, + signOptions: { expiresIn: '7d' }, + }), }), ], controllers: [ClipsController], diff --git a/src/clips/cloudinary.service.ts b/src/clips/cloudinary.service.ts index 59e3035..f4553e1 100644 --- a/src/clips/cloudinary.service.ts +++ b/src/clips/cloudinary.service.ts @@ -3,6 +3,7 @@ import { v2 as cloudinary } from 'cloudinary'; import * as streamifier from 'streamifier'; import * as fs from 'fs'; import { CircuitBreakerService, CircuitBreakerConfig } from '../common/circuit-breaker/circuit-breaker.service'; +import { AppConfigService } from '../config/app-config.service'; export interface CloudinaryUploadResult { secure_url: string; @@ -14,6 +15,7 @@ export interface CloudinaryUploadResult { @Injectable() export class CloudinaryService { private readonly logger = new Logger(CloudinaryService.name); + private readonly cloudName: string | undefined; private readonly circuitBreakerConfig: CircuitBreakerConfig = { name: 'cloudinary-upload', @@ -29,11 +31,15 @@ export class CloudinaryService { samplingDuration: 60000, }; - constructor(private readonly circuitBreakerService: CircuitBreakerService) { + constructor( + private readonly circuitBreakerService: CircuitBreakerService, + appConfig: AppConfigService, + ) { + this.cloudName = appConfig.cloudinaryCloudName; cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, + cloud_name: appConfig.cloudinaryCloudName, + api_key: appConfig.cloudinaryApiKey, + api_secret: appConfig.cloudinaryApiSecret, }); } @@ -113,7 +119,7 @@ export class CloudinaryService { private generateThumbnailUrl(publicId: string, resourceType: string, timeRatio = 0.5): string { if (resourceType !== 'video') return ''; - return `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/video/upload/so_${Math.round(timeRatio * 100)}p/${publicId}.jpg`; + return `https://res.cloudinary.com/${this.cloudName}/video/upload/so_${Math.round(timeRatio * 100)}p/${publicId}.jpg`; } async deleteClip(publicId: string): Promise { diff --git a/src/clips/nft-mint.service.ts b/src/clips/nft-mint.service.ts index 4c1dd2b..dff3e38 100644 --- a/src/clips/nft-mint.service.ts +++ b/src/clips/nft-mint.service.ts @@ -10,7 +10,7 @@ import { StellarService } from '../stellar/stellar.service'; import StellarSdk from '@stellar/stellar-sdk'; import { MetricsService } from '../metrics/metrics.service'; import { CircuitBreakerService, CircuitBreakerConfig } from '../common/circuit-breaker/circuit-breaker.service'; -import { ConfigService } from '../config/config.service'; +import { AppConfigService } from '../config/app-config.service'; interface NftAttribute { trait_type: string; @@ -48,7 +48,7 @@ export class NftMintService { private readonly stellarService: StellarService, private readonly metricsService: MetricsService, private readonly circuitBreakerService: CircuitBreakerService, - private readonly config: ConfigService, + private readonly config: AppConfigService, ) {} private get CONTRACT_ID(): string { @@ -332,10 +332,8 @@ export class NftMintService { metadata: NftMetadata, clipId: number, ): Promise { - const pinataJwt = process.env.PINATA_JWT ?? process.env.IPFS_JWT; - const ipfsApiUrl = - process.env.IPFS_API_URL ?? - 'https://api.pinata.cloud/pinning/pinJSONToIPFS'; + const pinataJwt = this.config.pinataJwt; + const ipfsApiUrl = this.config.ipfsApiUrl; if (!pinataJwt) { throw new BadRequestException( diff --git a/src/common/guards/admin.guard.ts b/src/common/guards/admin.guard.ts index 2112241..7c22dc7 100644 --- a/src/common/guards/admin.guard.ts +++ b/src/common/guards/admin.guard.ts @@ -4,16 +4,19 @@ import { ExecutionContext, UnauthorizedException, } from '@nestjs/common'; +import { AppConfigService } from '../../config/app-config.service'; /** * Simple admin guard: requires `x-admin-secret` header matching ADMIN_SECRET env var. */ @Injectable() export class AdminGuard implements CanActivate { + constructor(private readonly appConfig: AppConfigService) {} + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const secret = request.headers['x-admin-secret']; - const expected = process.env.ADMIN_SECRET; + const expected = this.appConfig.adminSecret; if (!expected || secret !== expected) { throw new UnauthorizedException('Admin access required'); diff --git a/src/config/app-config.service.ts b/src/config/app-config.service.ts new file mode 100644 index 0000000..c02eacf --- /dev/null +++ b/src/config/app-config.service.ts @@ -0,0 +1,268 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getAllowedOrigins, parseCsvEnv } from './env.validation'; + +@Injectable() +export class AppConfigService { + readonly clipJobMaxAttempts = 5; + readonly clipJobBackoffDelayMs = 2000; + readonly nftMintJobMaxAttempts = 3; + readonly nftMintJobBackoffDelayMs = 2000; + readonly clipPostingJobMaxAttempts = 3; + readonly clipPostingJobBackoffDelayMs = 2000; + readonly emailDeliveryJobMaxAttempts = 3; + readonly emailDeliveryJobBackoffDelayMs = 1000; + readonly queueRateLimitWindowSeconds = 3600; + readonly clipGenerationMaxConcurrentPerUser = 5; + + constructor(private readonly config: ConfigService) {} + + get nodeEnv(): string { + return this.config.get('NODE_ENV', 'development'); + } + + get isProduction(): boolean { + return this.nodeEnv === 'production'; + } + + get port(): number { + return this.config.get('PORT', 3000); + } + + get logLevel(): string { + return (this.config.get('LOG_LEVEL', 'info') ?? 'info').toLowerCase(); + } + + get databaseUrl(): string | undefined { + return this.config.get('DATABASE_URL'); + } + + get encryptionSecret(): string | undefined { + return this.config.get('ENCRYPTION_SECRET'); + } + + get jwtSecret(): string { + return this.config.get('JWT_SECRET', 'dev_jwt_secret'); + } + + get jwtExpires(): number { + const value = this.config.get('JWT_EXPIRES', 3600); + return value > 0 ? value : 3600; + } + + get jwtRefreshExpiresDays(): number { + const value = this.config.get('JWT_REFRESH_EXPIRES_DAYS', 14); + return value > 0 ? value : 14; + } + + get googleClientId(): string { + return this.config.get('GOOGLE_CLIENT_ID', 'google-client-id'); + } + + get googleClientSecret(): string { + return this.config.get('GOOGLE_CLIENT_SECRET', 'google-client-secret'); + } + + get googleCallbackUrl(): string { + return this.config.get( + 'GOOGLE_CALLBACK_URL', + 'http://localhost:3000/auth/google/callback', + ); + } + + get appBaseUrl(): string { + return this.config.get('APP_BASE_URL', 'http://localhost:3000'); + } + + get smtpHost(): string { + return this.config.get('SMTP_HOST', 'smtp.ethereal.email'); + } + + get smtpPort(): number { + return this.config.get('SMTP_PORT', 587); + } + + get smtpSecure(): boolean { + return this.config.get('SMTP_SECURE', false); + } + + get smtpUser(): string | undefined { + return this.config.get('SMTP_USER'); + } + + get smtpPass(): string | undefined { + return this.config.get('SMTP_PASS'); + } + + get smtpFrom(): string { + return this.config.get('SMTP_FROM', '"Clips App" '); + } + + get allowedOrigins(): string[] { + return getAllowedOrigins(); + } + + get redisHost(): string { + return this.config.get('REDIS_HOST', 'localhost'); + } + + get redisPort(): number { + return this.config.get('REDIS_PORT', 6379); + } + + get redisPassword(): string | undefined { + return this.config.get('REDIS_PASSWORD') || undefined; + } + + get bullmqClipGenerationConcurrency(): number { + return this.config.get('BULLMQ_CLIP_GENERATION_CONCURRENCY', 2); + } + + get bullmqEmailDeliveryConcurrency(): number { + return this.config.get('BULLMQ_EMAIL_DELIVERY_CONCURRENCY', 5); + } + + get bruteForceMaxAttempts(): number { + return this.config.get('BRUTE_FORCE_MAX_ATTEMPTS', 5); + } + + get bruteForceLockoutDuration(): number { + return this.config.get('BRUTE_FORCE_LOCKOUT_DURATION', 900); + } + + get bruteForceWindowDuration(): number { + return this.config.get('BRUTE_FORCE_WINDOW_DURATION', 900); + } + + get throttlerWhitelist(): string[] { + return parseCsvEnv(this.config.get('THROTTLER_WHITELIST')); + } + + get cookieSecure(): boolean { + return this.config.get('COOKIE_SECURE', true); + } + + get cookieSameSite(): 'strict' | 'lax' | 'none' { + const raw = (this.config.get('COOKIE_SAME_SITE', 'lax') ?? 'lax').toLowerCase(); + return raw === 'strict' || raw === 'none' ? raw : 'lax'; + } + + get stellarNetwork(): string { + return this.config.get('STELLAR_NETWORK', 'testnet'); + } + + get creatorRoyaltyBps(): number { + return this.config.get('CREATOR_ROYALTY_BPS', 1000); + } + + get platformRoyaltyBps(): number { + return this.config.get('PLATFORM_ROYALTY_BPS', 100); + } + + get platformWallet(): string { + return ( + this.config.get('PLATFORM_WALLET') || + this.config.get('PLATFORM_WALLET_ADDRESS') || + '' + ); + } + + get sorobanNftContractId(): string { + return this.config.get('SOROBAN_NFT_CONTRACT_ID', ''); + } + + get pinataJwt(): string | undefined { + return this.config.get('PINATA_JWT') || this.config.get('IPFS_JWT'); + } + + get ipfsApiUrl(): string { + return ( + this.config.get('IPFS_API_URL') ?? + 'https://api.pinata.cloud/pinning/pinJSONToIPFS' + ); + } + + get cloudinaryCloudName(): string | undefined { + return this.config.get('CLOUDINARY_CLOUD_NAME'); + } + + get cloudinaryApiKey(): string | undefined { + return this.config.get('CLOUDINARY_API_KEY'); + } + + get cloudinaryApiSecret(): string | undefined { + return this.config.get('CLOUDINARY_API_SECRET'); + } + + get ayrshareApiKey(): string { + return this.config.get('AYRSHARE_API_KEY', ''); + } + + get metricsToken(): string | undefined { + return this.config.get('METRICS_TOKEN'); + } + + get leaderboardEnabled(): boolean { + return this.config.get('LEADERBOARD_ENABLED', false); + } + + get webhookSecret(): string | undefined { + return this.config.get('WEBHOOK_SECRET'); + } + + get tiktokWebhookSecret(): string | undefined { + return this.config.get('TIKTOK_WEBHOOK_SECRET'); + } + + get youtubeWebhookSecret(): string | undefined { + return this.config.get('YOUTUBE_WEBHOOK_SECRET'); + } + + get adminEmails(): string[] { + return parseCsvEnv(this.config.get('ADMIN_EMAILS')); + } + + get adminSecret(): string | undefined { + return this.config.get('ADMIN_SECRET'); + } + + get anomalyThresholdMultiplier(): number { + return this.config.get('ANOMALY_THRESHOLD_MULTIPLIER', 3); + } + + get minEarningsForAnalysis(): number { + return this.config.get('MIN_EARNINGS_FOR_ANALYSIS', 10); + } + + get anomalyLookbackDays(): number { + return this.config.get('ANOMALY_LOOKBACK_DAYS', 30); + } + + get enableSwaggerUi(): boolean { + return this.config.get('ENABLE_SWAGGER_UI', false); + } + + get gracefulShutdownTimeoutMs(): number { + return this.config.get('GRACEFUL_SHUTDOWN_TIMEOUT_MS', 30000); + } + + get payoutVerifierIntervalMs(): number { + return this.config.get('PAYOUT_VERIFIER_INTERVAL_MS', 60000); + } + + get earningsCacheTtlSeconds(): number { + return this.config.get('EARNINGS_CACHE_TTL', 3600); + } + + get bullJobRetentionDays(): number { + return this.config.get('BULL_JOB_RETENTION_DAYS', 30); + } + + get anthropicApiKey(): string | undefined { + return this.config.get('ANTHROPIC_API_KEY'); + } + + get anthropicModel(): string { + return this.config.get('ANTHROPIC_MODEL', 'claude-4.1'); + } +} diff --git a/src/config/bullmq.config.ts b/src/config/bullmq.config.ts index 578f455..462e980 100644 --- a/src/config/bullmq.config.ts +++ b/src/config/bullmq.config.ts @@ -27,19 +27,31 @@ export interface BullMQWorkerConfig { * Load BullMQ worker configuration from environment variables * with sensible defaults for each queue type. */ +function readConcurrency( + configService: ConfigService | undefined, + key: string, + defaultValue: string, +): number { + const fromConfig = configService?.get(key); + const raw = fromConfig ?? process.env[key] ?? defaultValue; + return parseInt(raw, 10); +} + export function getBullMQWorkerConfig( - configService: ConfigService, + configService?: ConfigService, ): BullMQWorkerConfig { return { // Clip generation: CPU-intensive, default to 2 concurrent jobs - clipGenerationConcurrency: parseInt( - configService.get('BULLMQ_CLIP_GENERATION_CONCURRENCY', '2'), - 10, + clipGenerationConcurrency: readConcurrency( + configService, + 'BULLMQ_CLIP_GENERATION_CONCURRENCY', + '2', ), // Email delivery: I/O-bound, default to 5 concurrent jobs - emailDeliveryConcurrency: parseInt( - configService.get('BULLMQ_EMAIL_DELIVERY_CONCURRENCY', '5'), - 10, + emailDeliveryConcurrency: readConcurrency( + configService, + 'BULLMQ_EMAIL_DELIVERY_CONCURRENCY', + '5', ), }; } diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 22c75de..79e71b9 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -1,9 +1,17 @@ -import { Module, Global } from '@nestjs/common'; -import { ConfigService } from './config.service'; +import { Global, Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { AppConfigService } from './app-config.service'; +import { validateEnv } from './env.validation'; @Global() @Module({ - providers: [ConfigService], - exports: [ConfigService], + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + validate: validateEnv, + }), + ], + providers: [AppConfigService], + exports: [AppConfigService], }) -export class ConfigModule {} +export class AppConfigModule {} diff --git a/src/config/config.service.ts b/src/config/config.service.ts deleted file mode 100644 index 8c35d23..0000000 --- a/src/config/config.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class ConfigService { - readonly earningsCacheTtlSeconds = parseInt(process.env.EARNINGS_CACHE_TTL ?? '3600', 10); - - readonly leaderboardEnabled = process.env.LEADERBOARD_ENABLED === 'true'; - - readonly creatorRoyaltyBps = parseInt(process.env.CREATOR_ROYALTY_BPS ?? '1000', 10); - - readonly platformRoyaltyBps = parseInt(process.env.PLATFORM_ROYALTY_BPS ?? '100', 10); - - readonly clipJobMaxAttempts = 5; - - readonly clipJobBackoffDelayMs = 2000; - - readonly nftMintJobMaxAttempts = 3; - - readonly nftMintJobBackoffDelayMs = 2000; - - readonly clipPostingJobMaxAttempts = 3; - - readonly clipPostingJobBackoffDelayMs = 2000; - - readonly emailDeliveryJobMaxAttempts = 3; - - readonly emailDeliveryJobBackoffDelayMs = 1000; - - readonly queueRateLimitWindowSeconds = 3600; - - readonly clipGenerationMaxConcurrentPerUser = 5; - - readonly adminEmails = (process.env.ADMIN_EMAILS ?? '').split(',').filter(Boolean); - - readonly sorobanNftContractId = process.env.SOROBAN_NFT_CONTRACT_ID || ''; - - readonly platformWallet = process.env.PLATFORM_WALLET || ''; - - readonly tiktokWebhookSecret = process.env.TIKTOK_WEBHOOK_SECRET || ''; - - readonly youtubeWebhookSecret = process.env.YOUTUBE_WEBHOOK_SECRET || ''; - - readonly redisHost = process.env.REDIS_HOST ?? 'localhost'; - - readonly redisPort = parseInt(process.env.REDIS_PORT ?? '6379', 10); - - readonly redisPassword = process.env.REDIS_PASSWORD; -} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..22b6ddf --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,42 @@ +import { validateEnv } from './env.validation'; + +describe('validateEnv', () => { + const baseConfig = { + NODE_ENV: 'development', + JWT_SECRET: 'dev_jwt_secret', + }; + + it('accepts a minimal development configuration', () => { + expect(() => validateEnv(baseConfig)).not.toThrow(); + }); + + it('rejects invalid BullMQ concurrency values', () => { + expect(() => + validateEnv({ + ...baseConfig, + BULLMQ_CLIP_GENERATION_CONCURRENCY: 0, + }), + ).toThrow(/BULLMQ_CLIP_GENERATION_CONCURRENCY/); + }); + + it('requires critical secrets in production', () => { + expect(() => + validateEnv({ + NODE_ENV: 'production', + JWT_SECRET: 'dev_jwt_secret', + }), + ).toThrow(/ENCRYPTION_SECRET/); + }); + + it('accepts a valid production configuration', () => { + expect(() => + validateEnv({ + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + ENCRYPTION_SECRET: 'a-secure-production-secret', + JWT_SECRET: 'a-secure-jwt-secret', + SOROBAN_NFT_CONTRACT_ID: 'CABC123', + }), + ).not.toThrow(); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..848e473 --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,372 @@ +import { plainToInstance, Transform } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, + validateSync, +} from 'class-validator'; + +const DEV_JWT_SECRET = 'dev_jwt_secret'; + +export class EnvironmentVariables { + @IsOptional() + @IsString() + NODE_ENV?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(65535) + PORT?: number; + + @IsOptional() + @IsString() + LOG_LEVEL?: string; + + @IsOptional() + @IsString() + DATABASE_URL?: string; + + @IsOptional() + @IsString() + ENCRYPTION_SECRET?: string; + + @IsOptional() + @IsString() + JWT_SECRET?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + JWT_EXPIRES?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + JWT_REFRESH_EXPIRES_DAYS?: number; + + @IsOptional() + @IsString() + GOOGLE_CLIENT_ID?: string; + + @IsOptional() + @IsString() + GOOGLE_CLIENT_SECRET?: string; + + @IsOptional() + @IsString() + GOOGLE_CALLBACK_URL?: string; + + @IsOptional() + @IsString() + APP_BASE_URL?: string; + + @IsOptional() + @IsString() + SMTP_HOST?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + SMTP_PORT?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + SMTP_SECURE?: boolean; + + @IsOptional() + @IsString() + SMTP_USER?: string; + + @IsOptional() + @IsString() + SMTP_PASS?: string; + + @IsOptional() + @IsString() + SMTP_FROM?: string; + + @IsOptional() + @IsString() + ALLOWED_ORIGINS?: string; + + @IsOptional() + @IsString() + REDIS_HOST?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(65535) + REDIS_PORT?: number; + + @IsOptional() + @IsString() + REDIS_PASSWORD?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(200) + BULLMQ_CLIP_GENERATION_CONCURRENCY?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(50) + BULLMQ_EMAIL_DELIVERY_CONCURRENCY?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_MAX_ATTEMPTS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_LOCKOUT_DURATION?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_WINDOW_DURATION?: number; + + @IsOptional() + @IsString() + THROTTLER_WHITELIST?: string; + + @IsOptional() + @Transform(({ value }) => value !== 'false') + @IsBoolean() + COOKIE_SECURE?: boolean; + + @IsOptional() + @IsIn(['strict', 'lax', 'none']) + COOKIE_SAME_SITE?: 'strict' | 'lax' | 'none'; + + @IsOptional() + @IsString() + STELLAR_NETWORK?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(0) + PLATFORM_ROYALTY_BPS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(0) + CREATOR_ROYALTY_BPS?: number; + + @IsOptional() + @IsString() + PLATFORM_WALLET?: string; + + @IsOptional() + @IsString() + PLATFORM_WALLET_ADDRESS?: string; + + @IsOptional() + @IsString() + SOROBAN_NFT_CONTRACT_ID?: string; + + @IsOptional() + @IsString() + PINATA_JWT?: string; + + @IsOptional() + @IsString() + IPFS_JWT?: string; + + @IsOptional() + @IsString() + IPFS_API_URL?: string; + + @IsOptional() + @IsString() + CLOUDINARY_CLOUD_NAME?: string; + + @IsOptional() + @IsString() + CLOUDINARY_API_KEY?: string; + + @IsOptional() + @IsString() + CLOUDINARY_API_SECRET?: string; + + @IsOptional() + @IsString() + AYRSHARE_API_KEY?: string; + + @IsOptional() + @IsString() + METRICS_TOKEN?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + LEADERBOARD_ENABLED?: boolean; + + @IsOptional() + @IsString() + WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + TIKTOK_WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + YOUTUBE_WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + ADMIN_EMAILS?: string; + + @IsOptional() + @IsString() + ADMIN_SECRET?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsNumber() + ANOMALY_THRESHOLD_MULTIPLIER?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsNumber() + MIN_EARNINGS_FOR_ANALYSIS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + ANOMALY_LOOKBACK_DAYS?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + ENABLE_SWAGGER_UI?: boolean; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1000) + GRACEFUL_SHUTDOWN_TIMEOUT_MS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1000) + PAYOUT_VERIFIER_INTERVAL_MS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + EARNINGS_CACHE_TTL?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BULL_JOB_RETENTION_DAYS?: number; + + @IsOptional() + @IsString() + ANTHROPIC_API_KEY?: string; + + @IsOptional() + @IsString() + ANTHROPIC_MODEL?: string; +} + +function formatValidationErrors(errors: ReturnType): string[] { + return errors.flatMap((error) => + error.constraints ? Object.values(error.constraints) : [], + ); +} + +function collectProductionRequirements(config: Record): string[] { + const isProduction = config.NODE_ENV === 'production'; + if (!isProduction) { + return []; + } + + const errors: string[] = []; + + if (!config.DATABASE_URL) { + errors.push('DATABASE_URL is required in production'); + } + if (!config.ENCRYPTION_SECRET) { + errors.push('ENCRYPTION_SECRET is required in production'); + } + if (!config.JWT_SECRET || config.JWT_SECRET === DEV_JWT_SECRET) { + errors.push('JWT_SECRET must be set to a secure value in production'); + } + if (!config.SOROBAN_NFT_CONTRACT_ID) { + errors.push('SOROBAN_NFT_CONTRACT_ID is required in production'); + } + + return errors; +} + +/** + * Validates environment variables at startup via ConfigModule.forRoot(). + * Throws with a descriptive message when configuration is invalid. + */ +export function validateEnv( + config: Record, +): Record { + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + + const schemaErrors = formatValidationErrors( + validateSync(validatedConfig, { skipMissingProperties: true }), + ); + const productionErrors = collectProductionRequirements(config); + const errors = [...schemaErrors, ...productionErrors]; + + if (errors.length > 0) { + throw new Error( + `Environment validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`, + ); + } + + return config; +} + +/** Parse comma-separated env values into a trimmed string array. */ +export function parseCsvEnv(value: string | undefined): string[] { + if (!value) { + return []; + } + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** Shared helper for static decorators that run before DI is available. */ +export function getAllowedOrigins(): string[] { + const origins = parseCsvEnv(process.env.ALLOWED_ORIGINS); + return origins.length > 0 ? origins : ['http://localhost:3000']; +} diff --git a/src/csrf/csrf.module.ts b/src/csrf/csrf.module.ts index 01322ec..4ec7b77 100644 --- a/src/csrf/csrf.module.ts +++ b/src/csrf/csrf.module.ts @@ -1,11 +1,11 @@ import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { CsrfService } from './csrf.service'; import { CsrfGuard } from './csrf.guard'; +import { AppConfigModule } from '../config/config.module'; @Module({ - imports: [ConfigModule], + imports: [AppConfigModule], providers: [ CsrfService, { @@ -17,11 +17,8 @@ import { CsrfGuard } from './csrf.guard'; }) export class CsrfModule { configure(consumer: MiddlewareConsumer) { - const configService = new ConfigService(); - consumer .apply((req: any, res: any, next: any) => { - const csrfService = new CsrfService(configService); // Skip CSRF for GET, HEAD, OPTIONS requests if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { diff --git a/src/csrf/csrf.service.ts b/src/csrf/csrf.service.ts index eba3218..0dcd7ed 100644 --- a/src/csrf/csrf.service.ts +++ b/src/csrf/csrf.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() export class CsrfService { - constructor(private configService: ConfigService) {} + constructor(private readonly appConfig: AppConfigService) {} generateToken(): string { return crypto.randomBytes(32).toString('hex'); @@ -18,13 +18,11 @@ export class CsrfService { } setCsrfCookie(res: any, token: string): void { - const isProduction = this.configService.get('NODE_ENV') === 'production'; - res.cookie('_csrf', token, { - httpOnly: false, // Allow JavaScript to read for header inclusion - secure: isProduction, + httpOnly: false, + secure: this.appConfig.isProduction, sameSite: 'strict', - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: 24 * 60 * 60 * 1000, }); } diff --git a/src/earnings/anomaly-detection.processor.ts b/src/earnings/anomaly-detection.processor.ts index 2f6e189..32b01a9 100644 --- a/src/earnings/anomaly-detection.processor.ts +++ b/src/earnings/anomaly-detection.processor.ts @@ -5,6 +5,7 @@ import { AnomalyDetectionService } from './anomaly-detection.service'; import { MetricsService } from '../metrics/metrics.service'; import { ANOMALY_DETECTION_QUEUE } from './anomaly-detection.queue'; import { MailService } from '../auth/mail.service'; +import { AppConfigService } from '../config/app-config.service'; interface AnomalyDetectionJob { earningId: number; @@ -18,6 +19,7 @@ export class AnomalyDetectionProcessor { private anomalyDetectionService: AnomalyDetectionService, private mailService: MailService, private metricsService: MetricsService, + private readonly appConfig: AppConfigService, ) {} @Process('detect-anomaly') @@ -54,7 +56,7 @@ export class AnomalyDetectionProcessor { reason: string; severity: string; }): Promise { - const adminEmails = process.env.ADMIN_EMAILS?.split(',') || []; + const adminEmails = this.appConfig.adminEmails; if (adminEmails.length === 0) { this.logger.warn('No admin emails configured for anomaly notifications'); diff --git a/src/earnings/anomaly-detection.service.ts b/src/earnings/anomaly-detection.service.ts index f7a28d8..afcdc2c 100644 --- a/src/earnings/anomaly-detection.service.ts +++ b/src/earnings/anomaly-detection.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { AppConfigService } from '../config/app-config.service'; interface AnomalyConfig { thresholdMultiplier: number; @@ -10,13 +11,18 @@ interface AnomalyConfig { @Injectable() export class AnomalyDetectionService { private readonly logger = new Logger(AnomalyDetectionService.name); - private readonly config: AnomalyConfig = { - thresholdMultiplier: parseFloat(process.env.ANOMALY_THRESHOLD_MULTIPLIER ?? '3'), - minEarningsForAnalysis: parseFloat(process.env.MIN_EARNINGS_FOR_ANALYSIS ?? '10'), - lookbackDays: parseInt(process.env.ANOMALY_LOOKBACK_DAYS ?? '30', 10), - }; - - constructor(private prisma: PrismaService) {} + private readonly config: AnomalyConfig; + + constructor( + private prisma: PrismaService, + appConfig: AppConfigService, + ) { + this.config = { + thresholdMultiplier: appConfig.anomalyThresholdMultiplier, + minEarningsForAnalysis: appConfig.minEarningsForAnalysis, + lookbackDays: appConfig.anomalyLookbackDays, + }; + } async detectAnomalies(earningId: number): Promise<{ isAnomaly: boolean; diff --git a/src/earnings/earnings-aggregation.service.ts b/src/earnings/earnings-aggregation.service.ts index 5a353cf..7ca9e30 100644 --- a/src/earnings/earnings-aggregation.service.ts +++ b/src/earnings/earnings-aggregation.service.ts @@ -3,7 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { Currency, EarningsBreakdown } from './earnings.types'; import { CurrencyConversionService } from './currency-conversion.service'; import { RedisService } from '../redis/redis.service'; -import { ConfigService } from '../config/config.service'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class EarningsAggregationService { @@ -13,7 +13,7 @@ export class EarningsAggregationService { private prisma: PrismaService, private currencyConversion: CurrencyConversionService, private redisService: RedisService, - private config: ConfigService, + private config: AppConfigService, ) {} private getCacheKey(userId: number, targetCurrency: Currency): string { diff --git a/src/encryption/encryption-cli.ts b/src/encryption/encryption-cli.ts index dfbd839..3c39dcb 100644 --- a/src/encryption/encryption-cli.ts +++ b/src/encryption/encryption-cli.ts @@ -3,17 +3,16 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from '../app.module'; import { UserPlatformService } from '../user-platform/user-platform.service'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; async function runMigration() { console.log('🔐 Starting encryption migration for UserPlatform tokens...'); const app = await NestFactory.createApplicationContext(AppModule); const userPlatformService = app.get(UserPlatformService); - const configService = app.get(ConfigService); + const appConfig = app.get(AppConfigService); - // Verify encryption secret is set - const encryptionSecret = configService.get('ENCRYPTION_SECRET'); + const encryptionSecret = appConfig.encryptionSecret; if (!encryptionSecret) { console.error('❌ ENCRYPTION_SECRET environment variable is required'); process.exit(1); diff --git a/src/encryption/encryption.service.ts b/src/encryption/encryption.service.ts index 36331d5..438a2ac 100644 --- a/src/encryption/encryption.service.ts +++ b/src/encryption/encryption.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() @@ -7,12 +7,12 @@ export class EncryptionService { private readonly algorithm = 'aes-256-gcm'; private readonly key: Buffer; - constructor(private configService: ConfigService) { - const secret = this.configService.get('ENCRYPTION_SECRET'); + constructor(private readonly appConfig: AppConfigService) { + const secret = appConfig.encryptionSecret; if (!secret) { throw new Error('ENCRYPTION_SECRET environment variable is required'); } - + // Use SHA-256 to ensure we have exactly 32 bytes for AES-256 this.key = crypto.createHash('sha256').update(secret).digest(); } diff --git a/src/encryption/encryption.spec.ts b/src/encryption/encryption.spec.ts index d894861..cd2f072 100644 --- a/src/encryption/encryption.spec.ts +++ b/src/encryption/encryption.spec.ts @@ -1,28 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import { EncryptionService } from './encryption.service'; describe('EncryptionService', () => { let service: EncryptionService; - let configService: ConfigService; beforeEach(async () => { - const mockConfigService = { - get: jest.fn().mockReturnValue('test-encryption-secret-32-chars-long'), + const mockAppConfig = { + encryptionSecret: 'test-encryption-secret-32-chars-long', }; const module: TestingModule = await Test.createTestingModule({ providers: [ EncryptionService, { - provide: ConfigService, - useValue: mockConfigService, + provide: AppConfigService, + useValue: mockAppConfig, }, ], }).compile(); service = module.get(EncryptionService); - configService = module.get(ConfigService); }); it('should be defined', () => { @@ -83,8 +81,8 @@ describe('EncryptionService', () => { it('should throw error when decrypting with a different key', () => { const plaintext = 'sensitive-access-token'; const encrypted = service.encrypt(plaintext); - const wrongConfigService = { get: jest.fn().mockReturnValue('different-test-encryption-secret') }; - const wrongService = new EncryptionService(wrongConfigService as any); + const wrongAppConfig = { encryptionSecret: 'different-test-encryption-secret' }; + const wrongService = new EncryptionService(wrongAppConfig as AppConfigService); expect(() => wrongService.decrypt(encrypted)).toThrow('Failed to decrypt sensitive data'); }); @@ -92,8 +90,8 @@ describe('EncryptionService', () => { describe('constructor', () => { it('should throw if ENCRYPTION_SECRET is missing', async () => { - const mockConfigService = { - get: jest.fn().mockReturnValue(undefined), + const mockAppConfig = { + encryptionSecret: undefined, }; await expect( @@ -101,8 +99,8 @@ describe('EncryptionService', () => { providers: [ EncryptionService, { - provide: ConfigService, - useValue: mockConfigService, + provide: AppConfigService, + useValue: mockAppConfig, }, ], }).compile(), diff --git a/src/jobs/queue-cleanup.service.ts b/src/jobs/queue-cleanup.service.ts index 7e9dbf6..c11cfd9 100644 --- a/src/jobs/queue-cleanup.service.ts +++ b/src/jobs/queue-cleanup.service.ts @@ -1,8 +1,8 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; import { CLIP_GENERATION_QUEUE } from '../clips/clip-generation.queue'; import { EMAIL_DELIVERY_QUEUE } from '../auth/email-delivery.queue'; +import { AppConfigService } from '../config/app-config.service'; const ONE_DAY_MS = 24 * 60 * 60 * 1000; const CLEAN_BATCH_LIMIT = 1000; @@ -15,7 +15,7 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { private readonly emailQueue: Queue; private cleanupTimer?: NodeJS.Timeout; - constructor(private readonly config: ConfigService) { + constructor(private readonly appConfig: AppConfigService) { const connection = this.getRedisConnection(); this.clipQueue = new Queue(CLIP_GENERATION_QUEUE, { connection }); this.emailQueue = new Queue(EMAIL_DELIVERY_QUEUE, { connection }); @@ -88,8 +88,7 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { } private getRetentionMilliseconds(): number { - const raw = this.config.get('BULL_JOB_RETENTION_DAYS'); - const retentionDays = Number.parseInt(raw ?? `${DEFAULT_RETENTION_DAYS}`, 10); + const retentionDays = this.appConfig.bullJobRetentionDays; if (Number.isNaN(retentionDays) || retentionDays < 1) { return DEFAULT_RETENTION_DAYS * ONE_DAY_MS; @@ -114,14 +113,10 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { } private getRedisConnection() { - const host = this.config.get('REDIS_HOST') ?? 'localhost'; - const port = Number.parseInt(this.config.get('REDIS_PORT') ?? '6379', 10); - const password = this.config.get('REDIS_PASSWORD'); - return { - host, - port, - password: password || undefined, + host: this.appConfig.redisHost, + port: this.appConfig.redisPort, + password: this.appConfig.redisPassword, }; } } diff --git a/src/logger/logger.module.ts b/src/logger/logger.module.ts index c9b4c7b..5539322 100644 --- a/src/logger/logger.module.ts +++ b/src/logger/logger.module.ts @@ -1,8 +1,10 @@ import { Global, Module } from '@nestjs/common'; import { AppLoggerService } from './logger.service'; +import { AppConfigModule } from '../config/config.module'; @Global() @Module({ + imports: [AppConfigModule], providers: [AppLoggerService], exports: [AppLoggerService], }) diff --git a/src/logger/logger.service.spec.ts b/src/logger/logger.service.spec.ts index 70b8da2..8cc0b3e 100644 --- a/src/logger/logger.service.spec.ts +++ b/src/logger/logger.service.spec.ts @@ -1,4 +1,13 @@ import { AppLoggerService } from './logger.service'; +import { AppConfigService } from '../config/app-config.service'; + +function createMockAppConfig(overrides: Partial = {}): AppConfigService { + return { + logLevel: 'debug', + isProduction: true, + ...overrides, + } as AppConfigService; +} describe('AppLoggerService', () => { let service: AppLoggerService; @@ -6,16 +15,13 @@ describe('AppLoggerService', () => { let consoleSpy: jest.SpyInstance; beforeEach(() => { - process.env.NODE_ENV = 'production'; - process.env.LOG_LEVEL = 'debug'; - service = new AppLoggerService(); + service = new AppLoggerService(createMockAppConfig()); stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); - delete process.env.LOG_LEVEL; }); it('outputs JSON in production', () => { @@ -53,8 +59,7 @@ describe('AppLoggerService', () => { }); it('respects LOG_LEVEL — suppresses debug when level is warn', () => { - process.env.LOG_LEVEL = 'warn'; - service = new AppLoggerService(); + service = new AppLoggerService(createMockAppConfig({ logLevel: 'warn' })); service.debug('should not appear'); expect(stdoutSpy).not.toHaveBeenCalled(); }); diff --git a/src/logger/logger.service.ts b/src/logger/logger.service.ts index fc46d8a..6842b5a 100644 --- a/src/logger/logger.service.ts +++ b/src/logger/logger.service.ts @@ -1,4 +1,5 @@ import { Injectable, LoggerService, Scope } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service'; export interface LogContext { requestId?: string; @@ -37,9 +38,9 @@ export class AppLoggerService implements LoggerService { verbose: 4, }; - constructor() { - this.level = (process.env.LOG_LEVEL ?? 'info').toLowerCase(); - this.isProduction = process.env.NODE_ENV === 'production'; + constructor(private readonly appConfig: AppConfigService) { + this.level = appConfig.logLevel; + this.isProduction = appConfig.isProduction; } private shouldLog(level: string): boolean { diff --git a/src/main.ts b/src/main.ts index c313017..01d82b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; @@ -11,6 +10,7 @@ import { AppModule } from './app.module'; import { PayoutsService } from './payouts/payouts.service'; import { MetricsInterceptor } from './metrics/metrics.interceptor'; import { AppLoggerService } from './logger/logger.service'; +import { AppConfigService } from './config/app-config.service'; import { getBullMQWorkerConfig, validateWorkerConfig, @@ -19,11 +19,11 @@ import { async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); - const isProduction = process.env.NODE_ENV === 'production'; + const appConfig = app.get(AppConfigService); + const isProduction = appConfig.isProduction; // Validate BullMQ worker configuration on startup - const configService = app.get(ConfigService); - const workerConfig = getBullMQWorkerConfig(configService); + const workerConfig = getBullMQWorkerConfig(); try { validateWorkerConfig(workerConfig); logger.log( @@ -113,7 +113,7 @@ async function bootstrap() { logger.log(`OpenAPI spec exported to ${openapiPath}`); // Setup Swagger UI (only in non-production or if explicitly enabled) - const enableSwaggerUI = !isProduction || process.env.ENABLE_SWAGGER_UI === 'true'; + const enableSwaggerUI = !isProduction || appConfig.enableSwaggerUi; if (enableSwaggerUI) { SwaggerModule.setup('api/docs', app, document, { swaggerOptions: { @@ -129,9 +129,7 @@ async function bootstrap() { logger.log('Swagger UI disabled in production. Set ENABLE_SWAGGER_UI=true to enable.'); } - const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [ - 'http://localhost:3000', - ]; + const allowedOrigins = appConfig.allowedOrigins; app.enableCors({ origin: allowedOrigins, credentials: true, // required for cross-origin cookie support @@ -191,7 +189,7 @@ async function bootstrap() { // in-flight work to finish (e.g. BullMQ processors should finish jobs). const shutdown = async (signal: string) => { logger.log(`Received ${signal}, shutting down gracefully...`); - const timeoutMs = Number(process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS) || 30000; + const timeoutMs = appConfig.gracefulShutdownTimeoutMs; const forceExit = setTimeout(() => { logger.error(`Shutdown timed out after ${timeoutMs}ms — forcing exit.`); process.exit(1); @@ -212,12 +210,12 @@ async function bootstrap() { process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT')); - await app.listen(process.env.PORT ?? 3000); + await app.listen(appConfig.port); // Start periodic payout verification to confirm on-chain transactions try { const payoutsService = app.get(PayoutsService); - const intervalMs = parseInt(process.env.PAYOUT_VERIFIER_INTERVAL_MS ?? '60000', 10); + const intervalMs = appConfig.payoutVerifierIntervalMs; // Run once on startup void payoutsService.verifyPendingPayouts().catch((err) => logger.error(`Payout verifier initial run failed: ${err?.message ?? err}`)); diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index 4ccb5a9..4dfc017 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import Redis from 'ioredis'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class RedisService { private readonly logger = new Logger(RedisService.name); private readonly redis: Redis; - constructor() { + constructor(appConfig: AppConfigService) { this.redis = new Redis({ - host: process.env.REDIS_HOST ?? 'localhost', - port: parseInt(process.env.REDIS_PORT ?? '6379', 10), - password: process.env.REDIS_PASSWORD || undefined, + host: appConfig.redisHost, + port: appConfig.redisPort, + password: appConfig.redisPassword, lazyConnect: true, }); diff --git a/src/videos/video.service.ts b/src/videos/video.service.ts index f293e82..10777a4 100644 --- a/src/videos/video.service.ts +++ b/src/videos/video.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; +import { AppConfigService } from '../config/app-config.service'; import ffmpeg from 'fluent-ffmpeg'; type ViralMoment = { start: number; end: number; reason: string }; @@ -11,7 +11,7 @@ export class VideoService { constructor( private readonly prisma: PrismaService, - private readonly config: ConfigService, + private readonly appConfig: AppConfigService, ) {} async detectViralTimestamps(videoId: number): Promise { @@ -118,10 +118,8 @@ export class VideoService { } private async callClaudeApi(videoUrl: string) { - const apiKey = - this.config.get('ANTHROPIC_API_KEY') || - process.env.ANTHROPIC_API_KEY; - const model = this.config.get('ANTHROPIC_MODEL') || 'claude-4.1'; + const apiKey = this.appConfig.anthropicApiKey; + const model = this.appConfig.anthropicModel; const maxClips = 30; const minClips = 10; @@ -238,7 +236,7 @@ export class VideoService { provider: string, usage?: { inputTokens?: number; outputTokens?: number }, ) { - const model = this.config.get('ANTHROPIC_MODEL') || 'claude-4.1'; + const model = this.appConfig.anthropicModel; if (usage?.inputTokens || usage?.outputTokens) { this.logger.log( diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index 640cb5d..d8380b5 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -1,18 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EarningsService } from '../earnings/earnings.service'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() export class WebhooksService { private readonly logger = new Logger(WebhooksService.name); - private readonly tiktokSecret = process.env.TIKTOK_WEBHOOK_SECRET; - private readonly youtubeSecret = process.env.YOUTUBE_WEBHOOK_SECRET; + private readonly tiktokSecret: string | undefined; + private readonly youtubeSecret: string | undefined; constructor( private prisma: PrismaService, private earningsService: EarningsService, - ) {} + appConfig: AppConfigService, + ) { + this.tiktokSecret = appConfig.tiktokWebhookSecret; + this.youtubeSecret = appConfig.youtubeWebhookSecret; + } async validateTikTokSignature(payload: any, signature: string): Promise { if (!this.tiktokSecret) {