diff --git a/.env.example b/.env.example index deaaba4..5a2fdd7 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 - diff --git a/.env.production.example b/.env.production.example index d1e59a7..16ff5bb 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/README.md b/README.md index da87a72..b4253c9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.module.ts b/src/app.module.ts index 2c91b7f..72dde2a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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"; @@ -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"; @@ -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, @@ -218,7 +208,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; }, { provide: APP_GUARD, - useClass: ThrottlerUserIpGuard, + useClass: QuotaGuard, }, { provide: APP_GUARD, @@ -253,4 +243,3 @@ export class AppModule implements NestModule, OnModuleInit { } - diff --git a/src/common/decorators/rate-limit.decorator.ts b/src/common/decorators/rate-limit.decorator.ts index 7661b76..e27616e 100644 --- a/src/common/decorators/rate-limit.decorator.ts +++ b/src/common/decorators/rate-limit.decorator.ts @@ -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. @@ -29,7 +28,14 @@ const TIER_CONFIG: Record = { 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, + }), + ); } /** @@ -44,4 +50,3 @@ export function RateLimit(options: RateLimitOptions) { } - diff --git a/src/common/guard/quota.guard.spec.ts b/src/common/guard/quota.guard.spec.ts index 249480c..d6480cd 100644 --- a/src/common/guard/quota.guard.spec.ts +++ b/src/common/guard/quota.guard.spec.ts @@ -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); - // reflector = module.get(Reflector); - // rateLimiterService = module.get(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, + ); + } + }); +}); diff --git a/src/common/guard/quota.guard.ts b/src/common/guard/quota.guard.ts index 289c78d..3665a7b 100644 --- a/src/common/guard/quota.guard.ts +++ b/src/common/guard/quota.guard.ts @@ -1,290 +1,244 @@ import { CanActivate, ExecutionContext, - Injectable, HttpException, HttpStatus, - Optional, + Injectable, + Logger, } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { RATE_LIMIT_KEY, RateLimitOptions, } from "../decorators/rate-limit.decorator"; -import { QUOTA_LEVELS, DEFAULT_QUOTA } from "src/config/quota.config"; +import { + RateLimitTier, + getRateLimitPolicyFromEnv, + normalizeRateLimitTier, + resolveRateLimitTierFromRole, +} from "src/config/quota.config"; + +interface RateWindowState { + count: number; + resetAt: number; +} + +interface ResolvedPolicy { + tier: RateLimitTier; + label: string; + limit: number; + windowMs: number; + burst: number; +} @Injectable() export class QuotaGuard implements CanActivate { - private metrics?: any; - private dynamicScaling?: any; - private analytics?: any; - private premiumBonus?: any; - private rateLimiterService?: any; + private readonly logger = new Logger(QuotaGuard.name); + private readonly windows = new Map(); + private lastCleanupAt = Date.now(); constructor( private readonly reflector: Reflector, - @Optional() metrics?: any, - @Optional() dynamicScaling?: any, - @Optional() analytics?: any, - @Optional() premiumBonus?: any, - @Optional() rateLimiterService?: any, - ) { - this.metrics = metrics; - this.dynamicScaling = dynamicScaling; - this.analytics = analytics; - this.premiumBonus = premiumBonus; - this.rateLimiterService = rateLimiterService; - } + ) {} async canActivate(context: ExecutionContext): Promise { const options = this.reflector.getAllAndOverride( RATE_LIMIT_KEY, [context.getHandler(), context.getClass()], - ) as RateLimitOptions | undefined; - - if (!options) { - return true; - } + ); const request = context.switchToHttp().getRequest(); - const trackerKey = this.getTrackerKey(request); - - // Merge options with level config - const levelConfig = - QUOTA_LEVELS[(options.level as string) || "free"] || DEFAULT_QUOTA; - const baseLimit = options.limit ?? levelConfig.limit; - const baseWindowMs = options.windowMs ?? levelConfig.windowMs; - const baseBurst = options.burst ?? levelConfig.burst; - - const endpoint = - request.route?.path || request.originalUrl || request.url || "unknown"; - const userId = String(request.user?.id || trackerKey); - const userTier = request.user?.tier || options.level || "unknown"; - const policy = (options.level as string) || "custom"; - - const dynamic = this.dynamicScaling?.getAdjustment({ - key: trackerKey, - userId, - endpoint, + const response = context.switchToHttp().getResponse(); + const tier = this.resolveRequestTier(request); + const policy = this.resolvePolicy(options, tier); + const tracker = this.getTrackerKey(request); + const scope = this.getScope(request, options); + const key = `${tracker}:${scope}:${policy.tier}`; + + const decision = this.consume(key, policy.limit, policy.windowMs); + this.applyHeaders( + response, policy, - baseLimit, - baseWindowMs, - baseBurst, - }); - - const dynamicLimit = dynamic?.limit ?? baseLimit; - const dynamicWindowMs = dynamic?.windowMs ?? baseWindowMs; - const dynamicBurst = dynamic?.burst ?? baseBurst; - - if (dynamic) { - const direction = - dynamic.multiplier > 1.01 - ? "up" - : dynamic.multiplier < 0.99 - ? "down" - : "stable"; - this.metrics?.rateLimitScalingDecisions.inc({ - policy, - endpoint, - direction, - predicted_burst: String(dynamic.predictedBurst), - }); - this.metrics?.rateLimitScalingMultiplier.set( - { - policy, - endpoint, - }, - dynamic.multiplier, - ); - this.metrics?.rateLimitPredictionConfidence.set( + decision.remaining, + decision.resetAt, + ); + + if (!decision.allowed) { + throw new HttpException( { - policy, - endpoint, + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: "Rate limit exceeded", + limit: policy.limit, + remaining: 0, + resetAt: new Date(decision.resetAt).toISOString(), + tier: policy.tier, }, - dynamic.confidence, + HttpStatus.TOO_MANY_REQUESTS, ); - this.metrics?.rateLimitPredictionLatency.observe( - { - policy, - endpoint, - }, - dynamic.predictionLatencyMs, + } + + if (decision.remaining <= Math.max(1, Math.ceil(policy.limit * 0.1))) { + this.logger.warn( + `Approaching rate limit for ${tracker} (${policy.label}): ` + + `${policy.limit - decision.remaining}/${policy.limit}`, ); } - const control = this.analytics?.getEffectiveControl( - request.user?.id, - dynamicLimit, - dynamicWindowMs, - dynamicBurst, - ); + return true; + } - const controlledLimit = control?.limit ?? dynamicLimit; - const controlledWindowMs = control?.windowMs ?? dynamicWindowMs; - const controlledBurst = control?.burst ?? dynamicBurst; - - const premiumAdjustment = this.premiumBonus - ? await this.premiumBonus.getAdjustment({ - userId, - userTier: String(userTier), - endpoint, - policy, - baseLimit: controlledLimit, - baseWindowMs: controlledWindowMs, - baseBurst: controlledBurst, - }) - : undefined; - - const limit = premiumAdjustment?.limit ?? controlledLimit; - const windowMs = premiumAdjustment?.windowMs ?? controlledWindowMs; - const burst = premiumAdjustment?.burst ?? controlledBurst; - - const startedAt = Date.now(); - - const result = await this.rateLimiterService.checkQuota( - trackerKey, - limit, - windowMs, - burst, + private resolvePolicy( + options: RateLimitOptions | undefined, + tier: RateLimitTier, + ): ResolvedPolicy { + const envPolicy = getRateLimitPolicyFromEnv( + tier, + process.env as Record, ); - const decisionMs = Date.now() - startedAt; + if (!options) { + return { + tier, + label: tier, + ...envPolicy, + }; + } - this.metrics?.rateLimitHits.inc({ policy, user_tier: userTier, endpoint }); - this.metrics?.rateLimitCurrentUsage.set( - { - policy, - user_id: String(userId), - endpoint, - }, - Math.max(0, limit - result.remaining), - ); - this.metrics?.rateLimitResetTime.set( - { - policy, - user_id: String(userId), - endpoint, - }, - Date.now() + result.resetMs, + const configuredTier = options.level + ? normalizeRateLimitTier(options.level) + : tier; + const levelPolicy = getRateLimitPolicyFromEnv( + configuredTier, + process.env as Record, ); - if (!result.allowed) { - this.metrics?.rateLimitExceeded.inc({ - policy, - user_tier: userTier, - endpoint, - }); - this.metrics?.throttlingEvents.inc({ - severity: result.remaining <= 0 ? "high" : "medium", - policy, - user_tier: userTier, - }); - } + return { + tier: configuredTier, + label: options.level || configuredTier, + limit: options.limit ?? levelPolicy.limit, + windowMs: options.windowMs ?? levelPolicy.windowMs, + burst: options.burst ?? levelPolicy.burst, + }; + } - if (premiumAdjustment && premiumAdjustment.bonusApplied) { - this.metrics?.premiumTierUsage.inc({ - feature: premiumAdjustment.feature, - user_tier: String(userTier), - plan: policy, - }); - - this.metrics?.premiumBonusClaims.inc({ - bonus_type: - premiumAdjustment.activeBoostIds.length > 0 ? "boost" : "tier", - user_tier: String(userTier), - source: - premiumAdjustment.activeBoostIds.length > 0 - ? "manual_or_campaign" - : "tier_policy", - }); - - if (premiumAdjustment.componentMultipliers.referral > 0) { - this.metrics?.referralBonusUsage.inc({ - bonus_type: "rate_limit", - referrer_tier: String(userTier), - referee_tier: String(userTier), - }); - } + private resolveRequestTier(request: { + authType?: string; + user?: { id?: string | number; role?: string; tier?: string; type?: string }; + }): RateLimitTier { + const explicitTier = request.user?.tier; + const authType = request.authType ?? request.user?.type; + + if (authType === "api-key") { + return normalizeRateLimitTier(explicitTier ?? "enterprise"); } - this.dynamicScaling?.recordFeedback({ - context: { - key: trackerKey, - userId, - endpoint, - policy, - baseLimit, - baseWindowMs, - baseBurst, - }, - allowed: result.allowed, - remaining: result.remaining, - }); - - if (premiumAdjustment) { - this.premiumBonus?.recordUsage({ - userId, - userTier: String(userTier), - endpoint, - feature: premiumAdjustment.feature, - policy, - baseLimit: controlledLimit, - effectiveLimit: limit, - allowed: result.allowed, - remaining: result.remaining, - adjustment: premiumAdjustment, - }); + return resolveRateLimitTierFromRole( + request.user?.role, + authType, + explicitTier, + ); + } + + private getTrackerKey(request: { + ip?: string; + headers?: Record; + user?: { id?: string | number; sub?: string | number; address?: string }; + }): string { + const userId = request.user?.id ?? request.user?.sub; + if (userId !== undefined && userId !== null) { + return `user:${String(userId)}`; } - this.analytics?.recordRateLimitDecision({ - key: trackerKey, - userId: String(userId), - endpoint, - policy, - userTier: String(userTier), - allowed: result.allowed, - remaining: result.remaining, - limit, - resetMs: result.resetMs, - decisionMs, - }); + if (request.user?.address) { + return `wallet:${request.user.address.toLowerCase()}`; + } - const response = context.switchToHttp().getResponse(); + const xff = request.headers?.["x-forwarded-for"]; + if (typeof xff === "string" && xff.length > 0) { + return `ip:${xff.split(",")[0].trim()}`; + } - // Set headers - response.header("X-RateLimit-Limit", limit); - response.header("X-RateLimit-Remaining", result.remaining); - response.header( - "X-RateLimit-Reset", - new Date(Date.now() + result.resetMs).toISOString(), - ); + return `ip:${request.ip ?? "unknown"}`; + } - if (!result.allowed) { - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - message: "Rate limit exceeded", - retryAfterMs: result.resetMs, - }, - HttpStatus.TOO_MANY_REQUESTS, - ); + private getScope( + request: { route?: { path?: string }; originalUrl?: string; url?: string }, + options: RateLimitOptions | undefined, + ): string { + if (!options) { + return "global"; } - return true; + return request.route?.path || request.originalUrl || request.url || "route"; } - private getTrackerKey(req: any): string { - const userId = req.user?.id; - if (userId) { - return `user:${userId}`; + private consume( + key: string, + limit: number, + windowMs: number, + ): { allowed: boolean; remaining: number; resetAt: number } { + const now = Date.now(); + let state = this.windows.get(key); + + if (!state || state.resetAt <= now) { + state = { + count: 0, + resetAt: now + windowMs, + }; + this.windows.set(key, state); } - const xff = req.headers?.["x-forwarded-for"]; - const ip = typeof xff === "string" ? xff.split(",")[0].trim() : req.ip; + state.count += 1; + const remaining = Math.max(0, limit - state.count); + const allowed = state.count <= limit; + + this.windows.set(key, state); + this.cleanupExpired(now); - return `ip:${ip || "unknown"}`; + return { + allowed, + remaining, + resetAt: state.resetAt, + }; } -} + private applyHeaders( + response: any, + policy: ResolvedPolicy, + remaining: number, + resetAt: number, + ): void { + const headers: Array<[string, string | number]> = [ + ["X-RateLimit-Limit", policy.limit], + ["X-RateLimit-Remaining", remaining], + ["X-RateLimit-Reset", new Date(resetAt).toISOString()], + ["X-RateLimit-Tier", policy.tier], + ]; + + for (const [name, value] of headers) { + if (typeof response?.header === "function") { + response.header(name, value); + } else if (typeof response?.setHeader === "function") { + response.setHeader(name, value); + } + } + } + + private cleanupExpired(now: number): void { + if (this.windows.size === 0) { + return; + } + + if (now - this.lastCleanupAt < 30_000 && this.windows.size < 1000) { + return; + } + for (const [key, state] of this.windows.entries()) { + if (state.resetAt <= now) { + this.windows.delete(key); + } + } + this.lastCleanupAt = now; + } +} diff --git a/src/config/quota.config.ts b/src/config/quota.config.ts index 4ea8ecf..7af6d70 100644 --- a/src/config/quota.config.ts +++ b/src/config/quota.config.ts @@ -5,25 +5,129 @@ export interface QuotaConfig { burst: number; // Maximum burst size (capacity) } +export type RateLimitTier = "free" | "paid" | "enterprise"; + +export interface RateLimitTierConfig { + limit: number; + windowMs: number; + burst: number; +} + +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_BURST_MULTIPLIER = 1.2; + +function readEnvNumber(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function normalizeRateLimitTier(value?: string | null): RateLimitTier { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + + if ( + normalized === "paid" || + normalized === "standard" || + normalized === "premium" + ) { + return "paid"; + } + + if (normalized === "enterprise" || normalized === "internal") { + return "enterprise"; + } + + return "free"; +} + +export function getRateLimitPolicyFromEnv( + tier: RateLimitTier, + env: Record = process.env, +): RateLimitTierConfig { + const defaults: Record = { + free: 100, + paid: 1000, + enterprise: 10000, + }; + + const envKeyByTier: Record = { + free: "RATE_LIMIT_FREE_PER_MINUTE", + paid: "RATE_LIMIT_PAID_PER_MINUTE", + enterprise: "RATE_LIMIT_ENTERPRISE_PER_MINUTE", + }; + + const limit = readEnvNumber( + env[envKeyByTier[tier]], + defaults[tier], + ); + const windowMs = readEnvNumber( + env.RATE_LIMIT_WINDOW_MS, + DEFAULT_WINDOW_MS, + ); + const burstMultiplier = readEnvNumber( + env.RATE_LIMIT_BURST_MULTIPLIER, + DEFAULT_BURST_MULTIPLIER, + ); + + return { + limit, + windowMs, + burst: Math.max(limit, Math.ceil(limit * burstMultiplier)), + }; +} + +export function resolveRateLimitTierFromRole( + role?: string | null, + authType?: string | null, + explicitTier?: string | null, +): RateLimitTier { + if (explicitTier) { + return normalizeRateLimitTier(explicitTier); + } + + if (authType === "api-key") { + return "enterprise"; + } + + const normalizedRole = String(role ?? "") + .trim() + .toLowerCase(); + + if (normalizedRole === "admin") { + return "enterprise"; + } + + if ( + normalizedRole === "kyc_operator" || + normalizedRole === "operator" || + normalizedRole === "service" + ) { + return "paid"; + } + + return "free"; +} + export const QUOTA_LEVELS: Record = { free: { name: "Free Tier", - limit: 10, - windowMs: 60_000, // 10 requests per minute - burst: 15, - }, - standard: { - name: "Standard Tier", limit: 100, windowMs: 60_000, // 100 requests per minute burst: 120, }, - premium: { - name: "Premium Tier", + standard: { + name: "Standard Tier", limit: 1000, windowMs: 60_000, // 1000 requests per minute burst: 1200, }, + premium: { + name: "Premium Tier", + limit: 10000, + windowMs: 60_000, // 10000 requests per minute + burst: 12000, + }, internal: { name: "Internal Services", limit: 10000, @@ -34,5 +138,3 @@ export const QUOTA_LEVELS: Record = { export const DEFAULT_QUOTA = QUOTA_LEVELS.free; - - diff --git a/src/core/auth/auth.controller.ts b/src/core/auth/auth.controller.ts index 8ddffbd..7c8cce0 100644 --- a/src/core/auth/auth.controller.ts +++ b/src/core/auth/auth.controller.ts @@ -33,8 +33,10 @@ import { RequestRecoveryDto } from "./dto/request-recovery.dto"; import { LinkWalletDto } from "./dto/link-wallet.dto"; import { UnlinkWalletDto } from "./dto/unlink-wallet.dto"; import { RecoverWalletDto } from "./dto/recover-wallet.dto"; -import { Throttle } from "@nestjs/throttler"; -import { SensitiveRateLimit } from "src/common/decorators/rate-limit.decorator"; +import { + RateLimit, + SensitiveRateLimit, +} from "src/common/decorators/rate-limit.decorator"; import { Roles, Role } from "src/common/decorators/roles.decorator"; import { RolesGuard } from "src/common/guard/roles.guard"; import { Public } from "src/common/decorators/public.decorator"; @@ -68,7 +70,6 @@ export class VerifySignatureDto { // Auth endpoints are high-value targets — enforce strict per-user/IP limit: 5 req/min @SensitiveRateLimit("auth") @ApiTags("Authentication") -@Throttle({ default: { ttl: 60000, limit: 10 } }) @Controller("auth") export class AuthController { constructor( @@ -222,7 +223,7 @@ export class AuthController { // Wallet Management Endpoints - @Throttle({ default: { ttl: 60000, limit: 5 } }) + @RateLimit({ level: "auth", limit: 5, windowMs: 60000 }) @UseGuards(JwtAuthGuard) @Post("link-wallet") async linkWallet(@Request() req, @Body() dto: LinkWalletDto) { @@ -238,7 +239,7 @@ export class AuthController { ); } - @Throttle({ default: { ttl: 60000, limit: 5 } }) + @RateLimit({ level: "auth", limit: 5, windowMs: 60000 }) @UseGuards(JwtAuthGuard) @Post("unlink-wallet") async unlinkWallet(@Request() req, @Body() dto: UnlinkWalletDto) { @@ -267,7 +268,7 @@ export class AuthController { return this.walletAuthService.setPrimaryWallet(walletId, userId); } - @Throttle({ default: { ttl: 60000, limit: 3 } }) + @RateLimit({ level: "auth", limit: 3, windowMs: 60000 }) @Post("recover-wallet") async recoverWallet(@Body() dto: RecoverWalletDto) { return this.walletAuthService.recoverWallet(dto.email, dto.recoveryToken); @@ -275,7 +276,7 @@ export class AuthController { // Advanced Session Recovery Endpoints - @Throttle({ default: { ttl: 60000, limit: 3 } }) + @RateLimit({ level: "auth", limit: 3, windowMs: 60000 }) @Post("recovery/backup-code/initiate") async initiateBackupCodeRecovery( @Body() dto: { walletAddress: string; backupCode: string }, @@ -288,7 +289,7 @@ export class AuthController { ); } - @Throttle({ default: { ttl: 60000, limit: 3 } }) + @RateLimit({ level: "auth", limit: 3, windowMs: 60000 }) @Post("recovery/email/initiate") async initiateEmailRecovery(@Body() dto: { email: string }, @Request() req) { return this.sessionRecoveryService.initiateEmailRecovery(dto.email, { @@ -297,7 +298,7 @@ export class AuthController { }); } - @Throttle({ default: { ttl: 60000, limit: 5 } }) + @RateLimit({ level: "auth", limit: 5, windowMs: 60000 }) @Post("recovery/email/verify") async verifyEmailRecoveryCode( @Body() dto: { sessionId: string; code: string }, @@ -341,7 +342,7 @@ export class AuthController { // Delegation Endpoints @UseGuards(JwtAuthGuard) - @Throttle({ default: { ttl: 60000, limit: 5 } }) + @RateLimit({ level: "auth", limit: 5, windowMs: 60000 }) @Post("delegation/request") async requestDelegation( @Body() @@ -491,5 +492,3 @@ export class AuthController { } } - - diff --git a/src/core/auth/auth.service.ts b/src/core/auth/auth.service.ts index c1c38ca..c8cc26d 100644 --- a/src/core/auth/auth.service.ts +++ b/src/core/auth/auth.service.ts @@ -12,6 +12,7 @@ import { JwtService } from "@nestjs/jwt"; import { User } from "../user/entities/user.entity"; import { RegisterDto, LoginDto } from "./dto/auth.dto"; import { TokenBlacklistService } from "./token-blacklist.service"; +import { resolveRateLimitTierFromRole } from "src/config/quota.config"; @Injectable() export class AuthService { @@ -28,7 +29,7 @@ export class AuthService { */ async register( registerDto: RegisterDto, - ): Promise<{ token: string; user: Partial }> { + ): Promise<{ token: string; user: Partial & { tier?: string } }> { const { email, password, username, referralCode } = registerDto; // Check if user already exists @@ -93,6 +94,7 @@ export class AuthService { email: user.email, username: user.username, jti, + tier: resolveRateLimitTierFromRole(user.role), }; const token = this.jwtService.sign(payload, { expiresIn: "15m" }); @@ -103,6 +105,7 @@ export class AuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), referralCode: user.referralCode, }, }; @@ -114,7 +117,7 @@ export class AuthService { */ async login( loginDto: LoginDto, - ): Promise<{ token: string; user: Partial }> { + ): Promise<{ token: string; user: Partial & { tier?: string } }> { const { email, password } = loginDto; // Find user by email @@ -143,6 +146,7 @@ export class AuthService { email: user.email, username: user.username, jti, + tier: resolveRateLimitTierFromRole(user.role), }; const token = this.jwtService.sign(payload, { expiresIn: "15m" }); @@ -153,6 +157,7 @@ export class AuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), referralCode: user.referralCode, }, }; @@ -172,7 +177,10 @@ export class AuthService { async getAuthStatus( user: User, - ): Promise<{ isAuthenticated: boolean; user: Partial }> { + ): Promise<{ + isAuthenticated: boolean; + user: Partial & { tier?: string }; + }> { return { isAuthenticated: true, user: { @@ -180,11 +188,10 @@ export class AuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), referralCode: user.referralCode, }, }; } } - - diff --git a/src/core/auth/enhanced-auth.service.ts b/src/core/auth/enhanced-auth.service.ts index dfad1c6..bdf5faf 100644 --- a/src/core/auth/enhanced-auth.service.ts +++ b/src/core/auth/enhanced-auth.service.ts @@ -18,6 +18,7 @@ import * as speakeasy from "speakeasy"; import * as qrcode from "qrcode"; import { EmailService } from "./email.service"; import { User } from "src/core/user/entities/user.entity"; +import { resolveRateLimitTierFromRole } from "src/config/quota.config"; import { RefreshToken, TwoFactorAuth, @@ -62,7 +63,7 @@ export class EnhancedAuthService { ): Promise<{ accessToken: string; refreshToken: string; - user: Partial; + user: Partial & { tier?: string }; requiresTwoFactor?: boolean; }> { const { email, password, username, referralCode } = registerDto; @@ -124,6 +125,7 @@ export class EnhancedAuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), kycStatus: user.kycStatus, }, requiresTwoFactor: false, @@ -137,7 +139,7 @@ export class EnhancedAuthService { ): Promise<{ accessToken: string; refreshToken: string; - user: Partial; + user: Partial & { tier?: string }; requiresTwoFactor?: boolean; }> { const { email, password } = loginDto; @@ -187,6 +189,7 @@ export class EnhancedAuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), kycStatus: user.kycStatus, }, requiresTwoFactor: twoFactorEnabled, @@ -554,6 +557,7 @@ export class EnhancedAuthService { email: user.email, username: user.username, role: user.role, + tier: resolveRateLimitTierFromRole(user.role), twoFactorVerified, }; const accessToken = this.jwtService.sign(payload); @@ -648,5 +652,3 @@ export class EnhancedAuthService { } } - - diff --git a/src/core/auth/guards/strategy-auth.guard.ts b/src/core/auth/guards/strategy-auth.guard.ts index 065297f..ebf53ee 100644 --- a/src/core/auth/guards/strategy-auth.guard.ts +++ b/src/core/auth/guards/strategy-auth.guard.ts @@ -122,6 +122,7 @@ export class StrategyAuthGuard implements CanActivate { email?: string; username?: string; role: string; + tier?: string; roles: string[]; type: string; } { @@ -131,6 +132,7 @@ export class StrategyAuthGuard implements CanActivate { email: payload.email, username: payload.username, role: payload.role, + tier: payload.tier, roles: payload.roles || [payload.role], type: payload.type, }; @@ -138,4 +140,3 @@ export class StrategyAuthGuard implements CanActivate { } - diff --git a/src/core/auth/jwt.strategy.ts b/src/core/auth/jwt.strategy.ts index 4e06812..2d3a721 100644 --- a/src/core/auth/jwt.strategy.ts +++ b/src/core/auth/jwt.strategy.ts @@ -11,6 +11,7 @@ interface JwtPayload { email?: string; username?: string; role?: string; + tier?: string; jti?: string; // JWT ID for replay attack prevention twoFactorVerified?: boolean; // whether 2FA was completed for this session iat?: number; @@ -69,6 +70,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { email: payload.email, username: payload.username, role: payload.role || "user", + tier: payload.tier, jti: payload.jti, twoFactorVerified: payload.twoFactorVerified ?? false, exp: payload.exp, @@ -79,6 +81,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { address: payload.address, email: payload.email, role: payload.role || "user", + tier: payload.tier, roles: payload.role ? [payload.role] : ["user"], jti: payload.jti, twoFactorVerified: payload.twoFactorVerified ?? false, @@ -90,4 +93,3 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } - diff --git a/src/core/auth/strategies/api-key/api-key.strategy.ts b/src/core/auth/strategies/api-key/api-key.strategy.ts index 3650aa4..3f8a4f9 100644 --- a/src/core/auth/strategies/api-key/api-key.strategy.ts +++ b/src/core/auth/strategies/api-key/api-key.strategy.ts @@ -16,6 +16,10 @@ import { ApiKeyCredentials, } from "../interfaces/auth-strategy.interface"; import { User } from "src/core/user/entities/user.entity"; +import { + RateLimitTier, + resolveRateLimitTierFromRole, +} from "src/config/quota.config"; /** * API Key metadata @@ -24,6 +28,7 @@ interface ApiKeyMetadata { userId: string; name: string; permissions: string[]; + tier: RateLimitTier; createdAt: Date; expiresAt?: Date; lastUsedAt?: Date; @@ -60,12 +65,14 @@ export class ApiKeyStrategy implements AuthStrategy { userId: string; name: string; permissions: string[]; + tier?: RateLimitTier; }> = JSON.parse(systemApiKeys); - keys.forEach(({ key, userId, name, permissions }) => { + keys.forEach(({ key, userId, name, permissions, tier }) => { this.apiKeys.set(key, { userId, name, permissions, + tier: tier ?? "enterprise", createdAt: new Date(), }); }); @@ -119,6 +126,8 @@ export class ApiKeyStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "service", + tier: + keyMetadata.tier || resolveRateLimitTierFromRole(user.role, "api-key"), iat: Math.floor(Date.now() / 1000), type: "api-key", }; @@ -138,6 +147,9 @@ export class ApiKeyStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "service", + tier: + keyMetadata.tier || + resolveRateLimitTierFromRole(user.role, "api-key"), type: "api-key", }, }; @@ -179,6 +191,7 @@ export class ApiKeyStrategy implements AuthStrategy { name: string, permissions: string[] = ["read"], expiresInDays?: number, + tier: RateLimitTier = "enterprise", ): { key: string; secret: string } { const key = `sk_${crypto.randomBytes(24).toString("hex")}`; const secret = crypto.randomBytes(32).toString("hex"); @@ -187,6 +200,7 @@ export class ApiKeyStrategy implements AuthStrategy { userId, name, permissions, + tier, createdAt: new Date(), expiresAt: expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) @@ -228,6 +242,7 @@ export class ApiKeyStrategy implements AuthStrategy { id: `key_${index++}`, name: metadata.name, permissions: metadata.permissions, + tier: metadata.tier, createdAt: metadata.createdAt, expiresAt: metadata.expiresAt, lastUsedAt: metadata.lastUsedAt, @@ -254,4 +269,3 @@ export class ApiKeyStrategy implements AuthStrategy { } - diff --git a/src/core/auth/strategies/interfaces/auth-strategy.interface.ts b/src/core/auth/strategies/interfaces/auth-strategy.interface.ts index 0829ba6..2a64c3d 100644 --- a/src/core/auth/strategies/interfaces/auth-strategy.interface.ts +++ b/src/core/auth/strategies/interfaces/auth-strategy.interface.ts @@ -57,6 +57,9 @@ export interface AuthUser { /** User role */ role: string; + /** Rate-limit tier derived from the account or API key */ + tier?: string; + /** Authentication type */ type: AuthType; } @@ -80,6 +83,9 @@ export interface AuthPayload { /** User role */ role: string; + /** Rate-limit tier derived from the account or API key */ + tier?: string; + /** User roles array */ roles?: string[]; @@ -160,4 +166,3 @@ export interface ApiKeyCredentials { } - diff --git a/src/core/auth/strategies/oauth/oauth.strategy.ts b/src/core/auth/strategies/oauth/oauth.strategy.ts index 5ea4248..b0285db 100644 --- a/src/core/auth/strategies/oauth/oauth.strategy.ts +++ b/src/core/auth/strategies/oauth/oauth.strategy.ts @@ -15,6 +15,9 @@ import { OAuthCredentials, } from "../interfaces/auth-strategy.interface"; import { User } from "src/core/user/entities/user.entity"; +import { + resolveRateLimitTierFromRole, +} from "src/config/quota.config"; /** * OAuth provider configuration @@ -143,6 +146,7 @@ export class OAuthStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), iat: Math.floor(Date.now() / 1000), type: "oauth", }; @@ -160,6 +164,7 @@ export class OAuthStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), type: "oauth", }, }; @@ -281,4 +286,3 @@ export class OAuthStrategy implements AuthStrategy { } - diff --git a/src/core/auth/strategies/traditional/traditional.strategy.ts b/src/core/auth/strategies/traditional/traditional.strategy.ts index 093dff6..f88d60b 100644 --- a/src/core/auth/strategies/traditional/traditional.strategy.ts +++ b/src/core/auth/strategies/traditional/traditional.strategy.ts @@ -17,6 +17,9 @@ import { TraditionalCredentials, } from "../interfaces/auth-strategy.interface"; import { User } from "src/core/user/entities/user.entity"; +import { + resolveRateLimitTierFromRole, +} from "src/config/quota.config"; /** * Traditional email/password authentication strategy @@ -77,6 +80,7 @@ export class TraditionalStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), iat: Math.floor(Date.now() / 1000), type: "traditional", }; @@ -92,6 +96,7 @@ export class TraditionalStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), type: "traditional", }, }; @@ -144,6 +149,7 @@ export class TraditionalStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), iat: Math.floor(Date.now() / 1000), type: "traditional", }; @@ -159,6 +165,7 @@ export class TraditionalStrategy implements AuthStrategy { email: user.email, username: user.username, role: user.role || "user", + tier: resolveRateLimitTierFromRole(user.role), type: "traditional", }, }; @@ -180,4 +187,3 @@ export class TraditionalStrategy implements AuthStrategy { } - diff --git a/src/core/auth/strategies/wallet/wallet.strategy.ts b/src/core/auth/strategies/wallet/wallet.strategy.ts index 1d3d184..3b268e9 100644 --- a/src/core/auth/strategies/wallet/wallet.strategy.ts +++ b/src/core/auth/strategies/wallet/wallet.strategy.ts @@ -18,6 +18,9 @@ import { import { ChallengeService } from "src/core/auth/challenge.service"; import { User } from "src/core/user/entities/user.entity"; import { Wallet } from "src/core/auth/entities/wallet.entity"; +import { + resolveRateLimitTierFromRole, +} from "src/config/quota.config"; /** * Wallet-based authentication strategy @@ -98,6 +101,7 @@ export class WalletStrategy implements AuthStrategy { address: recoveredAddress.toLowerCase(), email: user?.emailVerified ? user.email : undefined, role: user?.role || "user", + tier: resolveRateLimitTierFromRole(user?.role), iat: Math.floor(Date.now() / 1000), type: "wallet", }; @@ -112,6 +116,7 @@ export class WalletStrategy implements AuthStrategy { address: recoveredAddress.toLowerCase(), email: user?.emailVerified ? user.email : undefined, role: user?.role || "user", + tier: resolveRateLimitTierFromRole(user?.role), type: "wallet", }, }; @@ -133,4 +138,3 @@ export class WalletStrategy implements AuthStrategy { } - diff --git a/src/core/auth/wallet-auth.service.ts b/src/core/auth/wallet-auth.service.ts index 8e19bca..9176bf8 100644 --- a/src/core/auth/wallet-auth.service.ts +++ b/src/core/auth/wallet-auth.service.ts @@ -22,11 +22,13 @@ import { ProvenanceAction, ProvenanceStatus, } from "src/infrastructure/audit/entities/provenance-record.entity"; +import { resolveRateLimitTierFromRole } from "src/config/quota.config"; export interface AuthPayload { address: string; email?: string; role?: string; + tier?: string; roles?: string[]; twoFactorVerified?: boolean; iat: number; @@ -124,6 +126,7 @@ export class WalletAuthService { address: normalized, email: user?.emailVerified ? user.email : undefined, role: user?.role || "user", + tier: resolveRateLimitTierFromRole(user?.role), iat: Math.floor(Date.now() / 1000), }; @@ -196,13 +199,14 @@ export class WalletAuthService { user: User | null, twoFactorVerified: boolean, ): string { - const payload: AuthPayload = { - address: address.toLowerCase(), - email: user?.emailVerified ? user.email : undefined, - role: user?.role || "user", - twoFactorVerified, - iat: Math.floor(Date.now() / 1000), - }; + const payload: AuthPayload = { + address: address.toLowerCase(), + email: user?.emailVerified ? user.email : undefined, + role: user?.role || "user", + tier: resolveRateLimitTierFromRole(user?.role), + twoFactorVerified, + iat: Math.floor(Date.now() / 1000), + }; return this.jwtService.sign(payload); } @@ -552,4 +556,3 @@ export class WalletAuthService { } - diff --git a/src/investment/portfolio/portfolio.controller.ts b/src/investment/portfolio/portfolio.controller.ts index 498e158..0ad73f0 100644 --- a/src/investment/portfolio/portfolio.controller.ts +++ b/src/investment/portfolio/portfolio.controller.ts @@ -20,7 +20,6 @@ import { ApiQuery, ApiOkResponse, } from "@nestjs/swagger"; -import { Throttle } from "@nestjs/throttler"; import { JwtAuthGuard } from "src/core/auth/jwt.guard"; import { PortfolioService } from "./services/portfolio.service"; import { RebalancingService } from "./services/rebalancing.service"; @@ -49,12 +48,13 @@ import { TimeRangeDto, } from "./dto/performance.dto"; import { CreateBacktestDto } from "./dto/backtest.dto"; +import { RateLimit } from "src/common/decorators/rate-limit.decorator"; @Controller("portfolio") @ApiTags("Portfolio Optimization") @ApiBearerAuth() @UseGuards(JwtAuthGuard) -@Throttle({ trading: { ttl: 60_000, limit: 20 } }) +@RateLimit({ level: "trading", limit: 20, windowMs: 60_000 }) export class PortfolioController { constructor( private portfolioService: PortfolioService, @@ -481,5 +481,3 @@ export class PortfolioController { } } - -