fix: apply consistent-type-imports across API codebase (728 lint errors)

- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
  saved searches, circuit breaker, rate limiting, and admin enhancements

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:22:21 +07:00
parent 8cdfe17205
commit 6ebacbc9bf
85 changed files with 3844 additions and 82 deletions

View File

@@ -8,10 +8,12 @@ describe('CacheService', () => {
set: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
getClient: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
let mockDegradationCounter: { inc: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRedis = {
@@ -22,16 +24,19 @@ describe('CacheService', () => {
scan: vi.fn().mockResolvedValue(['0', []]),
del: vi.fn(),
}),
isAvailable: vi.fn().mockReturnValue(true),
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
mockHitCounter = { inc: vi.fn() };
mockMissCounter = { inc: vi.fn() };
mockDegradationCounter = { inc: vi.fn() };
cacheService = new CacheService(
mockRedis as any,
mockLogger as any,
mockHitCounter as any,
mockMissCounter as any,
mockDegradationCounter as any,
);
});
@@ -91,6 +96,39 @@ describe('CacheService', () => {
await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
});
it('should skip Redis and call loader directly when Redis is unavailable', async () => {
mockRedis.isAvailable.mockReturnValue(false);
const data = { id: 'direct' };
const loader = vi.fn().mockResolvedValue(data);
const result = await cacheService.getOrSet('key', loader, 60, 'listing');
expect(result).toEqual(data);
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'listing', operation: 'skip_unavailable' });
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
});
it('should track degradation on cache read error', async () => {
mockRedis.get.mockRejectedValue(new Error('connection lost'));
const loader = vi.fn().mockResolvedValue({ ok: true });
await cacheService.getOrSet('key', loader, 60, 'search');
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'read_error' });
});
it('should track degradation on cache write error', async () => {
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockRejectedValue(new Error('write error'));
const loader = vi.fn().mockResolvedValue({ ok: true });
await cacheService.getOrSet('key', loader, 60, 'search');
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'write_error' });
});
});
describe('invalidate', () => {

View File

@@ -0,0 +1,139 @@
import { CircuitBreaker, CircuitOpenError, CircuitState } from '../circuit-breaker';
describe('CircuitBreaker', () => {
let breaker: CircuitBreaker;
let stateChanges: Array<{ from: CircuitState; to: CircuitState }>;
beforeEach(() => {
stateChanges = [];
breaker = new CircuitBreaker({
name: 'test-service',
failureThreshold: 3,
resetTimeMs: 100, // fast for tests
onStateChange: (from, to) => stateChanges.push({ from, to }),
});
});
it('starts in CLOSED state', () => {
expect(breaker.getState()).toBe(CircuitState.CLOSED);
expect(breaker.isAvailable()).toBe(true);
});
it('stays CLOSED when failures are below threshold', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
await breaker.exec(failingFn).catch(() => {});
await breaker.exec(failingFn).catch(() => {});
expect(breaker.getState()).toBe(CircuitState.CLOSED);
expect(breaker.isAvailable()).toBe(true);
});
it('opens after reaching failure threshold', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
expect(breaker.isAvailable()).toBe(false);
expect(stateChanges).toEqual([{ from: CircuitState.CLOSED, to: CircuitState.OPEN }]);
});
it('rejects calls immediately when OPEN', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
await expect(breaker.exec(() => Promise.resolve('ok'))).rejects.toThrow(CircuitOpenError);
});
it('transitions to HALF_OPEN after reset timeout', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Wait for the reset timeout
await new Promise((r) => setTimeout(r, 150));
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
expect(breaker.isAvailable()).toBe(true);
});
it('closes on successful probe in HALF_OPEN', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
await new Promise((r) => setTimeout(r, 150));
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
const result = await breaker.exec(() => Promise.resolve('recovered'));
expect(result).toBe('recovered');
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('re-opens on failed probe in HALF_OPEN', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
await new Promise((r) => setTimeout(r, 150));
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
await breaker.exec(failingFn).catch(() => {});
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('resets failure count on success', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
const succeedingFn = vi.fn().mockResolvedValue('ok');
// 2 failures, then success, then 2 more failures → should still be CLOSED
await breaker.exec(failingFn).catch(() => {});
await breaker.exec(failingFn).catch(() => {});
await breaker.exec(succeedingFn);
await breaker.exec(failingFn).catch(() => {});
await breaker.exec(failingFn).catch(() => {});
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('manual reset() brings breaker back to CLOSED', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
breaker.reset();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
expect(breaker.isAvailable()).toBe(true);
});
it('recordSuccess() transitions from OPEN to CLOSED after timeout', async () => {
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
for (let i = 0; i < 3; i++) {
await breaker.exec(failingFn).catch(() => {});
}
await new Promise((r) => setTimeout(r, 150));
breaker.recordSuccess();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('recordFailure() increments failures', () => {
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
});

View File

@@ -0,0 +1,241 @@
import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import {
UserRateLimitGuard,
DEFAULT_ROLE_LIMITS,
DEFAULT_WINDOW_SECONDS,
} from '../guards/user-rate-limit.guard';
// ── helpers ──────────────────────────────────────────────────────────────────
function mockRedis(overrides: Partial<{ evalResult: [number, number]; throwError: boolean }> = {}) {
const evalFn = overrides.throwError
? vi.fn().mockRejectedValue(new Error('Redis connection lost'))
: vi.fn().mockResolvedValue(overrides.evalResult ?? [1, 60]);
return {
getClient: vi.fn().mockReturnValue({ eval: evalFn }),
isAvailable: vi.fn().mockReturnValue(!overrides.throwError),
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
onModuleDestroy: vi.fn(),
} as any;
}
function mockLogger() {
return {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any;
}
function mockReflector(options?: any) {
return {
getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
} as unknown as Reflector;
}
interface MockContextOptions {
user?: { sub: string; role: string } | null;
handler?: string;
controller?: string;
}
function buildContext(opts: MockContextOptions = {}): ExecutionContext {
const headers: Record<string, string> = {};
const response = { setHeader: vi.fn() };
const user = 'user' in opts ? opts.user : { sub: 'user-123', role: 'BUYER' };
return {
switchToHttp: () => ({
getRequest: () => ({
user,
headers,
}),
getResponse: () => response,
}),
getHandler: () => ({ name: opts.handler ?? 'testHandler' }),
getClass: () => ({ name: opts.controller ?? 'TestController' }),
} as unknown as ExecutionContext;
}
// ── tests ────────────────────────────────────────────────────────────────────
describe('UserRateLimitGuard', () => {
it('allows request when user is within rate limit', async () => {
const redis = mockRedis({ evalResult: [1, 60] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(buildContext());
expect(result).toBe(true);
});
it('passes through for unauthenticated requests (no user)', async () => {
const redis = mockRedis();
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(buildContext({ user: null }));
expect(result).toBe(true);
});
it('passes through for requests without user.sub', async () => {
const redis = mockRedis();
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: '', role: 'BUYER' } }),
);
expect(result).toBe(true);
});
it('rejects request with 429 when rate limit exceeded', async () => {
const redis = mockRedis({ evalResult: [101, 45] }); // 101 > BUYER limit of 100
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext();
await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException);
try {
await guard.canActivate(ctx);
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
const body = (error as HttpException).getResponse();
expect(body).toMatchObject({
statusCode: 429,
retryAfter: 45,
});
}
});
it('sets rate limit headers on the response', async () => {
const redis = mockRedis({ evalResult: [50, 30] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext();
await guard.canActivate(ctx);
const response = ctx.switchToHttp().getResponse();
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', DEFAULT_ROLE_LIMITS.BUYER);
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 50); // 100 - 50
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 30);
});
it('sets Retry-After header when limit exceeded', async () => {
const redis = mockRedis({ evalResult: [201, 42] }); // Exceeds AGENT limit of 200
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const ctx = buildContext({ user: { sub: 'agent-1', role: 'AGENT' } });
try {
await guard.canActivate(ctx);
} catch {
// expected
}
const response = ctx.switchToHttp().getResponse();
expect(response.setHeader).toHaveBeenCalledWith('Retry-After', 42);
});
it('uses role-specific limits (ADMIN gets 500)', async () => {
const redis = mockRedis({ evalResult: [450, 20] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
);
expect(result).toBe(true); // 450 < 500 ADMIN limit
});
it('uses role-specific limits (AGENT gets 200)', async () => {
const redis = mockRedis({ evalResult: [199, 10] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
const result = await guard.canActivate(
buildContext({ user: { sub: 'agent-1', role: 'AGENT' } }),
);
expect(result).toBe(true); // 199 < 200 AGENT limit
});
it('uses per-route overrides from @UserRateLimit decorator', async () => {
const redis = mockRedis({ evalResult: [51, 30] });
// Override BUYER limit to 50 for this route
const reflector = mockReflector({ limits: { BUYER: 50 }, windowSeconds: 120 });
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
await expect(guard.canActivate(buildContext())).rejects.toThrow(HttpException);
});
it('passes Redis window seconds argument correctly', async () => {
const redis = mockRedis({ evalResult: [1, 120] });
const reflector = mockReflector({ windowSeconds: 120 });
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
await guard.canActivate(buildContext());
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[3]).toBe(120); // windowSeconds arg passed to Lua
});
it('uses correct Redis key format with user ID', async () => {
const redis = mockRedis({ evalResult: [1, 60] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
await guard.canActivate(buildContext({ user: { sub: 'user-xyz', role: 'BUYER' } }));
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[2]).toBe('rate_limit:user:user-xyz');
});
it('fails open when Redis throws an error', async () => {
const redis = mockRedis({ throwError: true });
const logger = mockLogger();
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
const result = await guard.canActivate(buildContext());
expect(result).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('User rate limit check failed'),
'UserRateLimitGuard',
);
});
it('logs warning when rate limit exceeded', async () => {
const redis = mockRedis({ evalResult: [101, 55] });
const logger = mockLogger();
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
try {
await guard.canActivate(buildContext());
} catch {
// expected
}
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('User rate limit exceeded'),
'UserRateLimitGuard',
);
});
it('uses DEFAULT_WINDOW_SECONDS when no override provided', async () => {
const redis = mockRedis({ evalResult: [1, DEFAULT_WINDOW_SECONDS] });
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
await guard.canActivate(buildContext());
const evalCall = redis.getClient().eval.mock.calls[0];
expect(evalCall[3]).toBe(DEFAULT_WINDOW_SECONDS);
});
it('defaults to BUYER limit for unknown roles', async () => {
const redis = mockRedis({ evalResult: [101, 30] }); // > BUYER's 100
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
// Force an unexpected role value
await expect(
guard.canActivate(buildContext({ user: { sub: 'u1', role: 'UNKNOWN_ROLE' } })),
).rejects.toThrow(HttpException);
});
});

View File

@@ -1,11 +1,15 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { type Counter } from 'prom-client';
import { type LoggerService } from './logger.service';
import { type RedisService } from './redis.service';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { Counter } from 'prom-client';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { LoggerService } from './logger.service';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { RedisService } from './redis.service';
export const CACHE_HIT_TOTAL = 'cache_hit_total';
export const CACHE_MISS_TOTAL = 'cache_miss_total';
export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_total';
export const CacheTTL = {
/** Listing detail — moderate TTL, invalidated on mutation */
@@ -52,6 +56,7 @@ export class CacheService implements OnModuleInit {
private readonly logger: LoggerService,
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
@InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter,
) {}
onModuleInit(): void {
@@ -60,6 +65,9 @@ export class CacheService implements OnModuleInit {
/**
* Cache-aside: get from cache, or execute loader and store result.
*
* When Redis is down the loader is called directly (graceful degradation).
* Degradation events are counted via `cache_degradation_total` for alerting.
*/
async getOrSet<T>(
key: string,
@@ -67,6 +75,13 @@ export class CacheService implements OnModuleInit {
ttlSeconds: number,
resource: string,
): Promise<T> {
// Fast-path: skip Redis entirely when it is known to be disconnected.
if (!this.redis.isAvailable()) {
this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
this.cacheMissCounter.inc({ resource });
return loader();
}
try {
const cached = await this.redis.get(key);
if (cached !== null) {
@@ -74,6 +89,7 @@ export class CacheService implements OnModuleInit {
return JSON.parse(cached) as T;
}
} catch (err) {
this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
}
@@ -83,6 +99,7 @@ export class CacheService implements OnModuleInit {
try {
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
} catch (err) {
this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
}
@@ -94,6 +111,7 @@ export class CacheService implements OnModuleInit {
try {
await this.redis.del(key);
} catch (err) {
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' });
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
}
}
@@ -111,6 +129,7 @@ export class CacheService implements OnModuleInit {
}
} while (cursor !== '0');
} catch (err) {
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' });
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
}
}

View File

@@ -0,0 +1,147 @@
/**
* Lightweight circuit breaker for external service calls.
*
* States:
* CLOSED → normal operation; failures increment counter
* OPEN → calls fail fast; after `resetTimeMs` → HALF_OPEN
* HALF_OPEN → one probe call allowed; success → CLOSED, failure → OPEN
*
* Does NOT depend on any NestJS or framework-specific API so it can
* be used as a plain utility across the codebase.
*/
export enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}
export interface CircuitBreakerOptions {
/** Human-readable service name — used in logs / metrics. */
name: string;
/** Number of consecutive failures before opening the circuit. Default 5. */
failureThreshold?: number;
/** Time in ms to wait before transitioning from OPEN → HALF_OPEN. Default 30 000 (30s). */
resetTimeMs?: number;
/** Optional callback fired on every state transition. */
onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
}
export class CircuitBreaker {
readonly name: string;
private state = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime = 0;
private readonly failureThreshold: number;
private readonly resetTimeMs: number;
private readonly onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.failureThreshold = options.failureThreshold ?? 5;
this.resetTimeMs = options.resetTimeMs ?? 30_000;
this.onStateChange = options.onStateChange;
}
/** Current state of the circuit. */
getState(): CircuitState {
this.evaluateState();
return this.state;
}
/** Whether the circuit allows a call to pass through. */
isAvailable(): boolean {
this.evaluateState();
return this.state !== CircuitState.OPEN;
}
/**
* Execute `fn` through the breaker.
*
* - CLOSED / HALF_OPEN → `fn` is called.
* - OPEN → immediately throws `CircuitOpenError`.
*
* On success the breaker resets to CLOSED.
* On failure the breaker increments the failure counter (CLOSED)
* or re-opens (HALF_OPEN).
*/
async exec<T>(fn: () => Promise<T>): Promise<T> {
this.evaluateState();
if (this.state === CircuitState.OPEN) {
throw new CircuitOpenError(this.name);
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
/** Record a manual success (e.g. from a health-check probe). */
recordSuccess(): void {
this.onSuccess();
}
/** Record a manual failure. */
recordFailure(): void {
this.onFailure();
}
/** Force-reset the breaker to CLOSED. */
reset(): void {
this.transitionTo(CircuitState.CLOSED);
this.failureCount = 0;
this.lastFailureTime = 0;
}
// -------------------------------------------------------------------------
private evaluateState(): void {
if (this.state === CircuitState.OPEN) {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed >= this.resetTimeMs) {
this.transitionTo(CircuitState.HALF_OPEN);
}
}
}
private onSuccess(): void {
if (this.state !== CircuitState.CLOSED) {
this.transitionTo(CircuitState.CLOSED);
}
this.failureCount = 0;
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === CircuitState.HALF_OPEN) {
this.transitionTo(CircuitState.OPEN);
} else if (this.failureCount >= this.failureThreshold) {
this.transitionTo(CircuitState.OPEN);
}
}
private transitionTo(next: CircuitState): void {
if (this.state === next) return;
const prev = this.state;
this.state = next;
this.onStateChange?.(prev, next, this.name);
}
}
/** Thrown when a call is attempted while the circuit is OPEN. */
export class CircuitOpenError extends Error {
constructor(public readonly serviceName: string) {
super(`Circuit breaker OPEN for service: ${serviceName}`);
this.name = 'CircuitOpenError';
}
}

View File

@@ -0,0 +1,21 @@
import { SetMetadata } from '@nestjs/common';
import { type UserRole } from '@prisma/client';
import { USER_RATE_LIMIT_KEY, type UserRateLimitOptions } from '../guards/user-rate-limit.guard';
/**
* Decorator to override per-user rate limits for a specific route or controller.
*
* When not applied, the UserRateLimitGuard uses DEFAULT_ROLE_LIMITS.
*
* @example
* // Override BUYER limit to 50 req/min for an expensive endpoint
* @UserRateLimit({ limits: { BUYER: 50 }, windowSeconds: 60 })
*
* @example
* // Allow higher limits for a specific route
* @UserRateLimit({ limits: { BUYER: 200, AGENT: 400 } })
*/
export const UserRateLimit = (options: {
limits?: Partial<Record<UserRole, number>>;
windowSeconds?: number;
}) => SetMetadata<string, UserRateLimitOptions>(USER_RATE_LIMIT_KEY, options);

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type EventEmitter2 } from '@nestjs/event-emitter';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
import { EventEmitter2 } from '@nestjs/event-emitter';
import { type DomainEvent } from '../domain/domain-event';
@Injectable()

View File

@@ -6,7 +6,7 @@ import {
HttpStatus,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Request, Response } from 'express';
import { type Request, type Response } from 'express';
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
import { ErrorCode } from '../../domain/error-codes';
import { type LoggerService } from '../logger.service';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import type { Request } from 'express';
import { type Request } from 'express';
/**
* Extends ThrottlerGuard to extract real client IP behind reverse proxies

View File

@@ -0,0 +1,143 @@
import {
Injectable,
type CanActivate,
type ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { type Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { type LoggerService } from '../logger.service';
import { type RedisService } from '../redis.service';
/**
* Role-based rate limits (requests per window).
* Applied only to authenticated routes that use JwtAuthGuard.
*/
export const DEFAULT_ROLE_LIMITS: Record<UserRole, number> = {
BUYER: 100,
SELLER: 150,
AGENT: 200,
ADMIN: 500,
};
/** Default sliding window in seconds. */
export const DEFAULT_WINDOW_SECONDS = 60;
/** Metadata key for per-route overrides via @UserRateLimit decorator. */
export const USER_RATE_LIMIT_KEY = 'user_rate_limit';
export interface UserRateLimitOptions {
/** Override limits per role for this route. Roles not listed use DEFAULT_ROLE_LIMITS. */
limits?: Partial<Record<UserRole, number>>;
/** Window in seconds (default 60). */
windowSeconds?: number;
}
/**
* Guard that enforces per-user (by user ID) rate limiting for authenticated routes.
*
* Uses a Redis sliding-window counter keyed by `rate_limit:user:{userId}`.
* Falls back to allowing the request if Redis is unavailable (fail-open)
* to avoid blocking legitimate traffic during cache outages.
*
* This guard checks `request.user` — it must run AFTER JwtAuthGuard.
* If no authenticated user is found, the guard passes (unauthenticated
* routes are handled by the existing IP-based ThrottlerBehindProxyGuard).
*/
@Injectable()
export class UserRateLimitGuard implements CanActivate {
constructor(
private readonly redis: RedisService,
private readonly reflector: Reflector,
private readonly logger: LoggerService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Skip for unauthenticated requests — IP-based throttler handles those
if (!user?.sub || !user?.role) {
return true;
}
const userId: string = user.sub;
const role: UserRole = user.role;
// Resolve per-route overrides
const options = this.reflector.getAllAndOverride<UserRateLimitOptions | undefined>(
USER_RATE_LIMIT_KEY,
[context.getHandler(), context.getClass()],
);
const windowSeconds = options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
const limit = options?.limits?.[role] ?? DEFAULT_ROLE_LIMITS[role] ?? DEFAULT_ROLE_LIMITS.BUYER;
const key = `rate_limit:user:${userId}`;
try {
const client = this.redis.getClient();
// Atomic increment + conditional TTL set via Lua script.
// Uses INCR + conditional EXPIRE to avoid race conditions.
const result = await client.eval(
`local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}`,
1,
key,
windowSeconds,
) as [number, number];
const current = result[0];
const ttl = result[1];
const response = context.switchToHttp().getResponse();
// Always set rate limit headers for observability
response.setHeader('X-RateLimit-Limit', limit);
response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - current));
response.setHeader('X-RateLimit-Reset', ttl > 0 ? ttl : windowSeconds);
if (current > limit) {
const retryAfter = ttl > 0 ? ttl : windowSeconds;
response.setHeader('Retry-After', retryAfter);
this.logger.warn(
`User rate limit exceeded: userId=${userId}, role=${role}, ` +
`current=${current}/${limit}, retryAfter=${retryAfter}s`,
'UserRateLimitGuard',
);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests. Please try again later.',
retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
return true;
} catch (error) {
// Re-throw 429 errors — they are intentional
if (error instanceof HttpException && error.getStatus() === HttpStatus.TOO_MANY_REQUESTS) {
throw error;
}
// Fail open on Redis errors — log and allow the request
this.logger.warn(
`User rate limit check failed (Redis error), allowing request: userId=${userId}, error=${
error instanceof Error ? error.message : 'unknown'
}`,
'UserRateLimitGuard',
);
return true;
}
}
}

View File

@@ -1,4 +1,5 @@
export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator';
export { CircuitBreaker, CircuitOpenError, CircuitState, type CircuitBreakerOptions } from './circuit-breaker';
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
@@ -11,6 +12,14 @@ export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'
export { CsrfMiddleware } from './middleware/csrf.middleware';
export { maskPii } from './pii-masker';
export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard';
export {
UserRateLimitGuard,
DEFAULT_ROLE_LIMITS,
DEFAULT_WINDOW_SECONDS,
USER_RATE_LIMIT_KEY,
type UserRateLimitOptions,
} from './guards/user-rate-limit.guard';
export { UserRateLimit } from './decorators/user-rate-limit.decorator';
export { FileValidationPipe } from './pipes/file-validation.pipe';
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
export { validateEnv, validateJwtSecret } from './env-validation';

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Injectable, type NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { type NextFunction, type Request, type Response } from 'express';
const CORRELATION_ID_HEADER = 'x-correlation-id';

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto';
import { ForbiddenException, Injectable, type NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { type NextFunction, type Request, type Response } from 'express';
const CSRF_COOKIE = 'XSRF-TOKEN';
const CSRF_HEADER = 'x-csrf-token';

View File

@@ -1,5 +1,5 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { type NextFunction, type Request, type Response } from 'express';
import { type LoggerService } from '../logger.service';
@Injectable()

View File

@@ -1,5 +1,5 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { type NextFunction, type Request, type Response } from 'express';
import sanitizeHtml from 'sanitize-html';
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {

View File

@@ -1,6 +1,26 @@
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
/**
* Thin wrapper around ioredis.
*
* Uses `lazyConnect: true` so the app starts even if Redis is unreachable.
* All call-sites (CacheService, health checks) already handle failures
* gracefully — if Redis is down the app serves data directly from the DB.
*
* `enableReadyCheck: false` prevents ioredis from throwing
* "Redis is not ready" errors during transient outages, allowing
* individual commands to fail with a standard connection error
* that the CacheService catches.
*
* `maxRetriesPerRequest: 1` ensures commands fail fast (single retry)
* instead of blocking the event loop with exponential backoff.
* The CacheService already treats Redis errors as non-fatal.
*
* `retryStrategy` implements bounded reconnection: waits 1 s then 2 s
* then 3 s up to 5 s max, ensuring the client keeps trying to reconnect
* without flooding the server.
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
@@ -11,6 +31,11 @@ export class RedisService implements OnModuleDestroy {
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
lazyConnect: true,
enableReadyCheck: false,
maxRetriesPerRequest: 1,
retryStrategy(times: number): number {
return Math.min(times * 1000, 5000);
},
});
}
@@ -22,6 +47,14 @@ export class RedisService implements OnModuleDestroy {
return this.client;
}
/**
* Quick health probe — returns true when the Redis connection is
* in a usable state (`ready` or `connect`).
*/
isAvailable(): boolean {
return this.client.status === 'ready' || this.client.status === 'connect';
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}

View File

@@ -1,8 +1,14 @@
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
import { CacheService, CACHE_HIT_TOTAL, CACHE_MISS_TOTAL } from './infrastructure/cache.service';
import { PrometheusModule, makeCounterProvider } from '@willsoto/nestjs-prometheus';
import {
CacheService,
CACHE_HIT_TOTAL,
CACHE_MISS_TOTAL,
CACHE_DEGRADATION_TOTAL,
} from './infrastructure/cache.service';
import { EventBusService } from './infrastructure/event-bus.service';
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
import { LoggerService } from './infrastructure/logger.service';
@@ -15,7 +21,11 @@ import { RedisService } from './infrastructure/redis.service';
@Global()
@Module({
imports: [EventEmitterModule.forRoot()],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
PrometheusModule.register({ path: '/metrics', defaultMetrics: { enabled: true } }),
],
providers: [
PrismaService,
RedisService,
@@ -32,12 +42,17 @@ import { RedisService } from './infrastructure/redis.service';
help: 'Total number of cache misses',
labelNames: ['resource'],
}),
makeCounterProvider({
name: CACHE_DEGRADATION_TOTAL,
help: 'Total number of cache degradation events',
labelNames: ['resource', 'operation'],
}),
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, PrometheusModule],
})
export class SharedModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {