Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { AnalyticsModule } from './modules/analytics/analytics.module';
import { DisputeResolutionModule } from './modules/dispute-resolution/dispute-resolution.module';
import { AuditModule } from './modules/audit/audit.module';
import { EscrowModule } from './modules/escrow/escrow.module';
import { RateLimitingModule } from './modules/rate-limiting/rate-limiting.module';

@Module({
imports: [
Expand Down Expand Up @@ -51,6 +52,7 @@ import { EscrowModule } from './modules/escrow/escrow.module';
DisputeResolutionModule,
AuditModule,
EscrowModule,
RateLimitingModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
5 changes: 5 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export default () => ({
eventPageLimit: parseInt(process.env.SOROBAN_EVENT_PAGE_LIMIT ?? '100', 10),
},

rateLimit: {
strategy: process.env.RATE_LIMIT_STRATEGY ?? 'sliding_window',
defaultWindowSize: parseInt(process.env.RATE_LIMIT_DEFAULT_WINDOW ?? '60', 10),
},

notifications: {
emailFrom: process.env.NOTIFICATION_EMAIL_FROM ?? 'noreply@interchangeabletrade.com',
twilio: {
Expand Down
6 changes: 6 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export const envValidationSchema = Joi.object({
BLOCKCHAIN_INDEXER_STREAM_TTL_SECS: Joi.number().default(300),
BLOCKCHAIN_INDEXER_INCLUDE_FAILED: Joi.boolean().default(true),

// Rate limiting
RATE_LIMIT_STRATEGY: Joi.string()
.valid('sliding_window', 'fixed_window')
.default('sliding_window'),
RATE_LIMIT_DEFAULT_WINDOW: Joi.number().default(60),

// Notification services (Twilio SMS)
TWILIO_ACCOUNT_SID: Joi.string().optional(),
TWILIO_AUTH_TOKEN: Joi.string().optional(),
Expand Down
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import helmet from 'helmet';
import { AllExceptionsFilter, TransformInterceptor } from '@app/common';
import { AppModule } from './app.module';
import { ErrorHandlerService } from './modules/error-handler/error-handler.service';
import { RateLimitGuard } from './modules/rate-limiting/guards/rate-limit.guard';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand All @@ -25,6 +26,11 @@ async function bootstrap() {
}),
);

// Global rate limiting guard — protects all routes except those
// decorated with @BypassRateLimit().
const rateLimitGuard = app.get(RateLimitGuard);
app.useGlobalGuards(rateLimitGuard);

// Consistent success envelope and error shape across all endpoints.
app.useGlobalInterceptors(new TransformInterceptor());
const errorHandler = app.get(ErrorHandlerService);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { SetMetadata } from '@nestjs/common';

export const RATE_LIMIT_BYPASS_KEY = 'rate-limit-bypass';

/**
* Marks a route or controller as exempt from rate limiting.
* Use for critical system operations (health checks, webhooks, etc.).
*
* @example
* @BypassRateLimit()
* @Get('health')
* healthCheck() { return { status: 'ok' }; }
*/
export const BypassRateLimit = () => SetMetadata(RATE_LIMIT_BYPASS_KEY, true);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { SetMetadata } from '@nestjs/common';
import { RateLimitTier } from '../enums/rate-limit.enum';

export const RATE_LIMIT_CONFIG_KEY = 'rate-limit-config';

export interface RateLimitOverride {
/** Override the max requests for this route specifically. */
maxRequests?: number;
/** Override the tier used for this route. */
tier?: RateLimitTier;
/** Override the window size in seconds. */
windowSizeSeconds?: number;
}

/**
* Apply per-route rate limit overrides. These values take precedence over
* the tier-based defaults when the guard evaluates this route.
*
* @example
* @RateLimitConfig({ maxRequests: 10, windowSizeSeconds: 1 })
* @Post('trade')
* placeOrder() { ... }
*/
export const RateLimitConfig = (override: RateLimitOverride) =>
SetMetadata(RATE_LIMIT_CONFIG_KEY, override);
75 changes: 75 additions & 0 deletions src/modules/rate-limiting/dto/update-rate-limit.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
IsEnum,
IsInt,
IsBoolean,
IsOptional,
IsString,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { RateLimitTier } from '../enums/rate-limit.enum';

/**
* Request body to create or update a rate limit configuration for a tier.
*/
export class UpdateRateLimitDto {
@ApiProperty({ enum: RateLimitTier, description: 'Target subscription tier' })
@IsEnum(RateLimitTier)
tier!: RateLimitTier;

@ApiProperty({
description: 'Maximum requests per window (use -1 for unlimited)',
example: 100,
})
@IsInt()
@Min(-1)
maxRequests!: number;

@ApiPropertyOptional({
description: 'Window size in seconds',
example: 60,
default: 60,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(3600)
windowSizeSeconds?: number;

@ApiPropertyOptional({
description: 'Endpoint pattern (e.g. /api/trading/*)',
nullable: true,
})
@IsOptional()
@IsString()
endpointPattern?: string;

@ApiPropertyOptional({
description: 'Whether this tier is enabled',
default: true,
})
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

/**
* Response DTO for a rate limit configuration.
*/
export class RateLimitConfigResponseDto {
@ApiProperty({ enum: RateLimitTier })
tier!: RateLimitTier;

@ApiProperty()
maxRequests!: number;

@ApiProperty()
windowSizeSeconds!: number;

@ApiProperty({ nullable: true })
endpointPattern?: string | null;

@ApiProperty()
enabled!: boolean;
}
31 changes: 31 additions & 0 deletions src/modules/rate-limiting/entities/rate-limit-config.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@app/common';
import { RateLimitTier } from '../enums/rate-limit.enum';

/**
* Stores per-tier and per-endpoint rate limit overrides.
* Admin endpoints can read/write these at runtime without restarts.
*/
@Entity('rate_limit_configs')
export class RateLimitConfig extends BaseEntity {
/** Which tier this configuration applies to. */
@Index({ unique: true })
@Column({ type: 'enum', enum: RateLimitTier, unique: true })
tier: RateLimitTier;

/** Maximum number of requests allowed in the window. */
@Column({ type: 'int', default: 100 })
maxRequests: number;

/** Window size in seconds. */
@Column({ type: 'int', default: 60 })
windowSizeSeconds: number;

/** Optional endpoint-specific pattern (e.g. '/api/trading/*'). Empty means global. */
@Column({ type: 'varchar', nullable: true })
endpointPattern?: string | null;

/** Whether this tier is currently enabled. */
@Column({ type: 'boolean', default: true })
enabled: boolean;
}
62 changes: 62 additions & 0 deletions src/modules/rate-limiting/enums/rate-limit.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* User subscription tiers that determine rate limit quotas.
*/
export enum RateLimitTier {
FREE = 'free',
PREMIUM = 'premium',
ENTERPRISE = 'enterprise',
}

/**
* Supported sliding window algorithms.
*/
export enum SlidingWindowStrategy {
/** Standard sliding window — counts requests in a rolling time window. */
SLIDING_WINDOW = 'sliding_window',
/** Fixed window — counts requests in a fixed time block (simpler, less precise). */
FIXED_WINDOW = 'fixed_window',
}

/**
* Determines how the rate limit key is derived for unauthenticated requests.
*/
export enum RateLimitIdentifier {
IP = 'ip',
API_KEY = 'api_key',
USER = 'user',
}

/**
* Default quotas per tier (requests per 60-second window).
*/
export const DEFAULT_TIER_QUOTAS: Record<RateLimitTier, number> = {
[RateLimitTier.FREE]: 100,
[RateLimitTier.PREMIUM]: 1000,
[RateLimitTier.ENTERPRISE]: Infinity,
};

/**
* Default window size in seconds.
*/
export const DEFAULT_WINDOW_SIZE_SECS = 60;

/**
* Redis key prefix for rate limit data.
*/
export const RATE_LIMIT_KEY_PREFIX = 'rl:';

/**
* Endpoints exempt from rate limiting (critical system operations).
*/
export const DEFAULT_BYPASS_PATHS: string[] = ['/api/health', '/api/docs'];

/**
* Response header names for rate limit information.
*/
export const RATE_LIMIT_HEADERS = {
LIMIT: 'X-RateLimit-Limit',
REMAINING: 'X-RateLimit-Remaining',
RESET: 'X-RateLimit-Reset',
RETRY_AFTER: 'Retry-After',
POLICY: 'X-RateLimit-Policy',
} as const;
Loading
Loading