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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ CHAIN_ID=1
# Redis (for caching and WebSocket)
REDIS_URL=redis://localhost:6379

# Rate limiting tiers
# Free users: 100 req/min, paid users: 1000 req/min, enterprise users: 10000 req/min
RATE_LIMIT_FREE_PER_MINUTE=100
RATE_LIMIT_PAID_PER_MINUTE=1000
RATE_LIMIT_ENTERPRISE_PER_MINUTE=10000
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_BURST_MULTIPLIER=1.2

# Logging
LOG_LEVEL=debug

Expand Down Expand Up @@ -81,4 +89,3 @@ REFERRAL_RATE_LIMIT_MAX_ATTEMPTS=10
REFERRAL_ENABLE_BOT_DETECTION=true
# Enable VPN/Proxy detection (requires external service)
REFERRAL_ENABLE_VPN_DETECTION=false

8 changes: 6 additions & 2 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ EMAIL_FROM=noreply@alian-structure.com
EMAIL_VERIFICATION_URL=https://api.alian-structure.com/api/v1/auth/verify-email

# Rate Limiting
THROTTLE_TTL=60000
THROTTLE_LIMIT=100
# Free users: 100 req/min, paid users: 1000 req/min, enterprise users: 10000 req/min
RATE_LIMIT_FREE_PER_MINUTE=100
RATE_LIMIT_PAID_PER_MINUTE=1000
RATE_LIMIT_ENTERPRISE_PER_MINUTE=10000
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_BURST_MULTIPLIER=1.2

# Security Headers
HSTS_MAX_AGE=31536000
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ Fine-grained control over compute jobs with role-based access control:
Configuration & deployment
--------------------------
- Environment variables drive provider keys, DB endpoints, wallet signing keys, and feature flags.
- Rate-limit tiers are configurable with `RATE_LIMIT_FREE_PER_MINUTE`, `RATE_LIMIT_PAID_PER_MINUTE`, and `RATE_LIMIT_ENTERPRISE_PER_MINUTE`.
- Use the simulator environment for safe, deterministic testing before enabling live on‑chain submission.
- Run behind an API gateway for rate limiting and authentication; use TLS for all external endpoints.
- Store signing keys in a KMS and follow key rotation practices.
Expand Down
15 changes: 2 additions & 13 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { join } from "path";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
Expand Down Expand Up @@ -86,7 +85,7 @@ import { AlertPreference } from "./growth/alerts/entities/alert-preference.entit

// Guards
import { APP_FILTER } from "@nestjs/core";
import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard";
import { QuotaGuard } from "./common/guard/quota.guard";
import { RolesGuard } from "./common/guard/roles.guard";
import { KycGuard } from "./common/guard/kyc.guard";
import { StrategyAuthGuard } from "./core/auth/guards/strategy-auth.guard";
Expand Down Expand Up @@ -181,15 +180,6 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";

EventEmitterModule.forRoot(),

ThrottlerModule.forRoot({
throttlers: [
{ name: "global", ttl: 60_000, limit: 100 },
{ name: "auth", ttl: 60_000, limit: 5 },
{ name: "trading", ttl: 60_000, limit: 20 },
{ name: "oracle", ttl: 60_000, limit: 10 },
],
}),

