- Wire ThrottlerModule to a Redis-backed storage (shared across API
instances) using @nest-lab/throttler-storage-redis.
- Add FeatureListingThrottlerGuard that tracks per-user when JWT is
present, falling back to the real client IP behind the reverse proxy —
keeps per-user and per-IP buckets independent.
- Apply @Throttle({ default: { limit: 10, ttl: 60_000 } }) + the guard
to POST /listings/:id/feature and document 429 in Swagger.
- Integration test (feature-listing-throttle.integration.spec.ts)
verifies: 10 reqs pass / 11th returns 429 with Retry-After, separate
IPs keep their own quotas, and the tracker key logic.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
|
import { BullModule } from '@nestjs/bullmq';
|
|
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
|
import { CqrsModule } from '@nestjs/cqrs';
|
|
import { ScheduleModule } from '@nestjs/schedule';
|
|
import { ThrottlerModule } from '@nestjs/throttler';
|
|
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
|
|
import { AdminModule } from '@modules/admin';
|
|
import { AgentsModule } from '@modules/agents';
|
|
import { AnalyticsModule } from '@modules/analytics';
|
|
import { AuthModule } from '@modules/auth';
|
|
import { FavoritesModule } from '@modules/favorites';
|
|
import { HealthModule } from '@modules/health';
|
|
import { IndustrialModule } from '@modules/industrial';
|
|
import { InquiriesModule } from '@modules/inquiries';
|
|
import { LeadsModule } from '@modules/leads';
|
|
import { ListingsModule } from '@modules/listings';
|
|
import { McpIntegrationModule } from '@modules/mcp';
|
|
import { MessagingModule } from '@modules/messaging';
|
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
|
import { NotificationsModule } from '@modules/notifications';
|
|
import { PaymentsModule } from '@modules/payments';
|
|
import { ProjectsModule } from '@modules/projects';
|
|
import { ReportsModule } from '@modules/reports';
|
|
import { ReviewsModule } from '@modules/reviews';
|
|
import { SearchModule } from '@modules/search';
|
|
import { SharedModule } from '@modules/shared';
|
|
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
|
|
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
|
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
|
|
import { SubscriptionsModule } from '@modules/subscriptions';
|
|
import { TransferModule } from '@modules/transfer';
|
|
import { AppController } from './app.controller';
|
|
|
|
@Module({
|
|
imports: [
|
|
SentryModule.forRoot(),
|
|
BullModule.forRoot({
|
|
connection: {
|
|
host: process.env['REDIS_HOST'] ?? 'localhost',
|
|
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
|
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
|
},
|
|
}),
|
|
CqrsModule.forRoot(),
|
|
ScheduleModule.forRoot(),
|
|
SharedModule,
|
|
HealthModule,
|
|
AuthModule,
|
|
AgentsModule,
|
|
InquiriesModule,
|
|
LeadsModule,
|
|
ListingsModule,
|
|
ReviewsModule,
|
|
FavoritesModule,
|
|
SearchModule,
|
|
NotificationsModule,
|
|
PaymentsModule,
|
|
SubscriptionsModule,
|
|
AdminModule,
|
|
AnalyticsModule,
|
|
MetricsModule,
|
|
McpIntegrationModule,
|
|
MessagingModule,
|
|
ReportsModule,
|
|
ProjectsModule,
|
|
IndustrialModule,
|
|
TransferModule,
|
|
|
|
// ── Rate Limiting ──
|
|
// Default: 60 requests per 60 seconds per IP
|
|
// Override per-route with @Throttle() decorator
|
|
// Storage: Redis-backed sliding window so limits are shared across
|
|
// every API instance (required for TEC-2930 feature-listing throttling).
|
|
ThrottlerModule.forRoot({
|
|
throttlers: [
|
|
{
|
|
name: 'default',
|
|
ttl: 60_000,
|
|
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 60,
|
|
},
|
|
{
|
|
name: 'auth',
|
|
ttl: 60_000,
|
|
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 10,
|
|
},
|
|
{
|
|
name: 'payment-callback',
|
|
ttl: 60_000,
|
|
limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20,
|
|
},
|
|
],
|
|
storage: new ThrottlerStorageRedisService({
|
|
host: process.env['REDIS_HOST'] ?? 'localhost',
|
|
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
|
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
|
// Single retry per command + bounded reconnect backoff so a
|
|
// transient Redis blip cannot stall the request path. Behaviour
|
|
// matches RedisService for consistency.
|
|
maxRetriesPerRequest: 1,
|
|
enableReadyCheck: false,
|
|
lazyConnect: true,
|
|
retryStrategy(times: number): number {
|
|
return Math.min(times * 1000, 5000);
|
|
},
|
|
keyPrefix: 'throttler:',
|
|
}),
|
|
}),
|
|
],
|
|
controllers: [AppController],
|
|
providers: [
|
|
{
|
|
provide: APP_FILTER,
|
|
useClass: SentryGlobalFilter,
|
|
},
|
|
{
|
|
provide: APP_GUARD,
|
|
useClass: ThrottlerBehindProxyGuard,
|
|
},
|
|
{
|
|
provide: APP_INTERCEPTOR,
|
|
useClass: HttpMetricsInterceptor,
|
|
},
|
|
],
|
|
})
|
|
export class AppModule implements NestModule {
|
|
configure(consumer: MiddlewareConsumer): void {
|
|
// Sanitize all incoming request strings to prevent stored XSS
|
|
consumer
|
|
.apply(SanitizeInputMiddleware)
|
|
.forRoutes('*');
|
|
|
|
// CSRF double-submit cookie (sets on GET, validates on state-changing methods)
|
|
// Exclude health endpoints — they must remain accessible without cookies
|
|
// Skip entirely in test mode so E2E / API tests can POST without a CSRF cookie
|
|
if (process.env['NODE_ENV'] !== 'test') {
|
|
consumer
|
|
.apply(CsrfMiddleware)
|
|
.exclude(
|
|
{ path: 'health', method: RequestMethod.GET },
|
|
{ path: 'health/(.*)', method: RequestMethod.GET },
|
|
)
|
|
.forRoutes('*');
|
|
}
|
|
}
|
|
}
|