Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,34 @@ CACHE_TTL_INTERNAL_NOTES=120
CACHE_TTL_USER_VERIFICATION_HISTORY=180
# AI task status - polled frequently
CACHE_TTL_AI_TASK_STATUS=30

# ── Cost-Aware Rate Limiting Configuration ──
# All values are requests per minute per IP/API key
# Verification endpoints are rate-limited due to high cost (external API calls)
# Health and docs endpoints are never rate-limited

# Strictest: OTP/email/phone operations
# These trigger external SMS/email providers and are most expensive
# Default: 20 requests per minute
THROTTLE_VERIFY_OTP_LIMIT=20
THROTTLE_VERIFY_OTP_TTL=60

# Strict: General verification operations (document upload, verification enqueue)
# Default: 30 requests per minute
THROTTLE_VERIFY_LIMIT=30
THROTTLE_VERIFY_TTL=60

# Moderate: General API endpoints (standard CRUD, queries, business logic)
# Default: 100 requests per minute
THROTTLE_GENERAL_LIMIT=100
THROTTLE_GENERAL_TTL=60

# Search endpoints: Read-only queries
# Default: 50 requests per minute
THROTTLE_SEARCH_LIMIT=50
THROTTLE_SEARCH_TTL=60

# ── Rate Limiting: Multi-Instance Support ──
# ThrottlerModule uses Redis for distributed rate limiting across multiple instances
# If Redis is unavailable, falls back to in-memory storage (not recommended for production)
# REDIS_HOST and REDIS_PORT are already configured above
62 changes: 56 additions & 6 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
import { LoggerService } from './logger/logger.service';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { AnalyticsModule } from './analytics/analytics.module';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule, ThrottlerStorageRedisService } from '@nestjs/throttler';
import { AidEscrowModule } from './onchain/aid-escrow.module';
import { CostAwareThrottlerGuard } from './common/guards/throttle.guard';
import { getThrottlerConfig } from './common/config/rate-limit.config';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SessionModule } from './session/session.module';
import { CommonServicesModule } from './common/services/common-services.module';
Expand Down Expand Up @@ -129,12 +131,56 @@
}),
inject: [ConfigService],
}),
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 seconds window
limit: 20, // default: 20 req/min
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
const redisHost = configService.get<string>('REDIS_HOST') ?? 'localhost';
const redisPort = parseInt(
configService.get<string>('REDIS_PORT') ?? '6379',
10,
);

// Try to use Redis storage for multi-instance compatibility
// Falls back to in-memory storage if Redis is unavailable
try {
const { createClient } = await import('redis');
const client = createClient({
socket: {
host: redisHost,
port: redisPort,
reconnectStrategy: (retries: number) => {
if (retries > 10) {
console.warn(
'ThrottlerModule: Failed to connect to Redis after 10 retries, falling back to in-memory storage',
);
return new Error(
'Max retries exceeded for ThrottlerModule Redis',
);
}
return retries * 50;
},
},
});

await client.connect();

return {
throttlers: getThrottlerConfig(),
storage: new ThrottlerStorageRedisService(client),
};
} catch (error) {
console.warn(
'ThrottlerModule: Redis unavailable, using in-memory storage',

Check warning on line 173 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe construction of a type that could not be resolved

Check warning on line 173 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an error typed value
error instanceof Error ? error.message : error,
);
// Fall back to in-memory storage for local development
return {
throttlers: getThrottlerConfig(),
};
}
},
]),
inject: [ConfigService],
}),
],

controllers: [AppController],
Expand All @@ -160,6 +206,10 @@
provide: APP_GUARD,
useClass: AdaptiveRateLimitGuard, // Adaptive rate limiting using Redis
},
{
provide: APP_GUARD,
useClass: CostAwareThrottlerGuard, // NestJS Throttler with cost-aware per-endpoint limits
},
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
Expand Down
126 changes: 126 additions & 0 deletions app/backend/src/common/config/rate-limit.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Cost-aware rate limiting configuration
*
* This module defines route-specific rate limits based on endpoint cost and resource usage:
* - Strictest: Verification endpoints (email/phone/OTP operations are costly)
* - Strict: General verification endpoints (document processing)
* - Moderate: General API endpoints (business logic operations)
* - None: Health checks, metrics, docs (no limiting needed)
*
* All values in requests per minute per IP/API key
*/