AuthModule,
UserModule,
ProfileModule,
Expand Down Expand Up @@ -218,7 +208,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";
},
{
provide: APP_GUARD,
useClass: ThrottlerUserIpGuard,
useClass: QuotaGuard,
},
{
provide: APP_GUARD,
Expand Down Expand Up @@ -253,4 +243,3 @@ export class AppModule implements NestModule, OnModuleInit {
}



11 changes: 8 additions & 3 deletions src/common/decorators/rate-limit.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { SetMetadata, applyDecorators } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";

/**
* Apply a named throttle configuration to a controller or handler.
Expand Down Expand Up @@ -29,7 +28,14 @@ const TIER_CONFIG: Record<SensitiveTier, { limit: number; ttl: number }> = {

export function SensitiveRateLimit(tier: SensitiveTier = "default") {
const { limit, ttl } = TIER_CONFIG[tier];
return applyDecorators(Throttle({ default: { limit, ttl } }));
return applyDecorators(
RateLimit({
level: tier,
limit,
windowMs: ttl,
burst: limit,
}),
);
}

/**
Expand All @@ -44,4 +50,3 @@ export function RateLimit(options: RateLimitOptions) {
}



210 changes: 88 additions & 122 deletions src/common/guard/quota.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,132 +1,98 @@
// ⚠️ TEST FILE HAS DEPENDENCIES ON MISSING CODE
// This test references RateLimiterService from "src/quota/rate-limiter.service"
// which does not exist in the current codebase. The quota/ directory was likely
// removed or merged elsewhere during codebase consolidation.
//
// To fix this test:
// 1. Locate the current rate limiter service implementation
// 2. Update the import path to point to the correct location
// 3. Ensure all mocks are updated accordingly

import { Test, TestingModule } from "@nestjs/testing";
import {
ExecutionContext,
HttpException,
HttpStatus,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { QuotaGuard } from "./quota.guard";
// import { RateLimiterService } from "src/quota/rate-limiter.service"; // Missing dependency
import { HttpException } from "@nestjs/common";

function createContext(options?: {
user?: { id?: string; sub?: string; address?: string; role?: string; tier?: string; type?: string };
ip?: string;
originalUrl?: string;
}): { context: ExecutionContext; response: { header: jest.Mock; setHeader: jest.Mock } } {
const request = {
ip: options?.ip ?? "127.0.0.1",
headers: {},
originalUrl: options?.originalUrl ?? "/api/test",
route: { path: "/api/test" },
authType: options?.user?.type,
user: options?.user,
};

const response = {
header: jest.fn(),
setHeader: jest.fn(),
};

const context = {
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ExecutionContext;

return { context, response };
}

describe("QuotaGuard", () => {
// TODO: Uncomment when RateLimiterService is located and import is fixed
// let guard: QuotaGuard;
// let reflector: Reflector;
// let rateLimiterService: RateLimiterService;
//
// beforeEach(async () => {
// const module: TestingModule = await Test.createTestingModule({
// providers: [
// QuotaGuard,
// {
// provide: Reflector,
// useValue: {
// getAllAndOverride: jest.fn(),
// },
// },
// {
// provide: RateLimiterService,
// useValue: {
// checkQuota: jest.fn().mockResolvedValue(true),
// },
// },
// ],
// }).compile();
//
// guard = module.get<QuotaGuard>(QuotaGuard);
// reflector = module.get<Reflector>(Reflector);
// rateLimiterService = module.get<RateLimiterService>(RateLimiterService);
// });
let guard: QuotaGuard;
let reflector: Reflector;

it("placeholder test until dependencies are restored", () => {
// This test exists to prevent the test suite from failing due to missing dependencies
expect(true).toBe(true);
beforeEach(() => {
reflector = new Reflector();
guard = new QuotaGuard(reflector);
});

// TODO: Uncomment all tests below when RateLimiterService is located and import is fixed
// it("should be defined", () => {
// expect(guard).toBeDefined();
// });
//
// it("should allow request if no @RateLimit decorator is present", async () => {
// (reflector.getAllAndOverride as jest.Mock).mockReturnValue(null);
// const context = {
// getHandler: jest.fn(),
// getClass: jest.fn(),
// switchToHttp: jest.fn().mockReturnValue({
// getRequest: jest.fn(),
// }),
// } as any;
//
// const result = await guard.canActivate(context);
// expect(result).toBe(true);
// });
//
// it("should throw HttpException if rate limit is exceeded", async () => {
// (reflector.getAllAndOverride as jest.Mock).mockReturnValue({
// level: "free",
// });
// (rateLimiterService.checkQuota as jest.Mock).mockResolvedValue({
// allowed: false,
// remaining: 0,
// resetMs: 60000,
// });
//
// const mockResponse = {
// header: jest.fn(),
// };
// const context = {
// getHandler: jest.fn(),
// getClass: jest.fn(),
// switchToHttp: jest.fn().mockReturnValue({
// getRequest: jest.fn().mockReturnValue({ ip: "127.0.0.1", headers: {} }),
// getResponse: jest.fn().mockReturnValue(mockResponse),
// }),
// } as any;
//
// await expect(guard.canActivate(context)).rejects.toThrow(HttpException);
// expect(mockResponse.header).toHaveBeenCalledWith(
// "X-RateLimit-Limit",
// expect.any(Number),
// );
// });
//
// it("should allow request and set headers if within limit", async () => {
// (reflector.getAllAndOverride as jest.Mock).mockReturnValue({
// level: "free",
// });
// (rateLimiterService.checkQuota as jest.Mock).mockResolvedValue({
// allowed: true,
// remaining: 5,
// resetMs: 60000,
// });
//
// const mockResponse = {
// header: jest.fn(),
// };
// const context = {
// getHandler: jest.fn(),
// getClass: jest.fn(),
// switchToHttp: jest.fn().mockReturnValue({
// getRequest: jest.fn().mockReturnValue({ ip: "127.0.0.1", headers: {} }),
// getResponse: jest.fn().mockReturnValue(mockResponse),
// }),
// } as any;
//
// const result = await guard.canActivate(context);
// expect(result).toBe(true);
// expect(mockResponse.header).toHaveBeenCalledWith(
// "X-RateLimit-Remaining",
// 5,
// );
// });
});
it("allows requests and emits rate-limit headers for the default tier", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext();

await expect(guard.canActivate(context)).resolves.toBe(true);
expect(response.header).toHaveBeenCalledWith("X-RateLimit-Limit", 100);
expect(response.header).toHaveBeenCalledWith(
"X-RateLimit-Remaining",
99,
);
expect(response.header).toHaveBeenCalledWith("X-RateLimit-Tier", "free");
});

it("uses the authenticated user tier when one is available", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(undefined);
const { context, response } = createContext({
user: { id: "user-1", role: "admin" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);
expect(response.header).toHaveBeenCalledWith(
"X-RateLimit-Tier",
"enterprise",
);
});

it("rejects requests after the configured limit is reached", async () => {
jest.spyOn(reflector, "getAllAndOverride").mockReturnValue({
level: "auth",
limit: 1,
windowMs: 60_000,
});

const { context } = createContext({
user: { id: "user-2", role: "user" },
});

await expect(guard.canActivate(context)).resolves.toBe(true);

try {
await guard.canActivate(context);
throw new Error("Expected rate limit rejection");
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
}
});
});
Loading
Loading