diff --git a/backend/package.json b/backend/package.json index 6bc7f8dbd..d68db98ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@keyv/redis": "^5.1.6", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", @@ -47,6 +48,7 @@ "archiver": "^7.0.1", "axios": "^1.13.5", "bcrypt": "^6.0.0", + "bullmq": "^5.79.1", "cache-manager": "^7.2.8", "cache-manager-redis-store": "^3.0.1", "cacheable": "^2.3.4", @@ -55,6 +57,7 @@ "compression": "^1.7.4", "dotenv": "^17.3.1", "helmet": "^8.1.0", + "ioredis": "^5.11.1", "isomorphic-dompurify": "^3.15.0", "joi": "^18.0.2", "multer": "^2.1.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dda263a47..a4cec6f64 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -52,6 +52,7 @@ import { PostmanModule } from './common/postman/postman.module'; import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware'; import { CompressionMetricsMiddleware } from './common/middleware/compression.middleware'; import { JobsModule } from './modules/jobs/jobs.module'; +import { JobQueueModule } from './modules/job-queue/job-queue.module'; import { GracefulShutdownService } from './common/services/graceful-shutdown.service'; import { ApmModule } from './modules/apm/apm.module'; import { PerformanceModule } from './modules/performance/performance.module'; @@ -325,6 +326,7 @@ const envValidationSchema = Joi.object({ ApmModule, FeatureFlagsModule, JobsModule, + JobQueueModule, SandboxModule, FeedbackModule, CommonModule, diff --git a/backend/src/auth/dto/auth.dto.ts b/backend/src/auth/dto/auth.dto.ts index 0064b9896..738b9cea5 100644 --- a/backend/src/auth/dto/auth.dto.ts +++ b/backend/src/auth/dto/auth.dto.ts @@ -4,23 +4,30 @@ import { MinLength, MaxLength, IsOptional, + Matches, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsStellarPublicKey } from '../../common/validators/is-stellar-key.validator'; +import { IsStrongPassword } from '../../common/validators/is-strong-password.validator'; +import { Trim } from '../../common/validators/sanitize.transform'; export class RegisterDto { @ApiProperty({ example: 'alice@example.com' }) + @Trim() @IsEmail() email: string; @ApiProperty({ example: 'supersecret123' }) @IsString() @MinLength(8) - @MaxLength(32) + @MaxLength(72, { message: 'password must not exceed 72 characters' }) + @IsStrongPassword() password: string; @ApiProperty({ example: 'Alice', required: false }) @IsString() + @Trim() + @MaxLength(255) name?: string; @ApiPropertyOptional({ @@ -29,6 +36,10 @@ export class RegisterDto { }) @IsOptional() @IsString() + @Trim() + @Matches(/^[A-Z0-9]{4,12}$/, { + message: 'referralCode must be 4-12 uppercase alphanumeric characters', + }) referralCode?: string; } diff --git a/backend/src/common/circuit-breaker/circuit-breaker.module.ts b/backend/src/common/circuit-breaker/circuit-breaker.module.ts index 8ddc16147..cea285241 100644 --- a/backend/src/common/circuit-breaker/circuit-breaker.module.ts +++ b/backend/src/common/circuit-breaker/circuit-breaker.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { CircuitBreakerService } from './circuit-breaker.service'; +import { ExternalCallService } from './external-call.service'; +import { DependencyHealthController } from './dependency-health.controller'; @Module({ - providers: [CircuitBreakerService], - exports: [CircuitBreakerService], + controllers: [DependencyHealthController], + providers: [CircuitBreakerService, ExternalCallService], + exports: [CircuitBreakerService, ExternalCallService], }) export class CircuitBreakerModule {} diff --git a/backend/src/common/circuit-breaker/dependency-health.controller.ts b/backend/src/common/circuit-breaker/dependency-health.controller.ts new file mode 100644 index 000000000..6eaa30293 --- /dev/null +++ b/backend/src/common/circuit-breaker/dependency-health.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ExternalCallService } from './external-call.service'; +import { Roles } from '../decorators/roles.decorator'; +import { Role } from '../enums/role.enum'; + +@ApiTags('Admin - Dependencies') +@ApiBearerAuth() +@Controller('admin/dependencies') +@Roles(Role.ADMIN) +export class DependencyHealthController { + constructor(private readonly externalCallService: ExternalCallService) {} + + @Get('health') + @ApiOperation({ summary: 'Get health status of all external dependencies' }) + getHealth() { + return { + success: true, + data: this.externalCallService.getDependencyHealth(), + }; + } + + @Get('metrics') + @ApiOperation({ + summary: 'Get recent call metrics for external dependencies', + }) + getMetrics(@Query('dependency') dependency?: string) { + const metrics = this.externalCallService.getMetrics(dependency); + return { + success: true, + data: { + count: metrics.length, + metrics: metrics.slice(-50), + }, + }; + } +} diff --git a/backend/src/common/circuit-breaker/external-call.service.spec.ts b/backend/src/common/circuit-breaker/external-call.service.spec.ts new file mode 100644 index 000000000..b08c5a8af --- /dev/null +++ b/backend/src/common/circuit-breaker/external-call.service.spec.ts @@ -0,0 +1,161 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ExternalCallService } from './external-call.service'; + +describe('ExternalCallService', () => { + let service: ExternalCallService; + let eventEmitter: EventEmitter2; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExternalCallService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(5), + }, + }, + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ExternalCallService); + eventEmitter = module.get(EventEmitter2); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('execute', () => { + it('should execute a successful call', async () => { + const result = await service.execute('email', async () => 'success'); + expect(result).toBe('success'); + }); + + it('should retry on failure and succeed', async () => { + let attempts = 0; + const result = await service.execute( + 'email', + async () => { + attempts++; + if (attempts < 2) throw new Error('temp failure'); + return 'recovered'; + }, + { retryDelayMs: 10 }, + ); + expect(result).toBe('recovered'); + expect(attempts).toBe(2); + }); + + it('should throw after all retries exhausted', async () => { + await expect( + service.execute( + 'email', + async () => { + throw new Error('permanent failure'); + }, + { maxRetries: 1, retryDelayMs: 10 }, + ), + ).rejects.toThrow('permanent failure'); + }); + + it('should timeout slow calls', async () => { + await expect( + service.execute( + 'email', + () => new Promise((resolve) => setTimeout(resolve, 5000)), + { timeoutMs: 50, maxRetries: 0 }, + ), + ).rejects.toThrow('timed out'); + }, 10000); + + it('should emit dependency.call event', async () => { + await service.execute('email', async () => 'ok'); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'dependency.call', + expect.objectContaining({ + dependency: 'email', + success: true, + }), + ); + }); + }); + + describe('executeWithFallback', () => { + it('should return primary result on success', async () => { + const result = await service.executeWithFallback( + 'email', + async () => 'primary', + () => 'fallback', + ); + expect(result).toBe('primary'); + }); + + it('should use fallback when primary fails', async () => { + const result = await service.executeWithFallback( + 'email', + async () => { + throw new Error('failed'); + }, + () => 'fallback', + { maxRetries: 0, retryDelayMs: 10 }, + ); + expect(result).toBe('fallback'); + }); + + it('should support async fallback', async () => { + const result = await service.executeWithFallback( + 'email', + async () => { + throw new Error('failed'); + }, + async () => 'async-fallback', + { maxRetries: 0, retryDelayMs: 10 }, + ); + expect(result).toBe('async-fallback'); + }); + }); + + describe('getDependencyHealth', () => { + it('should return health for all registered dependencies', () => { + const health = service.getDependencyHealth(); + expect(health).toHaveProperty('email'); + expect(health).toHaveProperty('stellar-rpc'); + expect(health['email']).toHaveProperty('state'); + expect(health['email']).toHaveProperty('failureRate'); + expect(health['email']).toHaveProperty('avgLatencyMs'); + }); + }); + + describe('getMetrics', () => { + it('should return empty metrics initially', () => { + const metrics = service.getMetrics(); + expect(metrics).toEqual([]); + }); + + it('should record metrics after calls', async () => { + await service.execute('email', async () => 'ok'); + const metrics = service.getMetrics('email'); + expect(metrics).toHaveLength(1); + expect(metrics[0].success).toBe(true); + expect(metrics[0].dependency).toBe('email'); + }); + + it('should filter by dependency name', async () => { + await service.execute('email', async () => 'ok'); + await service.execute('storage', async () => 'ok'); + + const emailMetrics = service.getMetrics('email'); + expect(emailMetrics).toHaveLength(1); + expect(emailMetrics[0].dependency).toBe('email'); + }); + }); +}); diff --git a/backend/src/common/circuit-breaker/external-call.service.ts b/backend/src/common/circuit-breaker/external-call.service.ts new file mode 100644 index 000000000..774b90a8c --- /dev/null +++ b/backend/src/common/circuit-breaker/external-call.service.ts @@ -0,0 +1,239 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CircuitBreaker, CircuitBreakerState } from './circuit-breaker.config'; + +export interface DependencyConfig { + name: string; + timeoutMs: number; + maxRetries: number; + retryDelayMs: number; + failureThreshold?: number; + recoveryTimeoutMs?: number; +} + +export interface DependencyCallMetrics { + dependency: string; + duration: number; + success: boolean; + error?: string; + timestamp: Date; + circuitState: CircuitBreakerState; +} + +const DEFAULT_DEPENDENCIES: Record> = { + 'stellar-rpc': { + timeoutMs: 10000, + maxRetries: 3, + retryDelayMs: 1000, + }, + 'stellar-horizon': { + timeoutMs: 15000, + maxRetries: 3, + retryDelayMs: 1000, + }, + email: { + timeoutMs: 10000, + maxRetries: 2, + retryDelayMs: 2000, + }, + kyc: { + timeoutMs: 15000, + maxRetries: 2, + retryDelayMs: 3000, + }, + storage: { + timeoutMs: 30000, + maxRetries: 2, + retryDelayMs: 2000, + }, +}; + +@Injectable() +export class ExternalCallService { + private readonly logger = new Logger(ExternalCallService.name); + private readonly breakers = new Map(); + private readonly metricsBuffer: DependencyCallMetrics[] = []; + private readonly MAX_METRICS = 500; + + constructor( + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + ) { + for (const [name, defaults] of Object.entries(DEFAULT_DEPENDENCIES)) { + this.getOrCreateBreaker(name, defaults); + } + } + + async execute( + dependencyName: string, + fn: () => Promise, + opts?: Partial, + ): Promise { + const config = this.resolveConfig(dependencyName, opts); + const breaker = this.getOrCreateBreaker(dependencyName, config); + + const start = Date.now(); + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + try { + const result = await breaker.execute(() => + this.withTimeout(fn(), config.timeoutMs), + ); + + this.recordMetrics(dependencyName, Date.now() - start, true, breaker); + return result; + } catch (error) { + lastError = error as Error; + + this.logger.warn( + `[${dependencyName}] Attempt ${attempt + 1}/${config.maxRetries + 1} failed: ${lastError.message}`, + ); + + if (attempt < config.maxRetries) { + const delay = config.retryDelayMs * Math.pow(2, attempt); + await this.sleep(delay); + } + } + } + + this.recordMetrics( + dependencyName, + Date.now() - start, + false, + breaker, + lastError?.message, + ); + + throw lastError!; + } + + async executeWithFallback( + dependencyName: string, + fn: () => Promise, + fallback: () => T | Promise, + opts?: Partial, + ): Promise { + try { + return await this.execute(dependencyName, fn, opts); + } catch (error) { + this.logger.warn( + `[${dependencyName}] All attempts failed, using fallback: ${(error as Error).message}`, + ); + return fallback(); + } + } + + getMetrics(dependencyName?: string): DependencyCallMetrics[] { + if (dependencyName) { + return this.metricsBuffer.filter((m) => m.dependency === dependencyName); + } + return [...this.metricsBuffer]; + } + + getDependencyHealth(): Record< + string, + { + state: CircuitBreakerState; + failureRate: number; + totalRequests: number; + avgLatencyMs: number; + } + > { + const health: Record = {}; + + for (const [name, breaker] of this.breakers) { + const metrics = breaker.getMetrics(); + const depMetrics = this.metricsBuffer.filter( + (m) => m.dependency === name, + ); + const avgLatency = + depMetrics.length > 0 + ? depMetrics.reduce((sum, m) => sum + m.duration, 0) / + depMetrics.length + : 0; + + health[name] = { + state: metrics.state, + failureRate: metrics.failureRate, + totalRequests: metrics.totalRequests, + avgLatencyMs: Math.round(avgLatency), + }; + } + + return health; + } + + private resolveConfig( + name: string, + opts?: Partial, + ): Omit { + const defaults = DEFAULT_DEPENDENCIES[name] || { + timeoutMs: 10000, + maxRetries: 2, + retryDelayMs: 1000, + }; + + return { ...defaults, ...opts }; + } + + private getOrCreateBreaker( + name: string, + config: Omit, + ): CircuitBreaker { + if (!this.breakers.has(name)) { + this.breakers.set(name, new CircuitBreaker(this.configService, name)); + } + return this.breakers.get(name)!; + } + + private recordMetrics( + dependency: string, + duration: number, + success: boolean, + breaker: CircuitBreaker, + error?: string, + ) { + const metric: DependencyCallMetrics = { + dependency, + duration, + success, + error, + timestamp: new Date(), + circuitState: breaker.getState(), + }; + + if (this.metricsBuffer.length >= this.MAX_METRICS) { + this.metricsBuffer.shift(); + } + this.metricsBuffer.push(metric); + + this.eventEmitter.emit('dependency.call', metric); + + if (!success) { + this.logger.error( + `[${dependency}] Call failed after ${duration}ms: ${error}`, + ); + } else if (duration > 5000) { + this.logger.warn(`[${dependency}] Slow call detected: ${duration}ms`); + } + } + + private withTimeout(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`External call timed out after ${timeoutMs}ms`)), + timeoutMs, + ), + ), + ]); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/backend/src/common/database/connection-pool.controller.ts b/backend/src/common/database/connection-pool.controller.ts index ad6321aab..c516a1d5b 100644 --- a/backend/src/common/database/connection-pool.controller.ts +++ b/backend/src/common/database/connection-pool.controller.ts @@ -20,8 +20,14 @@ export class ConnectionPoolController { ) {} @Get('summary') - @ApiOperation({ summary: 'Get connection pool summary with utilization trends' }) - @ApiResponse({ status: 200, description: 'Pool summary including current metrics, averages, and acquisition latency' }) + @ApiOperation({ + summary: 'Get connection pool summary with utilization trends', + }) + @ApiResponse({ + status: 200, + description: + 'Pool summary including current metrics, averages, and acquisition latency', + }) getSummary() { return this.poolService.getPoolSummary(); } diff --git a/backend/src/common/dto/standard-error-response.dto.ts b/backend/src/common/dto/standard-error-response.dto.ts index 1ab9e3769..1cfe99a18 100644 --- a/backend/src/common/dto/standard-error-response.dto.ts +++ b/backend/src/common/dto/standard-error-response.dto.ts @@ -106,7 +106,8 @@ export class StandardErrorResponseDto { @ApiProperty({ type: DebugContext, - description: 'Debug information (present only in development/test environments)', + description: + 'Debug information (present only in development/test environments)', required: false, }) debugContext?: DebugContext; diff --git a/backend/src/common/filters/enhanced-exception.filter.ts b/backend/src/common/filters/enhanced-exception.filter.ts index efed1a261..07a976b88 100644 --- a/backend/src/common/filters/enhanced-exception.filter.ts +++ b/backend/src/common/filters/enhanced-exception.filter.ts @@ -89,9 +89,7 @@ export class EnhancedExceptionFilter implements ExceptionFilter { private classifyException(exception: unknown): ClassifiedError { // 1. Check RPC errors if (this.isRpcError(exception)) { - const isTimeout = RPC_TIMEOUT_PATTERN.test( - (exception as Error).message, - ); + const isTimeout = RPC_TIMEOUT_PATTERN.test(exception.message); return { code: isTimeout ? 'RPC_001' : 'RPC_002', originalException: exception, @@ -100,7 +98,7 @@ export class EnhancedExceptionFilter implements ExceptionFilter { // 2. Check database errors if (this.isDatabaseError(exception)) { - const dbCode = this.classifyDatabaseError(exception as Error); + const dbCode = this.classifyDatabaseError(exception); return { code: dbCode, originalException: exception, @@ -202,7 +200,14 @@ export class EnhancedExceptionFilter implements ExceptionFilter { // Connection errors if ( DB_CONNECTION_PATTERNS.some((pattern) => pattern.test(message)) || - ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', '57P01', '08001', '08006'].includes(code) + [ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ETIMEDOUT', + '57P01', + '08001', + '08006', + ].includes(code) ) { return 'DB_001'; } diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 42f2d3231..52df79cef 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -130,6 +130,11 @@ export class AllExceptionsFilter implements ExceptionFilter { const body: StandardErrorResponse = { success: false, statusCode: httpStatus, + correlationId: (request as Request & { correlationId?: string }) + .correlationId, + errorCode: isTimeout ? 'SOROBAN_RPC_TIMEOUT' : 'SOROBAN_RPC_EXHAUSTED', + timestamp: new Date().toISOString(), + path: request.url, errorCode, message: isTimeout ? 'Soroban RPC request timed out. The network may be under load.' @@ -151,6 +156,12 @@ export class AllExceptionsFilter implements ExceptionFilter { if (isDatabaseConnectionError(exception)) { const body: StandardErrorResponse = { success: false, + statusCode, + correlationId: (request as Request & { correlationId?: string }) + .correlationId, + errorCode: 'DB_CONNECTION_ERROR', + timestamp: new Date().toISOString(), + path: request.url, statusCode: HttpStatus.SERVICE_UNAVAILABLE, errorCode: ErrorCode.DB_CONNECTION_ERROR, message: @@ -215,6 +226,11 @@ export class AllExceptionsFilter implements ExceptionFilter { const body: StandardErrorResponse = { success: false, statusCode: status, + correlationId: + (request as Request & { correlationId?: string }).correlationId ?? + request.headers['x-correlation-id'] ?? + undefined, + timestamp: new Date().toISOString(), errorCode, message, details, diff --git a/backend/src/common/filters/validation-exception.filter.ts b/backend/src/common/filters/validation-exception.filter.ts index 766c136df..ea7e1ef7c 100644 --- a/backend/src/common/filters/validation-exception.filter.ts +++ b/backend/src/common/filters/validation-exception.filter.ts @@ -100,7 +100,10 @@ export class ValidationExceptionFilter implements ExceptionFilter { } if (error.children && error.children.length > 0) { - const childErrors = this.formatClassValidatorErrors(error.children, field); + const childErrors = this.formatClassValidatorErrors( + error.children, + field, + ); result.push(...childErrors); } } diff --git a/backend/src/common/guards/tiered-throttler.guard.spec.ts b/backend/src/common/guards/tiered-throttler.guard.spec.ts new file mode 100644 index 000000000..c9ae4d278 --- /dev/null +++ b/backend/src/common/guards/tiered-throttler.guard.spec.ts @@ -0,0 +1,122 @@ +import { TieredThrottlerGuard, UserTier } from './tiered-throttler.guard'; + +describe('TieredThrottlerGuard', () => { + describe('resolveUserTier', () => { + it('should return FREE for undefined user', () => { + expect(TieredThrottlerGuard.resolveUserTier(undefined)).toBe( + UserTier.FREE, + ); + }); + + it('should return FREE for user with no role or KYC', () => { + expect(TieredThrottlerGuard.resolveUserTier({})).toBe(UserTier.FREE); + }); + + it('should return ADMIN for admin role', () => { + expect(TieredThrottlerGuard.resolveUserTier({ role: 'ADMIN' })).toBe( + UserTier.ADMIN, + ); + }); + + it('should return VERIFIED for KYC-approved user', () => { + expect( + TieredThrottlerGuard.resolveUserTier({ + role: 'USER', + kycStatus: 'APPROVED', + }), + ).toBe(UserTier.VERIFIED); + }); + + it('should return ENTERPRISE for enterprise tier', () => { + expect(TieredThrottlerGuard.resolveUserTier({ tier: 'enterprise' })).toBe( + UserTier.ENTERPRISE, + ); + }); + + it('should return PREMIUM for premium tier', () => { + expect(TieredThrottlerGuard.resolveUserTier({ tier: 'premium' })).toBe( + UserTier.PREMIUM, + ); + }); + + it('should prioritize enterprise tier over admin role', () => { + expect( + TieredThrottlerGuard.resolveUserTier({ + role: 'ADMIN', + tier: 'enterprise', + }), + ).toBe(UserTier.ENTERPRISE); + }); + }); + + describe('getLimitsForTier', () => { + it('should return default limits for FREE tier', () => { + const limits = TieredThrottlerGuard.getLimitsForTier( + UserTier.FREE, + 'default', + ); + expect(limits.limit).toBe(60); + expect(limits.ttl).toBe(60000); + }); + + it('should return auth limits for FREE tier', () => { + const limits = TieredThrottlerGuard.getLimitsForTier( + UserTier.FREE, + 'auth', + ); + expect(limits.limit).toBe(5); + }); + + it('should return higher limits for VERIFIED tier', () => { + const freeLimits = TieredThrottlerGuard.getLimitsForTier( + UserTier.FREE, + 'default', + ); + const verifiedLimits = TieredThrottlerGuard.getLimitsForTier( + UserTier.VERIFIED, + 'default', + ); + expect(verifiedLimits.limit).toBeGreaterThan(freeLimits.limit); + }); + + it('should return highest limits for ADMIN tier', () => { + const adminLimits = TieredThrottlerGuard.getLimitsForTier( + UserTier.ADMIN, + 'default', + ); + expect(adminLimits.limit).toBe(1000); + }); + + it('should fallback to default for unknown throttler name', () => { + const limits = TieredThrottlerGuard.getLimitsForTier( + UserTier.FREE, + 'nonexistent', + ); + expect(limits.limit).toBe(60); + }); + + it('should have progressively higher limits across tiers', () => { + const tiers = [ + UserTier.FREE, + UserTier.VERIFIED, + UserTier.PREMIUM, + UserTier.ENTERPRISE, + ]; + const limits = tiers.map((tier) => + TieredThrottlerGuard.getLimitsForTier(tier, 'default'), + ); + + for (let i = 1; i < limits.length; i++) { + expect(limits[i].limit).toBeGreaterThan(limits[i - 1].limit); + } + }); + + it('should have RPC limits for all tiers', () => { + for (const tier of Object.values(UserTier)) { + const limits = TieredThrottlerGuard.getLimitsForTier(tier, 'rpc'); + expect(limits.limit).toBeGreaterThan(0); + expect(limits.ttl).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/backend/src/common/interceptors/correlation-id.interceptor.ts b/backend/src/common/interceptors/correlation-id.interceptor.ts index 626b0d5ba..9bca2cb11 100644 --- a/backend/src/common/interceptors/correlation-id.interceptor.ts +++ b/backend/src/common/interceptors/correlation-id.interceptor.ts @@ -21,15 +21,14 @@ import { Request, Response } from 'express'; @Injectable() export class CorrelationIdInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest< - Request & { correlationId?: string } - >(); + const request = context + .switchToHttp() + .getRequest(); const response = context.switchToHttp().getResponse(); // Ensure ID exists (middleware should have set this, but guard against it) if (!request.correlationId) { - const id = - (request.headers['x-correlation-id'] as string) || uuidv4(); + const id = (request.headers['x-correlation-id'] as string) || uuidv4(); request.correlationId = id; response.setHeader('x-correlation-id', id); } diff --git a/backend/src/common/interceptors/idempotency.interceptor.ts b/backend/src/common/interceptors/idempotency.interceptor.ts index cb61262dc..3e8667ee1 100644 --- a/backend/src/common/interceptors/idempotency.interceptor.ts +++ b/backend/src/common/interceptors/idempotency.interceptor.ts @@ -143,4 +143,4 @@ export class IdempotencyInterceptor implements NestInterceptor { // Lock cleanup is best-effort } } -} +} \ No newline at end of file diff --git a/backend/src/common/interceptors/request-logging.interceptor.ts b/backend/src/common/interceptors/request-logging.interceptor.ts index 9df8d317e..1c7a7957d 100644 --- a/backend/src/common/interceptors/request-logging.interceptor.ts +++ b/backend/src/common/interceptors/request-logging.interceptor.ts @@ -106,9 +106,7 @@ export class RequestLoggingInterceptor implements NestInterceptor { const address = reqWithUser.user?.address; // Log incoming request (headers sanitized but not logged to avoid noise) - void this.sanitizer?.sanitizeHeaders( - request.headers as Record, - ); + void this.sanitizer?.sanitizeHeaders(request.headers); // Log incoming request this.pinoLogger.log({ @@ -136,7 +134,12 @@ export class RequestLoggingInterceptor implements NestInterceptor { const statusCode = response.statusCode; // APM: track request metrics - this.apmService?.trackHttpRequest(method, rawPath, statusCode, duration); + this.apmService?.trackHttpRequest( + method, + rawPath, + statusCode, + duration, + ); const logPayload = { msg: `← ${method} ${url} ${statusCode} (${duration}ms)`, @@ -162,7 +165,12 @@ export class RequestLoggingInterceptor implements NestInterceptor { const isClientError = statusCode < 500; // APM: track errors - this.apmService?.trackHttpRequest(method, rawPath, statusCode, duration); + this.apmService?.trackHttpRequest( + method, + rawPath, + statusCode, + duration, + ); if (!isClientError) { this.apmService?.trackError(error, { route: rawPath, diff --git a/backend/src/common/middleware/compression.middleware.ts b/backend/src/common/middleware/compression.middleware.ts index 849cd6c64..1070e26e0 100644 --- a/backend/src/common/middleware/compression.middleware.ts +++ b/backend/src/common/middleware/compression.middleware.ts @@ -4,7 +4,9 @@ import { CompressionMetricsService } from '../services/compression-metrics.servi @Injectable() export class CompressionMetricsMiddleware implements NestMiddleware { - constructor(private readonly compressionMetricsService: CompressionMetricsService) {} + constructor( + private readonly compressionMetricsService: CompressionMetricsService, + ) {} use(req: Request, res: Response, next: NextFunction) { let bytesWritten = 0; @@ -12,11 +14,15 @@ export class CompressionMetricsMiddleware implements NestMiddleware { const originalWrite = res.write.bind(res); const originalEnd = res.end.bind(res); - res.write = ((chunk: any, encoding?: BufferEncoding, cb?: (error?: Error) => void) => { + res.write = (( + chunk: any, + encoding?: BufferEncoding, + cb?: (error?: Error) => void, + ) => { if (chunk) { bytesWritten += Buffer.isBuffer(chunk) ? chunk.length - : Buffer.byteLength(chunk, encoding as BufferEncoding); + : Buffer.byteLength(chunk, encoding); } return originalWrite(chunk, encoding, cb); }) as typeof res.write; @@ -25,7 +31,7 @@ export class CompressionMetricsMiddleware implements NestMiddleware { if (chunk) { bytesWritten += Buffer.isBuffer(chunk) ? chunk.length - : Buffer.byteLength(chunk, encoding as BufferEncoding); + : Buffer.byteLength(chunk, encoding); } return originalEnd(chunk, encoding, cb); }) as typeof res.end; diff --git a/backend/src/common/middleware/validation.middleware.ts b/backend/src/common/middleware/validation.middleware.ts index 5fce9067b..62a253bdc 100644 --- a/backend/src/common/middleware/validation.middleware.ts +++ b/backend/src/common/middleware/validation.middleware.ts @@ -99,7 +99,9 @@ export class RequestValidationMiddleware implements NestMiddleware { // Reject requests with null bytes in any header for (const [name, value] of Object.entries(req.headers)) { - const headerStr = Array.isArray(value) ? value.join(',') : (value as string); + const headerStr = Array.isArray(value) + ? value.join(',') + : (value as string); if (headerStr && headerStr.includes('\0')) { throw new BadRequestException(`Invalid character in header '${name}'`); } diff --git a/backend/src/common/services/compression-metrics.service.ts b/backend/src/common/services/compression-metrics.service.ts index dff5d0139..79a8b9727 100644 --- a/backend/src/common/services/compression-metrics.service.ts +++ b/backend/src/common/services/compression-metrics.service.ts @@ -23,8 +23,9 @@ export class CompressionMetricsService { this.totalResponses += 1; this.totalBytesSent += bytesWritten; - const contentEncoding = - String(response.getHeader('content-encoding') || '').toLowerCase(); + const contentEncoding = String( + response.getHeader('content-encoding') || '', + ).toLowerCase(); if (contentEncoding.includes('gzip') || contentEncoding.includes('br')) { this.compressedResponses += 1; this.bytesSentWithCompression += bytesWritten; diff --git a/backend/src/common/services/graceful-shutdown.service.ts b/backend/src/common/services/graceful-shutdown.service.ts index 809b263df..bd1e30b85 100644 --- a/backend/src/common/services/graceful-shutdown.service.ts +++ b/backend/src/common/services/graceful-shutdown.service.ts @@ -90,7 +90,7 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { return; } - this.registeredHttpServer = server as ShutdownManagedServer; + this.registeredHttpServer = server; this.registeredHttpServer.on('connection', (socket: Socket) => { this.activeSockets.add(socket); socket.once('close', () => { @@ -197,7 +197,9 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { return 0; } catch (error) { const message = - error instanceof Error ? error.stack ?? error.message : String(error); + error instanceof Error + ? (error.stack ?? error.message) + : String(error); this.logger.error(`Graceful shutdown failed: ${message}`); await flushLogs?.(); return 1; @@ -284,7 +286,9 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { }); server.closeIdleConnections?.(); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') { + if ( + (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' + ) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to stop HTTP server: ${message}`); diff --git a/backend/src/common/services/idempotency-cleanup.service.ts b/backend/src/common/services/idempotency-cleanup.service.ts index 53bce5c87..a9afb0e57 100644 --- a/backend/src/common/services/idempotency-cleanup.service.ts +++ b/backend/src/common/services/idempotency-cleanup.service.ts @@ -15,6 +15,8 @@ export class IdempotencyCleanupService { this.logger.log('Idempotency cleanup job running...'); // Note: Since we use Redis with TTL, expired keys are handled automatically by Redis. // This job serves as a hook for any additional cleanup or monitoring. - this.logger.log('Idempotency cleanup completed (Redis TTL handles key expiration).'); + this.logger.log( + 'Idempotency cleanup completed (Redis TTL handles key expiration).', + ); } } diff --git a/backend/src/common/services/idempotency.service.ts b/backend/src/common/services/idempotency.service.ts index 6c53ff59d..f28e018c5 100644 --- a/backend/src/common/services/idempotency.service.ts +++ b/backend/src/common/services/idempotency.service.ts @@ -9,12 +9,16 @@ export class IdempotencyService { constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} - async getResponse(key: string, userId: string): Promise { + async getResponse(key: string, userId: string): Promise { const fullKey = this.getFullKey(key, userId); return await this.cacheManager.get(fullKey); } - async saveResponse(key: string, userId: string, response: any): Promise { + async saveResponse( + key: string, + userId: string, + response: any, + ): Promise { const fullKey = this.getFullKey(key, userId); await this.cacheManager.set(fullKey, response, this.TTL); } diff --git a/backend/src/common/services/log-sanitizer.service.ts b/backend/src/common/services/log-sanitizer.service.ts index 1e74bd559..645e134b1 100644 --- a/backend/src/common/services/log-sanitizer.service.ts +++ b/backend/src/common/services/log-sanitizer.service.ts @@ -68,7 +68,9 @@ export class LogSanitizerService { if (Array.isArray(body)) return body.map((item) => this.sanitizeBody(item)); const sanitized: Record = {}; - for (const [key, value] of Object.entries(body as Record)) { + for (const [key, value] of Object.entries( + body as Record, + )) { const lowerKey = key.toLowerCase(); const isSensitive = [...LogSanitizerService.SENSITIVE_BODY_KEYS].some( (k) => lowerKey.includes(k.toLowerCase()), diff --git a/backend/src/common/services/rate-limit-monitor.service.spec.ts b/backend/src/common/services/rate-limit-monitor.service.spec.ts new file mode 100644 index 000000000..5c70aa773 --- /dev/null +++ b/backend/src/common/services/rate-limit-monitor.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + RateLimitMonitorService, + RateLimitViolation, +} from './rate-limit-monitor.service'; + +describe('RateLimitMonitorService', () => { + let service: RateLimitMonitorService; + let eventEmitter: EventEmitter2; + + const createViolation = ( + overrides?: Partial, + ): RateLimitViolation => ({ + userId: 'user-1', + ip: '127.0.0.1', + tier: 'free', + route: '/api/test', + method: 'GET', + throttlerName: 'default', + limit: 60, + ttl: 60000, + timestamp: new Date(), + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitMonitorService, + { + provide: EventEmitter2, + useValue: { emit: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(RateLimitMonitorService); + eventEmitter = module.get(EventEmitter2); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('recordViolation', () => { + it('should record a violation', () => { + service.recordViolation(createViolation()); + expect(service.getRecentViolations()).toHaveLength(1); + }); + + it('should emit ratelimit.violation event', () => { + const violation = createViolation(); + service.recordViolation(violation); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'ratelimit.violation', + violation, + ); + }); + + it('should cap at 1000 violations', () => { + for (let i = 0; i < 1050; i++) { + service.recordViolation(createViolation({ userId: `user-${i}` })); + } + const violations = service.getRecentViolations(1500); + expect(violations.length).toBeLessThanOrEqual(1000); + }); + }); + + describe('getRecentViolations', () => { + it('should return violations in reverse chronological order', () => { + service.recordViolation(createViolation({ userId: 'first' })); + service.recordViolation(createViolation({ userId: 'second' })); + + const recent = service.getRecentViolations(2); + expect(recent[0].userId).toBe('second'); + expect(recent[1].userId).toBe('first'); + }); + + it('should limit results', () => { + for (let i = 0; i < 10; i++) { + service.recordViolation(createViolation()); + } + expect(service.getRecentViolations(5)).toHaveLength(5); + }); + }); + + describe('getViolationsByUser', () => { + it('should filter by userId', () => { + service.recordViolation(createViolation({ userId: 'user-1' })); + service.recordViolation(createViolation({ userId: 'user-2' })); + service.recordViolation(createViolation({ userId: 'user-1' })); + + const violations = service.getViolationsByUser('user-1'); + expect(violations).toHaveLength(2); + expect(violations.every((v) => v.userId === 'user-1')).toBe(true); + }); + }); + + describe('getViolationSummary', () => { + it('should return summary with correct structure', () => { + service.recordViolation(createViolation()); + const summary = service.getViolationSummary(); + + expect(summary).toHaveProperty('total'); + expect(summary).toHaveProperty('last24h'); + expect(summary).toHaveProperty('topOffenders'); + expect(summary).toHaveProperty('byTier'); + expect(summary).toHaveProperty('byRoute'); + }); + + it('should count violations by tier', () => { + service.recordViolation(createViolation({ tier: 'free' })); + service.recordViolation(createViolation({ tier: 'free' })); + service.recordViolation(createViolation({ tier: 'verified' })); + + const summary = service.getViolationSummary(); + expect(summary.byTier['free']).toBe(2); + expect(summary.byTier['verified']).toBe(1); + }); + + it('should identify top offenders', () => { + for (let i = 0; i < 5; i++) { + service.recordViolation(createViolation({ userId: 'offender' })); + } + service.recordViolation(createViolation({ userId: 'one-time' })); + + const summary = service.getViolationSummary(); + expect(summary.topOffenders[0].userId).toBe('offender'); + expect(summary.topOffenders[0].count).toBe(5); + }); + }); +}); diff --git a/backend/src/common/services/secrets-manager.service.ts b/backend/src/common/services/secrets-manager.service.ts index 3ca74e868..59fb07ca2 100644 --- a/backend/src/common/services/secrets-manager.service.ts +++ b/backend/src/common/services/secrets-manager.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; interface SecretMetadata { @@ -68,7 +73,8 @@ export class SecretsManagerService implements OnModuleInit, OnModuleDestroy { this.secretsMetadata.set(key, { key, createdAt: Date.now(), - expiresAt: expiresAt || Date.now() + this.SECRET_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + expiresAt: + expiresAt || Date.now() + this.SECRET_EXPIRY_DAYS * 24 * 60 * 60 * 1000, rotatedAt: Date.now(), }); this.logger.log(`Secret ${key} updated`); @@ -94,11 +100,16 @@ export class SecretsManagerService implements OnModuleInit, OnModuleDestroy { } private startExpirationMonitor() { - this.expirationMonitor = setInterval(() => { - const expiring = this.getExpiringSecrets(30); - if (expiring.length > 0) { - this.logger.warn(`Found ${expiring.length} expiring secrets: ${expiring.map(s => s.key).join(', ')}`); - } - }, 24 * 60 * 60 * 1000); + this.expirationMonitor = setInterval( + () => { + const expiring = this.getExpiringSecrets(30); + if (expiring.length > 0) { + this.logger.warn( + `Found ${expiring.length} expiring secrets: ${expiring.map((s) => s.key).join(', ')}`, + ); + } + }, + 24 * 60 * 60 * 1000, + ); } } diff --git a/backend/src/common/validators/is-iso-date.validator.ts b/backend/src/common/validators/is-iso-date.validator.ts index 93b6df6b1..f343b267c 100644 --- a/backend/src/common/validators/is-iso-date.validator.ts +++ b/backend/src/common/validators/is-iso-date.validator.ts @@ -15,8 +15,11 @@ export function IsISODate(validationOptions?: ValidationOptions) { validate(value: unknown): boolean { if (typeof value !== 'string') return false; const date = new Date(value); - return !isNaN(date.getTime()) && value === date.toISOString().split('T')[0] || - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value); + return ( + (!isNaN(date.getTime()) && + value === date.toISOString().split('T')[0]) || + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value) + ); }, defaultMessage(args: ValidationArguments): string { return `${args.property} must be a valid ISO 8601 date string (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ)`; @@ -39,7 +42,9 @@ export function IsDateRange( constraints: [startField], validator: { validate(value: unknown, args: ValidationArguments): boolean { - const startValue = (args.object as Record)[args.constraints[0]]; + const startValue = (args.object as Record)[ + args.constraints[0] + ]; if (!value || !startValue) return true; return new Date(value as string) >= new Date(startValue as string); }, diff --git a/backend/src/common/validators/is-positive-amount.validator.spec.ts b/backend/src/common/validators/is-positive-amount.validator.spec.ts new file mode 100644 index 000000000..c9684ec9e --- /dev/null +++ b/backend/src/common/validators/is-positive-amount.validator.spec.ts @@ -0,0 +1,73 @@ +import { validate } from 'class-validator'; +import { IsPositiveAmount } from './is-positive-amount.validator'; + +class TestDto { + @IsPositiveAmount(7) + amount: any; +} + +function createDto(amount: any): TestDto { + const dto = new TestDto(); + dto.amount = amount; + return dto; +} + +describe('IsPositiveAmount', () => { + it('should accept valid positive amounts', async () => { + const validAmounts = [0.01, 1, 100, 50000.1234567, 999999999]; + for (const amount of validAmounts) { + const errors = await validate(createDto(amount)); + expect(errors).toHaveLength(0); + } + }); + + it('should reject zero', async () => { + const errors = await validate(createDto(0)); + expect(errors).toHaveLength(1); + }); + + it('should reject negative numbers', async () => { + const errors = await validate(createDto(-1)); + expect(errors).toHaveLength(1); + }); + + it('should reject NaN', async () => { + const errors = await validate(createDto(NaN)); + expect(errors).toHaveLength(1); + }); + + it('should reject Infinity', async () => { + const errors = await validate(createDto(Infinity)); + expect(errors).toHaveLength(1); + }); + + it('should reject strings', async () => { + const errors = await validate(createDto('100')); + expect(errors).toHaveLength(1); + }); + + it('should reject amounts with too many decimals', async () => { + const errors = await validate(createDto(1.12345678)); + expect(errors).toHaveLength(1); + }); + + it('should accept amounts at the decimal limit', async () => { + const errors = await validate(createDto(1.1234567)); + expect(errors).toHaveLength(0); + }); + + it('should work with custom decimal places', async () => { + class TwoDecimalDto { + @IsPositiveAmount(2) + amount: any; + } + const dto = new TwoDecimalDto(); + dto.amount = 1.123; + const errors = await validate(dto); + expect(errors).toHaveLength(1); + + dto.amount = 1.12; + const errors2 = await validate(dto); + expect(errors2).toHaveLength(0); + }); +}); diff --git a/backend/src/common/validators/is-positive-amount.validator.ts b/backend/src/common/validators/is-positive-amount.validator.ts index 612dda722..45a31d0c6 100644 --- a/backend/src/common/validators/is-positive-amount.validator.ts +++ b/backend/src/common/validators/is-positive-amount.validator.ts @@ -4,20 +4,33 @@ import { ValidationArguments, } from 'class-validator'; -export function IsPositiveAmount(validationOptions?: ValidationOptions) { +const MAX_DECIMAL_PLACES = 7; // Stellar uses 7 decimal places (stroops) + +export function IsPositiveAmount( + maxDecimals = MAX_DECIMAL_PLACES, + validationOptions?: ValidationOptions, +) { return function (object: object, propertyName: string) { registerDecorator({ name: 'isPositiveAmount', target: object.constructor, - propertyName, + propertyName: propertyName, options: validationOptions, + constraints: [maxDecimals], validator: { - validate(value: unknown): boolean { - const num = typeof value === 'string' ? parseFloat(value) : Number(value); - return isFinite(num) && num > 0; + validate(value: any, args: ValidationArguments) { + if (typeof value !== 'number' || !isFinite(value)) return false; + if (value <= 0) return false; + + const decimals = args.constraints[0] as number; + const parts = value.toString().split('.'); + if (parts.length > 1 && parts[1].length > decimals) return false; + + return true; }, - defaultMessage(args: ValidationArguments): string { - return `${args.property} must be a positive number`; + defaultMessage(args: ValidationArguments) { + const decimals = args.constraints[0] as number; + return `${args.property} must be a positive number with at most ${decimals} decimal places`; }, }, }); @@ -33,9 +46,9 @@ export function IsUSDCAmount(validationOptions?: ValidationOptions) { options: validationOptions, validator: { validate(value: unknown): boolean { - const num = typeof value === 'string' ? parseFloat(value) : Number(value); + const num = + typeof value === 'string' ? parseFloat(value) : Number(value); if (!isFinite(num) || num <= 0) return false; - // USDC uses 7 decimal places on Stellar const strVal = String(value); const decimalPart = strVal.split('.')[1] || ''; return decimalPart.length <= 7; @@ -57,7 +70,8 @@ export function IsNonNegativeAmount(validationOptions?: ValidationOptions) { options: validationOptions, validator: { validate(value: unknown): boolean { - const num = typeof value === 'string' ? parseFloat(value) : Number(value); + const num = + typeof value === 'string' ? parseFloat(value) : Number(value); return isFinite(num) && num >= 0; }, defaultMessage(args: ValidationArguments): string { diff --git a/backend/src/common/validators/is-stellar-secret.validator.spec.ts b/backend/src/common/validators/is-stellar-secret.validator.spec.ts new file mode 100644 index 000000000..240498467 --- /dev/null +++ b/backend/src/common/validators/is-stellar-secret.validator.spec.ts @@ -0,0 +1,56 @@ +import { validate } from 'class-validator'; +import { IsStellarSecretKey } from './is-stellar-secret.validator'; + +class TestDto { + @IsStellarSecretKey() + secret: any; +} + +function createDto(secret: any): TestDto { + const dto = new TestDto(); + dto.secret = secret; + return dto; +} + +describe('IsStellarSecretKey', () => { + it('should accept valid Stellar secret key', async () => { + const validKey = 'S' + 'A'.repeat(55); + const errors = await validate(createDto(validKey)); + expect(errors).toHaveLength(0); + }); + + it('should accept key with valid Base32 characters', async () => { + const validKey = 'SABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + const errors = await validate(createDto(validKey)); + expect(errors).toHaveLength(0); + }); + + it('should reject key not starting with S', async () => { + const invalidKey = 'G' + 'A'.repeat(55); + const errors = await validate(createDto(invalidKey)); + expect(errors).toHaveLength(1); + }); + + it('should reject key with wrong length', async () => { + const shortKey = 'S' + 'A'.repeat(50); + const errors = await validate(createDto(shortKey)); + expect(errors).toHaveLength(1); + }); + + it('should reject key with invalid Base32 characters', async () => { + const invalidKey = 'S' + '1'.repeat(55); + const errors = await validate(createDto(invalidKey)); + expect(errors).toHaveLength(1); + }); + + it('should reject non-string values', async () => { + const errors = await validate(createDto(12345)); + expect(errors).toHaveLength(1); + }); + + it('should reject lowercase keys', async () => { + const invalidKey = 's' + 'a'.repeat(55); + const errors = await validate(createDto(invalidKey)); + expect(errors).toHaveLength(1); + }); +}); diff --git a/backend/src/common/validators/is-stellar-secret.validator.ts b/backend/src/common/validators/is-stellar-secret.validator.ts new file mode 100644 index 000000000..b1177487a --- /dev/null +++ b/backend/src/common/validators/is-stellar-secret.validator.ts @@ -0,0 +1,25 @@ +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; + +export function IsStellarSecretKey(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStellarSecretKey', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + if (typeof value !== 'string') return false; + return /^S[A-Z2-7]{55}$/.test(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid Stellar secret key (starts with S, exactly 56 characters, Base32 encoded)`; + }, + }, + }); + }; +} diff --git a/backend/src/common/validators/is-transaction-hash.validator.spec.ts b/backend/src/common/validators/is-transaction-hash.validator.spec.ts new file mode 100644 index 000000000..2208d4720 --- /dev/null +++ b/backend/src/common/validators/is-transaction-hash.validator.spec.ts @@ -0,0 +1,59 @@ +import { validate } from 'class-validator'; +import { IsTransactionHash } from './is-transaction-hash.validator'; + +class TestDto { + @IsTransactionHash() + hash: any; +} + +function createDto(hash: any): TestDto { + const dto = new TestDto(); + dto.hash = hash; + return dto; +} + +describe('IsTransactionHash', () => { + it('should accept valid 64-char lowercase hex hash', async () => { + const validHash = 'a'.repeat(64); + const errors = await validate(createDto(validHash)); + expect(errors).toHaveLength(0); + }); + + it('should accept mixed hex characters', async () => { + const validHash = 'abcdef0123456789'.repeat(4); + const errors = await validate(createDto(validHash)); + expect(errors).toHaveLength(0); + }); + + it('should reject uppercase hex', async () => { + const invalidHash = 'A'.repeat(64); + const errors = await validate(createDto(invalidHash)); + expect(errors).toHaveLength(1); + }); + + it('should reject short hashes', async () => { + const errors = await validate(createDto('abcdef')); + expect(errors).toHaveLength(1); + }); + + it('should reject long hashes', async () => { + const errors = await validate(createDto('a'.repeat(65))); + expect(errors).toHaveLength(1); + }); + + it('should reject non-hex characters', async () => { + const invalidHash = 'g'.repeat(64); + const errors = await validate(createDto(invalidHash)); + expect(errors).toHaveLength(1); + }); + + it('should reject non-string values', async () => { + const errors = await validate(createDto(12345)); + expect(errors).toHaveLength(1); + }); + + it('should reject null', async () => { + const errors = await validate(createDto(null)); + expect(errors).toHaveLength(1); + }); +}); diff --git a/backend/src/common/validators/is-transaction-hash.validator.ts b/backend/src/common/validators/is-transaction-hash.validator.ts new file mode 100644 index 000000000..278c6a20b --- /dev/null +++ b/backend/src/common/validators/is-transaction-hash.validator.ts @@ -0,0 +1,25 @@ +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; + +export function IsTransactionHash(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isTransactionHash', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + if (typeof value !== 'string') return false; + return /^[a-f0-9]{64}$/.test(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid transaction hash (64 lowercase hex characters)`; + }, + }, + }); + }; +} diff --git a/backend/src/common/validators/sanitize.transform.ts b/backend/src/common/validators/sanitize.transform.ts new file mode 100644 index 000000000..b62d88c28 --- /dev/null +++ b/backend/src/common/validators/sanitize.transform.ts @@ -0,0 +1,35 @@ +import { Transform } from 'class-transformer'; + +export function Trim() { + return Transform(({ value }) => + typeof value === 'string' ? value.trim() : value, + ); +} + +const CONTROL_CHAR_PATTERN = new RegExp( + '[' + + String.fromCharCode(0) + + '-' + + String.fromCharCode(8) + + String.fromCharCode(11) + + String.fromCharCode(12) + + String.fromCharCode(14) + + '-' + + String.fromCharCode(31) + + String.fromCharCode(127) + + ']', + 'g', +); + +export function StripControlChars() { + return Transform(({ value }) => + typeof value === 'string' ? value.replace(CONTROL_CHAR_PATTERN, '') : value, + ); +} + +export function Sanitize() { + return function (target: any, propertyKey: string) { + Trim()(target, propertyKey); + StripControlChars()(target, propertyKey); + }; +} diff --git a/backend/src/common/validators/validation-standard.spec.ts b/backend/src/common/validators/validation-standard.spec.ts index 91e159e56..210f8df35 100644 --- a/backend/src/common/validators/validation-standard.spec.ts +++ b/backend/src/common/validators/validation-standard.spec.ts @@ -50,7 +50,7 @@ describe('Validation Standardization and Sanitization', () => { fail('Should have thrown BadRequestException'); } catch (error) { expect(error).toBeInstanceOf(BadRequestException); - const response = error.getResponse() as any; + const response = error.getResponse(); expect(response.message).toBe('Validation failed'); expect(response.errors).toBeDefined(); expect(response.errors).toHaveLength(1); diff --git a/backend/src/config/__tests__/cors-config.spec.ts b/backend/src/config/__tests__/cors-config.spec.ts index f85513026..630ba6ef1 100644 --- a/backend/src/config/__tests__/cors-config.spec.ts +++ b/backend/src/config/__tests__/cors-config.spec.ts @@ -24,17 +24,26 @@ describe('CORS Configuration', () => { expect(config.cors.enabled).toBe(true); expect(config.cors.origins).toEqual(['http://localhost:3000']); expect(config.cors.methods).toEqual([ - 'GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS', + 'GET', + 'HEAD', + 'PUT', + 'PATCH', + 'POST', + 'DELETE', + 'OPTIONS', ]); expect(config.cors.allowedHeaders).toEqual([ - 'Content-Type', 'Authorization', 'Accept', + 'Content-Type', + 'Authorization', + 'Accept', ]); expect(config.cors.credentials).toBe(true); expect(config.cors.maxAge).toBe(86400); }); it('should parse comma-separated CORS_ORIGINS', () => { - process.env.CORS_ORIGINS = 'https://app.nestera.io,https://admin.nestera.io'; + process.env.CORS_ORIGINS = + 'https://app.nestera.io,https://admin.nestera.io'; const config = configuration(); expect(config.cors.origins).toEqual([ @@ -58,7 +67,8 @@ describe('CORS Configuration', () => { }); it('should parse custom CORS_ALLOWED_HEADERS', () => { - process.env.CORS_ALLOWED_HEADERS = 'Content-Type,Authorization,X-Custom-Header'; + process.env.CORS_ALLOWED_HEADERS = + 'Content-Type,Authorization,X-Custom-Header'; const config = configuration(); expect(config.cors.allowedHeaders).toEqual([ @@ -76,7 +86,8 @@ describe('CORS Configuration', () => { }); it('should trim whitespace from origins', () => { - process.env.CORS_ORIGINS = ' https://app.nestera.io , https://admin.nestera.io '; + process.env.CORS_ORIGINS = + ' https://app.nestera.io , https://admin.nestera.io '; const config = configuration(); expect(config.cors.origins).toEqual([ @@ -86,7 +97,8 @@ describe('CORS Configuration', () => { }); it('should filter empty origins from comma-separated list', () => { - process.env.CORS_ORIGINS = 'https://app.nestera.io,,,https://admin.nestera.io'; + process.env.CORS_ORIGINS = + 'https://app.nestera.io,,,https://admin.nestera.io'; const config = configuration(); expect(config.cors.origins).toEqual([ diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index a2f1e8f8d..1ab110295 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -94,6 +94,13 @@ export default () => ({ redis: { url: process.env.REDIS_URL, }, + jobQueue: { + defaultAttempts: parseInt( + process.env.JOB_QUEUE_DEFAULT_ATTEMPTS || '3', + 10, + ), + backoffDelay: parseInt(process.env.JOB_QUEUE_BACKOFF_DELAY || '2000', 10), + }, mail: { host: process.env.MAIL_HOST, port: parseInt(process.env.MAIL_PORT || '587', 10), diff --git a/backend/src/migrations/1800200000000-CreateTransactionSavedSearches.ts b/backend/src/migrations/1800200000000-CreateTransactionSavedSearches.ts index 0f2ef9008..1cac6aa50 100644 --- a/backend/src/migrations/1800200000000-CreateTransactionSavedSearches.ts +++ b/backend/src/migrations/1800200000000-CreateTransactionSavedSearches.ts @@ -6,9 +6,7 @@ import { TableIndex, } from 'typeorm'; -export class CreateTransactionSavedSearches1800200000000 - implements MigrationInterface -{ +export class CreateTransactionSavedSearches1800200000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ @@ -100,7 +98,10 @@ export class CreateTransactionSavedSearches1800200000000 fk.columnNames.includes('userId'), ); if (foreignKey) { - await queryRunner.dropForeignKey('transaction_saved_searches', foreignKey); + await queryRunner.dropForeignKey( + 'transaction_saved_searches', + foreignKey, + ); } await queryRunner.dropTable('transaction_saved_searches'); } diff --git a/backend/src/migrations/1800300000000-CreateAnalyticsExportJobsTable.ts b/backend/src/migrations/1800300000000-CreateAnalyticsExportJobsTable.ts index 902d008dd..4515adaed 100644 --- a/backend/src/migrations/1800300000000-CreateAnalyticsExportJobsTable.ts +++ b/backend/src/migrations/1800300000000-CreateAnalyticsExportJobsTable.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; -export class CreateAnalyticsExportJobsTable1800300000000 - implements MigrationInterface -{ +export class CreateAnalyticsExportJobsTable1800300000000 implements MigrationInterface { name = 'CreateAnalyticsExportJobsTable1800300000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 8d05ba879..0986c3395 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -61,7 +61,10 @@ export class AdminController { }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'KYC rejected' }) - @ApiResponse({ status: 400, description: 'Missing user ID or rejection reason' }) + @ApiResponse({ + status: 400, + description: 'Missing user ID or rejection reason', + }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Admin role required' }) @ApiResponse({ status: 404, description: 'User not found' }) @@ -84,7 +87,8 @@ export class AdminController { }) @ApiOperation({ summary: 'Approve or reject KYC for a user (single endpoint)', - description: 'Set `action` to `"approve"` or `"reject"`. Reason is required for rejection.', + description: + 'Set `action` to `"approve"` or `"reject"`. Reason is required for rejection.', }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'KYC status updated' }) @@ -125,7 +129,12 @@ export class AdminController { @Get('rate-limits/violations') @ApiOperation({ summary: 'Get recent rate limit violations' }) - @ApiQuery({ name: 'limit', required: false, description: 'Max results (default 50)', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Max results (default 50)', + type: Number, + }) @ApiResponse({ status: 200, description: 'Recent rate limit violations' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Admin role required' }) @@ -138,7 +147,12 @@ export class AdminController { @Get('rate-limits/violations/:userId') @ApiOperation({ summary: 'Get rate limit violations for a specific user' }) @ApiParam({ name: 'userId', description: 'User UUID' }) - @ApiQuery({ name: 'limit', required: false, description: 'Max results (default 50)', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Max results (default 50)', + type: Number, + }) @ApiResponse({ status: 200, description: 'User rate limit violations' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Admin role required' }) diff --git a/backend/src/modules/apm/apm.controller.ts b/backend/src/modules/apm/apm.controller.ts index af914a1b8..5ff3b324c 100644 --- a/backend/src/modules/apm/apm.controller.ts +++ b/backend/src/modules/apm/apm.controller.ts @@ -30,7 +30,10 @@ export class ApmController { @Get('metrics') @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') @ApiOperation({ summary: 'Prometheus-compatible metrics endpoint' }) - @ApiResponse({ status: 200, description: 'Metrics in Prometheus text format' }) + @ApiResponse({ + status: 200, + description: 'Metrics in Prometheus text format', + }) getPrometheusMetrics(): string { return this.metricsService.getMetricsAsPrometheusText(); } @@ -39,7 +42,10 @@ export class ApmController { @UseGuards(JwtAuthGuard) @ApiBearerAuth('access-token') @ApiOperation({ summary: 'APM dashboard overview' }) - @ApiResponse({ status: 200, description: 'Dashboard data with metrics, errors, and traces' }) + @ApiResponse({ + status: 200, + description: 'Dashboard data with metrics, errors, and traces', + }) getDashboard() { return this.apmService.getDashboardData(); } @@ -48,7 +54,12 @@ export class ApmController { @UseGuards(JwtAuthGuard) @ApiBearerAuth('access-token') @ApiOperation({ summary: 'Error tracking summary' }) - @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of errors to return' }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of errors to return', + }) getErrors(@Query('limit') limit = 50) { return { errors: this.apmService.getErrorSummary().slice(0, Number(limit)), diff --git a/backend/src/modules/apm/apm.interceptor.ts b/backend/src/modules/apm/apm.interceptor.ts index 5364bacd9..810d21393 100644 --- a/backend/src/modules/apm/apm.interceptor.ts +++ b/backend/src/modules/apm/apm.interceptor.ts @@ -23,7 +23,7 @@ export class ApmInterceptor implements NestInterceptor { const startTime = Date.now(); const incomingContext = this.tracingService.parseTraceContext( - request.headers as Record, + request.headers, ); const traceCtx = this.tracingService.createTraceContext( incomingContext || undefined, @@ -37,11 +37,14 @@ export class ApmInterceptor implements NestInterceptor { 'http.url': request.url, 'http.route': request.path, 'http.user_agent': (request.headers['user-agent'] as string) || '', - 'component': 'http', + component: 'http', }, ); - response.setHeader('traceparent', this.tracingService.buildTraceparentHeader(traceCtx)); + response.setHeader( + 'traceparent', + this.tracingService.buildTraceparentHeader(traceCtx), + ); response.setHeader('X-Trace-Id', traceCtx.traceId); (request as any).traceContext = traceCtx; (request as any).apmSpan = span; @@ -55,14 +58,24 @@ export class ApmInterceptor implements NestInterceptor { this.tracingService.addSpanTag(span, 'http.status_code', statusCode); this.tracingService.finishSpan(span); - this.apmService.trackHttpRequest(request.method, route, statusCode, duration); + this.apmService.trackHttpRequest( + request.method, + route, + statusCode, + duration, + ); }), catchError((error: Error) => { const duration = Date.now() - startTime; const statusCode = (error as any).status || 500; this.tracingService.finishSpan(span, error); - this.apmService.trackHttpRequest(request.method, route, statusCode, duration); + this.apmService.trackHttpRequest( + request.method, + route, + statusCode, + duration, + ); this.apmService.trackError(error, { route, method: request.method, @@ -78,7 +91,9 @@ export class ApmInterceptor implements NestInterceptor { private getRoutePattern(request: Request): string { const route = (request as any).route; if (route?.path) { - return request.path.replace(/\/[0-9a-f-]{36}/gi, '/:id').replace(/\/\d+/g, '/:id'); + return request.path + .replace(/\/[0-9a-f-]{36}/gi, '/:id') + .replace(/\/\d+/g, '/:id'); } return request.path; } diff --git a/backend/src/modules/apm/apm.module.ts b/backend/src/modules/apm/apm.module.ts index 1622fcb19..6d2fbae2f 100644 --- a/backend/src/modules/apm/apm.module.ts +++ b/backend/src/modules/apm/apm.module.ts @@ -8,7 +8,17 @@ import { ApmInterceptor } from './apm.interceptor'; @Global() @Module({ controllers: [ApmController], - providers: [ApmService, MetricsService, DistributedTracingService, ApmInterceptor], - exports: [ApmService, MetricsService, DistributedTracingService, ApmInterceptor], + providers: [ + ApmService, + MetricsService, + DistributedTracingService, + ApmInterceptor, + ], + exports: [ + ApmService, + MetricsService, + DistributedTracingService, + ApmInterceptor, + ], }) export class ApmModule {} diff --git a/backend/src/modules/badges/badges.controller.spec.ts b/backend/src/modules/badges/badges.controller.spec.ts index 36e8e104b..9fcd386d5 100644 --- a/backend/src/modules/badges/badges.controller.spec.ts +++ b/backend/src/modules/badges/badges.controller.spec.ts @@ -102,9 +102,15 @@ describe('BadgesController', () => { describe('generateShareToken', () => { it('should generate share token for badge', async () => { - const result = await controller.generateShareToken('user-badge-1', mockRequest as any); - - expect(service.generateShareToken).toHaveBeenCalledWith('user-1', 'user-badge-1'); + const result = await controller.generateShareToken( + 'user-badge-1', + mockRequest as any, + ); + + expect(service.generateShareToken).toHaveBeenCalledWith( + 'user-1', + 'user-badge-1', + ); expect(result).toEqual({ shareToken: 'share-token-123', shareUrl: '/badges/shared/share-token-123', diff --git a/backend/src/modules/blockchain/blockchain.controller.ts b/backend/src/modules/blockchain/blockchain.controller.ts index 7cc233629..0471f2a38 100644 --- a/backend/src/modules/blockchain/blockchain.controller.ts +++ b/backend/src/modules/blockchain/blockchain.controller.ts @@ -68,8 +68,14 @@ export class BlockchainController { } @Get('indexer/status') - @ApiOperation({ summary: 'Get contract event indexer status for monitoring dashboard' }) - @ApiResponse({ status: 200, description: 'Indexer state including ledger position, event counts, and monitored contracts' }) + @ApiOperation({ + summary: 'Get contract event indexer status for monitoring dashboard', + }) + @ApiResponse({ + status: 200, + description: + 'Indexer state including ledger position, event counts, and monitored contracts', + }) getIndexerStatus() { const state = this.indexerService.getIndexerState(); return { diff --git a/backend/src/modules/cache/cache-strategy.service.ts b/backend/src/modules/cache/cache-strategy.service.ts index be9c720c1..01746bbf4 100644 --- a/backend/src/modules/cache/cache-strategy.service.ts +++ b/backend/src/modules/cache/cache-strategy.service.ts @@ -20,12 +20,12 @@ interface CacheMetrics { @Injectable() export class CacheStrategyService { private readonly logger = new Logger(CacheStrategyService.name); - private metrics: CacheMetrics = { - hits: 0, - misses: 0, - sets: 0, - deletes: 0, - keyMetrics: new Map() + private metrics: CacheMetrics = { + hits: 0, + misses: 0, + sets: 0, + deletes: 0, + keyMetrics: new Map(), }; private resourceTTLs = new Map([ ['user', 5 * 60 * 1000], // 5 minutes @@ -55,13 +55,18 @@ export class CacheStrategyService { } } - async set(key: string, value: T, ttl?: number, tags?: string[]): Promise { + async set( + key: string, + value: T, + ttl?: number, + tags?: string[], + ): Promise { try { const finalTTL = ttl || this.getDefaultTTL(key); await this.cacheManager.set(key, value, finalTTL); this.metrics.sets++; this.updateKeyMetrics(key, 'sets'); - + if (tags) { for (const tag of tags) { if (!this.tagKeys.has(tag)) { @@ -70,7 +75,7 @@ export class CacheStrategyService { this.tagKeys.get(tag)!.add(key); } } - + this.logger.debug(`Cache set: ${key} (TTL: ${finalTTL}ms)`); } catch (error) { this.logger.error(`Cache set error for key ${key}:`, error); @@ -81,12 +86,12 @@ export class CacheStrategyService { try { await this.cacheManager.del(key); this.metrics.deletes++; - + // Remove key from all tag sets for (const [, keys] of this.tagKeys) { keys.delete(key); } - + this.logger.debug(`Cache deleted: ${key}`); } catch (error) { this.logger.error(`Cache delete error for key ${key}:`, error); @@ -96,13 +101,13 @@ export class CacheStrategyService { async invalidateByTag(tag: string): Promise { try { const keysToDelete = this.tagKeys.get(tag) || new Set(); - + for (const key of keysToDelete) { await this.del(key); } - + this.tagKeys.delete(tag); - + this.logger.debug( `Invalidated ${keysToDelete.size} keys with tag: ${tag}`, ); @@ -115,20 +120,27 @@ export class CacheStrategyService { try { // This is a fallback for implementations that don't support pattern matching // For Redis, we'd use KEYS or SCAN - const allKeys = Array.from(this.tagKeys.values()).flatMap(set => Array.from(set)); + const allKeys = Array.from(this.tagKeys.values()).flatMap((set) => + Array.from(set), + ); const uniqueKeys = new Set(allKeys); - - const keysToDelete = Array.from(uniqueKeys).filter(k => k.includes(pattern)); - + + const keysToDelete = Array.from(uniqueKeys).filter((k) => + k.includes(pattern), + ); + for (const key of keysToDelete) { await this.del(key); } - + this.logger.debug( `Invalidated ${keysToDelete.length} keys with pattern: ${pattern}`, ); } catch (error) { - this.logger.error(`Cache invalidation error for pattern ${pattern}:`, error); + this.logger.error( + `Cache invalidation error for pattern ${pattern}:`, + error, + ); } } @@ -178,23 +190,33 @@ export class CacheStrategyService { getMetrics() { const total = this.metrics.hits + this.metrics.misses; - const keyMetricsArray = Array.from(this.metrics.keyMetrics.entries()).map(([key, km]) => ({ - key, - ...km, - hitRate: (km.hits + km.misses) > 0 - ? ((km.hits / (km.hits + km.misses)) * 100).toFixed(2) + '%' - : '0%', - })); - + const keyMetricsArray = Array.from(this.metrics.keyMetrics.entries()).map( + ([key, km]) => ({ + key, + ...km, + hitRate: + km.hits + km.misses > 0 + ? ((km.hits / (km.hits + km.misses)) * 100).toFixed(2) + '%' + : '0%', + }), + ); + return { ...this.metrics, keyMetrics: keyMetricsArray, - hitRate: total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) + '%' : '0%', + hitRate: + total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) + '%' : '0%', }; } resetMetrics() { - this.metrics = { hits: 0, misses: 0, sets: 0, deletes: 0, keyMetrics: new Map() }; + this.metrics = { + hits: 0, + misses: 0, + sets: 0, + deletes: 0, + keyMetrics: new Map(), + }; } setResourceTTL(resource: string, ttl: number): void { diff --git a/backend/src/modules/cache/cache-warming.service.ts b/backend/src/modules/cache/cache-warming.service.ts index 8896e7f4b..0d1c1e9c8 100644 --- a/backend/src/modules/cache/cache-warming.service.ts +++ b/backend/src/modules/cache/cache-warming.service.ts @@ -80,9 +80,14 @@ export class CacheWarmingService { getWarmingMetrics() { return { ...this.warmingMetrics, - successRate: this.warmingMetrics.totalWarmed > 0 - ? ((this.warmingMetrics.successCount / this.warmingMetrics.totalWarmed) * 100).toFixed(2) + '%' - : '0%', + successRate: + this.warmingMetrics.totalWarmed > 0 + ? ( + (this.warmingMetrics.successCount / + this.warmingMetrics.totalWarmed) * + 100 + ).toFixed(2) + '%' + : '0%', }; } diff --git a/backend/src/modules/cache/cache.controller.ts b/backend/src/modules/cache/cache.controller.ts index e413916fb..178ef4567 100644 --- a/backend/src/modules/cache/cache.controller.ts +++ b/backend/src/modules/cache/cache.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Post, Delete, UseGuards, Param } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Delete, + UseGuards, + Param, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { CacheStrategyService } from './cache-strategy.service'; import { CacheWarmingService } from './cache-warming.service'; @@ -54,7 +61,9 @@ export class CacheController { } @Delete('invalidate/pattern/:pattern') - @ApiOperation({ summary: 'Invalidate all cache entries matching the given pattern' }) + @ApiOperation({ + summary: 'Invalidate all cache entries matching the given pattern', + }) async invalidateByPattern(@Param('pattern') pattern: string) { await this.cacheStrategy.invalidateByPattern(pattern); return { message: `Invalidated all keys matching pattern: ${pattern}` }; diff --git a/backend/src/modules/data-export/data-export.controller.ts b/backend/src/modules/data-export/data-export.controller.ts index 80fdfb70d..fc91555c5 100644 --- a/backend/src/modules/data-export/data-export.controller.ts +++ b/backend/src/modules/data-export/data-export.controller.ts @@ -67,7 +67,11 @@ export class DataExportController { @Get('export/transactions') @ApiOperation({ summary: 'Export transaction history as JSON' }) - @ApiQuery({ name: 'from', required: false, description: 'ISO date YYYY-MM-DD' }) + @ApiQuery({ + name: 'from', + required: false, + description: 'ISO date YYYY-MM-DD', + }) @ApiQuery({ name: 'to', required: false, description: 'ISO date YYYY-MM-DD' }) exportTransactions( @CurrentUser() user: { id: string }, @@ -104,7 +108,9 @@ export class DataExportController { // ─── Export history ─────────────────────────────────────────────────────── @Get('export/history') - @ApiOperation({ summary: 'List all past export requests for the current user' }) + @ApiOperation({ + summary: 'List all past export requests for the current user', + }) exportHistory(@CurrentUser() user: { id: string }) { return this.dataExportService.getExportHistory(user.id); } diff --git a/backend/src/modules/disputes/disputes.controller.ts b/backend/src/modules/disputes/disputes.controller.ts index 1121392c1..68c060006 100644 --- a/backend/src/modules/disputes/disputes.controller.ts +++ b/backend/src/modules/disputes/disputes.controller.ts @@ -82,7 +82,11 @@ export class DisputesController { @Patch(':id/investigate') @ApiOperation({ summary: 'Start investigation' }) - @ApiResponse({ status: 200, description: 'Investigation started', type: Dispute }) + @ApiResponse({ + status: 200, + description: 'Investigation started', + type: Dispute, + }) @ApiResponse({ status: 404, description: 'Dispute not found' }) async startInvestigation( @Param('id') id: string, diff --git a/backend/src/modules/email-templates/email-template.entity.ts b/backend/src/modules/email-templates/email-template.entity.ts index f65571082..eb9f84afe 100644 --- a/backend/src/modules/email-templates/email-template.entity.ts +++ b/backend/src/modules/email-templates/email-template.entity.ts @@ -1,4 +1,13 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; @Entity('email_templates') export class EmailTemplate { diff --git a/backend/src/modules/email-templates/email-templates.controller.ts b/backend/src/modules/email-templates/email-templates.controller.ts index 86be2aa38..c8a8da47d 100644 --- a/backend/src/modules/email-templates/email-templates.controller.ts +++ b/backend/src/modules/email-templates/email-templates.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Post, Body, Get, Param, Put, Delete, Query } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Get, + Param, + Put, + Delete, + Query, +} from '@nestjs/common'; import { EmailTemplatesService } from './email-templates.service'; @Controller('email-templates') @@ -46,7 +55,11 @@ export class EmailTemplatesController { } @Post('ab-tests/:id/preview') - previewAb(@Param('id') id: string, @Body() body: any, @Query('seed') seed?: string) { + previewAb( + @Param('id') id: string, + @Body() body: any, + @Query('seed') seed?: string, + ) { const s = seed ? Number(seed) : undefined; return this.svc.previewAbTest(id, body.variables ?? {}, s); } diff --git a/backend/src/modules/email-templates/email-templates.module.ts b/backend/src/modules/email-templates/email-templates.module.ts index 0bf1626f8..e12392ac3 100644 --- a/backend/src/modules/email-templates/email-templates.module.ts +++ b/backend/src/modules/email-templates/email-templates.module.ts @@ -2,10 +2,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EmailTemplatesController } from './email-templates.controller'; import { EmailTemplatesService } from './email-templates.service'; -import { EmailTemplate, EmailTemplateVersion, EmailAbTest, EmailAbVariant } from './email-template.entity'; +import { + EmailTemplate, + EmailTemplateVersion, + EmailAbTest, + EmailAbVariant, +} from './email-template.entity'; @Module({ - imports: [TypeOrmModule.forFeature([EmailTemplate, EmailTemplateVersion, EmailAbTest, EmailAbVariant])], + imports: [ + TypeOrmModule.forFeature([ + EmailTemplate, + EmailTemplateVersion, + EmailAbTest, + EmailAbVariant, + ]), + ], controllers: [EmailTemplatesController], providers: [EmailTemplatesService], exports: [EmailTemplatesService], diff --git a/backend/src/modules/email-templates/utils/substitute.ts b/backend/src/modules/email-templates/utils/substitute.ts index ad1842a6f..d24c8b8db 100644 --- a/backend/src/modules/email-templates/utils/substitute.ts +++ b/backend/src/modules/email-templates/utils/substitute.ts @@ -1,4 +1,7 @@ -export function substituteVariables(template: string, vars: Record) { +export function substituteVariables( + template: string, + vars: Record, +) { if (!template) return template; return template.replace(/{{\s*([a-zA-Z0-9_.]+)\s*}}/g, (_, key) => { const parts = key.split('.'); diff --git a/backend/src/modules/feature-flags/feature-flags.controller.ts b/backend/src/modules/feature-flags/feature-flags.controller.ts index 767113f47..4afcb97bc 100644 --- a/backend/src/modules/feature-flags/feature-flags.controller.ts +++ b/backend/src/modules/feature-flags/feature-flags.controller.ts @@ -50,9 +50,15 @@ export class FeatureFlagsController { @Query('segments') segments?: string | string[], ) { const segmentList = segments - ? Array.isArray(segments) ? segments : [segments] + ? Array.isArray(segments) + ? segments + : [segments] : undefined; - return this.service.evaluate(key, { address, network, segments: segmentList }); + return this.service.evaluate(key, { + address, + network, + segments: segmentList, + }); } /** Admin: get a single flag */ diff --git a/backend/src/modules/feature-flags/feature-flags.service.ts b/backend/src/modules/feature-flags/feature-flags.service.ts index dc5e11f72..cd2346df6 100644 --- a/backend/src/modules/feature-flags/feature-flags.service.ts +++ b/backend/src/modules/feature-flags/feature-flags.service.ts @@ -30,7 +30,9 @@ export class FeatureFlagsService { } async create(dto: CreateFlagDto): Promise { - const existing = await this.flagRepository.findOne({ where: { key: dto.key } }); + const existing = await this.flagRepository.findOne({ + where: { key: dto.key }, + }); if (existing) { throw new ConflictException(`Feature flag "${dto.key}" already exists`); } @@ -49,7 +51,9 @@ export class FeatureFlagsService { const flag = await this.findOne(key); Object.assign(flag, dto); const saved = await this.flagRepository.save(flag); - this.logger.log(`Feature flag updated: ${key}`, { changes: Object.keys(dto) }); + this.logger.log(`Feature flag updated: ${key}`, { + changes: Object.keys(dto), + }); return saved; } @@ -90,7 +94,10 @@ export class FeatureFlagsService { ); if (isTargeted) { return { - value: flag.type === 'boolean' ? flag.enabled : (flag.value ?? flag.defaultValue), + value: + flag.type === 'boolean' + ? flag.enabled + : (flag.value ?? flag.defaultValue), reason: 'user_targeted', }; } @@ -110,7 +117,10 @@ export class FeatureFlagsService { ); if (hasSegment) { return { - value: flag.type === 'boolean' ? flag.enabled : (flag.value ?? flag.defaultValue), + value: + flag.type === 'boolean' + ? flag.enabled + : (flag.value ?? flag.defaultValue), reason: 'segment_matched', }; } @@ -128,7 +138,10 @@ export class FeatureFlagsService { } return { - value: flag.type === 'boolean' ? flag.enabled : (flag.value ?? flag.defaultValue), + value: + flag.type === 'boolean' + ? flag.enabled + : (flag.value ?? flag.defaultValue), reason: 'default', }; } diff --git a/backend/src/modules/governance/dto/cast-vote.dto.ts b/backend/src/modules/governance/dto/cast-vote.dto.ts index f9e11a505..b75d7ffbd 100644 --- a/backend/src/modules/governance/dto/cast-vote.dto.ts +++ b/backend/src/modules/governance/dto/cast-vote.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty } from 'class-validator'; import { VoteDirection } from '../entities/vote.entity'; export class CastVoteDto { @@ -7,5 +8,9 @@ export class CastVoteDto { description: 'The direction of the vote', example: VoteDirection.FOR, }) + @IsNotEmpty() + @IsEnum(VoteDirection, { + message: `direction must be one of: ${Object.values(VoteDirection).join(', ')}`, + }) direction!: VoteDirection; } diff --git a/backend/src/modules/governance/dto/proposal-template-detail.dto.ts b/backend/src/modules/governance/dto/proposal-template-detail.dto.ts index fcd09af51..3a46088ce 100644 --- a/backend/src/modules/governance/dto/proposal-template-detail.dto.ts +++ b/backend/src/modules/governance/dto/proposal-template-detail.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ProposalCategory, ProposalType } from '../entities/governance-proposal.entity'; +import { + ProposalCategory, + ProposalType, +} from '../entities/governance-proposal.entity'; export class ProposalTemplateParameterDto { @ApiProperty({ description: 'Parameter name' }) @@ -17,13 +20,23 @@ export class ProposalTemplateParameterDto { @ApiProperty({ description: 'Whether the field is required' }) required: boolean; - @ApiProperty({ description: 'Allowed values when using an enum', required: false, example: ['flexiRate', 'fixedRate'] }) + @ApiProperty({ + description: 'Allowed values when using an enum', + required: false, + example: ['flexiRate', 'fixedRate'], + }) allowedValues?: string[]; - @ApiProperty({ description: 'Minimum numeric value when applicable', required: false }) + @ApiProperty({ + description: 'Minimum numeric value when applicable', + required: false, + }) min?: number; - @ApiProperty({ description: 'Maximum numeric value when applicable', required: false }) + @ApiProperty({ + description: 'Maximum numeric value when applicable', + required: false, + }) max?: number; @ApiProperty({ description: 'Default value when omitted', required: false }) diff --git a/backend/src/modules/governance/dto/proposal-template-summary.dto.ts b/backend/src/modules/governance/dto/proposal-template-summary.dto.ts index 62179ebec..af9c7eed8 100644 --- a/backend/src/modules/governance/dto/proposal-template-summary.dto.ts +++ b/backend/src/modules/governance/dto/proposal-template-summary.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ProposalCategory, ProposalType } from '../entities/governance-proposal.entity'; +import { + ProposalCategory, + ProposalType, +} from '../entities/governance-proposal.entity'; export class ProposalTemplateSummaryDto { @ApiProperty({ description: 'Template identifier' }) diff --git a/backend/src/modules/governance/dto/template-usage.dto.ts b/backend/src/modules/governance/dto/template-usage.dto.ts index 4d914068b..8980f21ef 100644 --- a/backend/src/modules/governance/dto/template-usage.dto.ts +++ b/backend/src/modules/governance/dto/template-usage.dto.ts @@ -10,7 +10,9 @@ export class TemplateUsageDto { @ApiProperty({ description: 'Template name' }) templateName: string; - @ApiProperty({ description: 'Number of proposals created using this template' }) + @ApiProperty({ + description: 'Number of proposals created using this template', + }) proposalsCreated: number; @ApiProperty({ description: 'Number of passed proposals' }) diff --git a/backend/src/modules/governance/governance-analytics.service.ts b/backend/src/modules/governance/governance-analytics.service.ts index f218a584b..e7c9f3b80 100644 --- a/backend/src/modules/governance/governance-analytics.service.ts +++ b/backend/src/modules/governance/governance-analytics.service.ts @@ -228,9 +228,10 @@ export class GovernanceAnalyticsService { const proposalsCreated = parseInt(stat.proposalsCreated || '0', 10); const passedProposals = parseInt(stat.passedProposals || '0', 10); const failedProposals = parseInt(stat.failedProposals || '0', 10); - const successRate = proposalsCreated > 0 - ? Math.round((passedProposals / proposalsCreated) * 1000) / 10 - : 0; + const successRate = + proposalsCreated > 0 + ? Math.round((passedProposals / proposalsCreated) * 1000) / 10 + : 0; return { templateId: stat.templateId, diff --git a/backend/src/modules/governance/governance-proposals.controller.ts b/backend/src/modules/governance/governance-proposals.controller.ts index 95d510ba0..f3ecb3efd 100644 --- a/backend/src/modules/governance/governance-proposals.controller.ts +++ b/backend/src/modules/governance/governance-proposals.controller.ts @@ -136,7 +136,8 @@ export class GovernanceProposalsController { @ApiQuery({ name: 'version', required: false, - description: 'Template version to use. Defaults to the latest available version.', + description: + 'Template version to use. Defaults to the latest available version.', }) @ApiResponse({ status: 200, type: ProposalTemplateDetailDto }) getProposalTemplate( @@ -201,12 +202,30 @@ export class GovernanceProposalsController { @Get(':id/status') @ApiOperation({ summary: 'Get current proposal lifecycle state' }) - @ApiParam({ name: 'id', type: 'string', format: 'uuid', description: 'Proposal UUID' }) - @ApiResponse({ status: 200, description: 'Proposal status', schema: { type: 'object', properties: { status: { type: 'string' }, timelockEndsAt: { type: 'string', nullable: true }, executedAt: { type: 'string', nullable: true } } } }) + @ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'Proposal UUID', + }) + @ApiResponse({ + status: 200, + description: 'Proposal status', + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + timelockEndsAt: { type: 'string', nullable: true }, + executedAt: { type: 'string', nullable: true }, + }, + }, + }) @ApiResponse({ status: 404, description: 'Proposal not found' }) - getProposalStatus( - @Param('id') id: string, - ): Promise<{ status: ProposalStatus; timelockEndsAt: Date | null; executedAt: Date | null }> { + getProposalStatus(@Param('id') id: string): Promise<{ + status: ProposalStatus; + timelockEndsAt: Date | null; + executedAt: Date | null; + }> { return this.governanceService.getProposalStatus(id); } @@ -215,7 +234,11 @@ export class GovernanceProposalsController { @ApiBearerAuth() @ApiOperation({ summary: 'Queue a passed proposal (starts timelock)' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 201, description: 'Proposal queued', type: ProposalResponseDto }) + @ApiResponse({ + status: 201, + description: 'Proposal queued', + type: ProposalResponseDto, + }) @ApiResponse({ status: 400, description: 'Proposal not in Passed state' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) queueProposal( @@ -231,8 +254,15 @@ export class GovernanceProposalsController { @ApiBearerAuth() @ApiOperation({ summary: 'Execute a queued proposal after timelock' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 201, description: 'Proposal executed', type: ProposalResponseDto }) - @ApiResponse({ status: 400, description: 'Timelock not elapsed or wrong state' }) + @ApiResponse({ + status: 201, + description: 'Proposal executed', + type: ProposalResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Timelock not elapsed or wrong state', + }) @ApiResponse({ status: 401, description: 'Unauthorized' }) executeProposal( @Param('id') id: string, @@ -246,7 +276,11 @@ export class GovernanceProposalsController { @ApiBearerAuth() @ApiOperation({ summary: 'Cancel a proposal (creator only)' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 201, description: 'Proposal cancelled', type: ProposalResponseDto }) + @ApiResponse({ + status: 201, + description: 'Proposal cancelled', + type: ProposalResponseDto, + }) @ApiResponse({ status: 403, description: 'Not the proposal creator' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) cancelProposal( @@ -256,4 +290,3 @@ export class GovernanceProposalsController { return this.governanceService.cancelProposal(id, user.id); } } - diff --git a/backend/src/modules/governance/proposal-templates.ts b/backend/src/modules/governance/proposal-templates.ts index 83adb3411..27a3b0c79 100644 --- a/backend/src/modules/governance/proposal-templates.ts +++ b/backend/src/modules/governance/proposal-templates.ts @@ -39,13 +39,17 @@ function assertStringField( const value = params[field]; if (value === undefined || value === null) { if (required) { - throw new Error(`Template ${templateId}: missing required parameter '${field}'`); + throw new Error( + `Template ${templateId}: missing required parameter '${field}'`, + ); } return undefined; } if (typeof value !== 'string' || !value.trim()) { - throw new Error(`Template ${templateId}: parameter '${field}' must be a non-empty string`); + throw new Error( + `Template ${templateId}: parameter '${field}' must be a non-empty string`, + ); } return value.trim(); } @@ -61,20 +65,28 @@ function assertNumberField( const value = params[field]; if (value === undefined || value === null) { if (required) { - throw new Error(`Template ${templateId}: missing required parameter '${field}'`); + throw new Error( + `Template ${templateId}: missing required parameter '${field}'`, + ); } return undefined; } if (typeof value !== 'number' || Number.isNaN(value)) { - throw new Error(`Template ${templateId}: parameter '${field}' must be a number`); + throw new Error( + `Template ${templateId}: parameter '${field}' must be a number`, + ); } if (min !== undefined && value < min) { - throw new Error(`Template ${templateId}: parameter '${field}' must be at least ${min}`); + throw new Error( + `Template ${templateId}: parameter '${field}' must be at least ${min}`, + ); } if (max !== undefined && value > max) { - throw new Error(`Template ${templateId}: parameter '${field}' must be at most ${max}`); + throw new Error( + `Template ${templateId}: parameter '${field}' must be at most ${max}`, + ); } return value; @@ -114,7 +126,8 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ { name: 'target', label: 'Rate target', - description: 'The rate field to change, for example flexiRate or fixedRate.', + description: + 'The rate field to change, for example flexiRate or fixedRate.', type: 'enum', required: true, allowedValues: ['flexiRate', 'fixedRate'], @@ -123,7 +136,8 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ { name: 'newValue', label: 'New rate value', - description: 'The new rate percentage to apply for the selected target.', + description: + 'The new rate percentage to apply for the selected target.', type: 'number', required: true, min: 0, @@ -154,7 +168,12 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ 0, 100, ); - const reason = assertStringField(params, 'reason', 'rate-change-standard', false); + const reason = assertStringField( + params, + 'reason', + 'rate-change-standard', + false, + ); return { target, @@ -181,7 +200,12 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ }, ], actionFactory: (params) => { - const reason = assertStringField(params, 'reason', 'pause-protocol', false); + const reason = assertStringField( + params, + 'reason', + 'pause-protocol', + false, + ); return { ...(reason ? { reason } : {}), }; @@ -191,7 +215,8 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ id: 'unpause-protocol', version: '1.0', name: 'Resume Protocol', - description: 'Create a proposal to unpause the protocol after a maintenance pause.', + description: + 'Create a proposal to unpause the protocol after a maintenance pause.', type: ProposalType.UNPAUSE, category: ProposalCategory.GOVERNANCE, parameterSchema: [ @@ -205,7 +230,12 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ }, ], actionFactory: (params) => { - const reason = assertStringField(params, 'reason', 'unpause-protocol', false); + const reason = assertStringField( + params, + 'reason', + 'unpause-protocol', + false, + ); return { ...(reason ? { reason } : {}), }; @@ -268,8 +298,15 @@ const PROPOSAL_TEMPLATES: ProposalTemplateDefinition[] = [ true, 0.00000001, ); - const asset = assertStringField(params, 'asset', 'treasury-allocation', false) ?? 'NST'; - const reason = assertStringField(params, 'reason', 'treasury-allocation', false); + const asset = + assertStringField(params, 'asset', 'treasury-allocation', false) ?? + 'NST'; + const reason = assertStringField( + params, + 'reason', + 'treasury-allocation', + false, + ); return { recipient, amount, diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 8b923b0ed..e03750a0a 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, HttpCode, HttpStatus, Query, Res } from '@nestjs/common'; +import { + Controller, + Get, + HttpCode, + HttpStatus, + Query, + Res, +} from '@nestjs/common'; import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { Response } from 'express'; @@ -172,7 +179,12 @@ export class HealthController { @ApiOperation({ summary: 'Health check dashboard' }) async dashboard(@Res() res: Response) { const data = await this.detailed(); - const scoreColor = parseInt(data.score) > 90 ? '#10B981' : parseInt(data.score) > 70 ? '#F59E0B' : '#EF4444'; + const scoreColor = + parseInt(data.score) > 90 + ? '#10B981' + : parseInt(data.score) > 70 + ? '#F59E0B' + : '#EF4444'; const html = ` @@ -207,7 +219,9 @@ export class HealthController {
- ${Object.entries(data.checks).map(([name, details]: [string, any]) => ` + ${Object.entries(data.checks) + .map( + ([name, details]: [string, any]) => `
${name.replace('-', ' ')}
@@ -218,7 +232,9 @@ export class HealthController { ${details.error ? `
${details.error}
` : ''}
- `).join('')} + `, + ) + .join('')}