const parseNumber = (value: string | undefined, fallback: number): number => {
if (value === undefined) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

/**
* Rate limit definitions per endpoint category
*/
export const RATE_LIMIT_CONFIG = {
/**
* Verification (email/phone/OTP): Strictest limit
* High cost due to external API calls (email, SMS providers)
* Default: 20 requests per minute
*/
VERIFY_OTP: {
limit: parseNumber(process.env.THROTTLE_VERIFY_OTP_LIMIT, 20),
ttl: parseNumber(process.env.THROTTLE_VERIFY_OTP_TTL, 60), // 60 seconds
},

/**
* Verification endpoints: Strict limit
* Document processing, verification enqueue, resend operations
* Default: 30 requests per minute
*/
VERIFY_ENDPOINT: {
limit: parseNumber(process.env.THROTTLE_VERIFY_LIMIT, 30),
ttl: parseNumber(process.env.THROTTLE_VERIFY_TTL, 60), // 60 seconds
},

/**
* General API endpoints: Moderate limit
* Standard CRUD operations, queries, business logic
* Default: 100 requests per minute
*/
GENERAL_ENDPOINT: {
limit: parseNumber(process.env.THROTTLE_GENERAL_LIMIT, 100),
ttl: parseNumber(process.env.THROTTLE_GENERAL_TTL, 60), // 60 seconds
},

/**
* Search endpoints: Moderate limit
* Queries can be expensive but are read-only
* Default: 50 requests per minute
*/
SEARCH_ENDPOINT: {
limit: parseNumber(process.env.THROTTLE_SEARCH_LIMIT, 50),
ttl: parseNumber(process.env.THROTTLE_SEARCH_TTL, 60), // 60 seconds
},
} as const;

/**
* Get ThrottlerModule configuration for NestJS global setup
* Supports Redis-backed storage for multi-instance compatibility
*
* @returns Array of ThrottlerOptions for ThrottlerModule.forRoot()
*/
export const getThrottlerConfig = () => [
{
// Default throttle configuration (fallback)
name: 'default',
ttl: RATE_LIMIT_CONFIG.GENERAL_ENDPOINT.ttl * 1000, // Convert to ms
limit: RATE_LIMIT_CONFIG.GENERAL_ENDPOINT.limit,
},
{
// Strict verification endpoints
name: 'verify',
ttl: RATE_LIMIT_CONFIG.VERIFY_ENDPOINT.ttl * 1000,
limit: RATE_LIMIT_CONFIG.VERIFY_ENDPOINT.limit,
},
{
// Strictest OTP/email/phone endpoints
name: 'verify-otp',
ttl: RATE_LIMIT_CONFIG.VERIFY_OTP.ttl * 1000,
limit: RATE_LIMIT_CONFIG.VERIFY_OTP.limit,
},
{
// Search queries
name: 'search',
ttl: RATE_LIMIT_CONFIG.SEARCH_ENDPOINT.ttl * 1000,
limit: RATE_LIMIT_CONFIG.SEARCH_ENDPOINT.limit,
},
];

/**
* Routes that should NOT be rate limited
* Health checks, metrics, and docs endpoints need unrestricted access
*/
export const RATE_LIMIT_SKIP_PATHS = [
// Health checks
/^\/(api\/)?(v\d+\/)?health(\/|$)/i,
/^\/(api\/)?(v\d+\/)?ping(\/|$)/i,
// Metrics
/^\/(api\/)?(v\d+\/)?metrics(\/|$)/i,
// Documentation
/^\/(api\/)?docs(\/|$)/i,
/^\/(api\/)?(v\d+\/)?swagger(\/|$)/i,
// OpenAPI spec
/^\/(api\/)?swagger\.json(\/|$)/i,
// Healthcheck alternatives
/^\/(api\/)?(v\d+\/)?status(\/|$)/i,
] as const;

/**
* Check if a request path should skip rate limiting
* @param path The request path
* @returns true if the path is exempt from rate limiting
*/
export const shouldSkipRateLimit = (path: string): boolean => {
const normalizedPath = path.split('?')[0]; // Remove query params
return RATE_LIMIT_SKIP_PATHS.some(pattern => pattern.test(normalizedPath));
};
18 changes: 18 additions & 0 deletions app/backend/src/common/decorators/skip-throttle.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { SetMetadata } from '@nestjs/common';

/**
* Skip rate limiting for this route
* Used for health checks, metrics, and other high-traffic endpoints
* that should not be throttled
*
* @example
* ```ts
* @Get('health')
* @SkipThrottle()
* check() {
* return { status: 'ok' };
* }
* ```
*/
export const SKIP_THROTTLE_KEY = 'skipThrottle';
export const SkipThrottle = () => SetMetadata(SKIP_THROTTLE_KEY, true);
47 changes: 47 additions & 0 deletions app/backend/src/common/guards/throttle.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
Injectable,
CanActivate,

Check failure on line 3 in app/backend/src/common/guards/throttle.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'CanActivate' is defined but never used. Allowed unused vars must match /^_/u
ExecutionContext,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';
import { SKIP_THROTTLE_KEY } from '../decorators/skip-throttle.decorator';
import { shouldSkipRateLimit } from '../config/rate-limit.config';

/**
* Enhanced ThrottlerGuard that respects @SkipThrottle() decorator
* and globally exempt paths (health, docs, metrics)
*
* This guard:
* 1. Checks if route has @SkipThrottle() decorator
* 2. Checks if path matches globally exempt patterns
* 3. Falls back to standard ThrottlerGuard behavior
*/
@Injectable()
export class CostAwareThrottlerGuard extends ThrottlerGuard {
constructor(@Inject(Reflector) protected reflector: Reflector) {
super();
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const skipThrottle = this.reflector.get<boolean>(
SKIP_THROTTLE_KEY,
context.getHandler(),
);

if (skipThrottle) {
return true;
}

const request = context.switchToHttp().getRequest();
const path = request.path ?? request.url ?? '';

if (shouldSkipRateLimit(path)) {
return true;
}

// Delegate to parent ThrottlerGuard
return super.canActivate(context);
}
}
8 changes: 6 additions & 2 deletions app/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ import { HealthService } from './health.service';
import { LivenessResponse, ReadinessResponse } from './health.service';
import { API_VERSIONS } from '../common/constants/api-version.constants';
import { Public } from '../common/decorators/public.decorator';
import { Throttle } from '@nestjs/throttler';
import { SkipThrottle } from '../common/decorators/skip-throttle.decorator';

@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}

@Public()
@SkipThrottle()
@Get()
@Throttle({ default: { ttl: 60, limit: 100 } }) // Limit to 100 requests per minute for this endpoint
@Version(API_VERSIONS.V1)
@ApiOperation({
summary: 'Check system liveness and basic service metadata',
Expand All @@ -46,6 +46,7 @@ export class HealthController {
}

@Public()
@SkipThrottle()
@Get('live')
@Version(API_VERSIONS.V1)
@ApiOperation({
Expand All @@ -67,6 +68,7 @@ export class HealthController {
}

@Public()
@SkipThrottle()
@Get('ready')
@Version(API_VERSIONS.V1)
@ApiOperation({
Expand Down Expand Up @@ -111,6 +113,7 @@ export class HealthController {
}

@Get('error')
@SkipThrottle()
@Version(API_VERSIONS.V1)
@ApiOperation({ summary: 'Trigger an error for testing' })
@ApiInternalServerErrorResponse({
Expand All @@ -127,6 +130,7 @@ export class HealthController {
}

@Get('onchain')
@SkipThrottle()
@Version(API_VERSIONS.V1)
@ApiOperation({
summary: 'On-chain contract health probe (internal use)',
Expand Down
Loading
Loading