Skip to content
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,15 @@ STORAGE_SECRET_KEY=astroid-secret
QUEUE_PREFIX=astroid
QUEUE_CONCURRENCY=5

# Rate limiting
# Rate limiting (per-tier steady-state limits, per TTL window)
THROTTLE_AUTH_LIMIT=10
THROTTLE_API_LIMIT=120
THROTTLE_WEBHOOK_LIMIT=30
THROTTLE_TTL=60
# Burst limits — short-term spike allowance per tier (requests per second)
THROTTLE_API_BURST=10
THROTTLE_AUTH_BURST=3
THROTTLE_WEBHOOK_BURST=5

# Redis-backed sliding-window rate limiter guard (SlidingWindowThrottlerGuard)
# Applied to public-facing agent/transaction submission endpoints. Per-route
Expand Down
11 changes: 7 additions & 4 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor
: { target: 'pino-pretty', options: { singleLine: true } },
},
}),
// Two rate-limit tiers, both driven by THROTTLE_* env vars. Every route is
// subject to both named throttlers, but AstroidThrottlerGuard enforces only
// the one matching the route's @ThrottleTierDecorator tier ('api' default,
// 'auth' for the sensitive auth endpoints).
// Three rate-limit tiers, all driven by THROTTLE_* env vars. Every route is
// subject to all named throttlers, but AstroidThrottlerGuard enforces only
// the one matching the route's @ThrottleTierDecorator tier:
// 'api' (default) — general API traffic
// 'auth' — sensitive auth endpoints (login, register, passkey)
// 'webhook' — webhook delivery callbacks
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
Expand All @@ -95,6 +97,7 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor
return [
{ name: 'api', ttl, limit: throttle.apiLimit },
{ name: 'auth', ttl, limit: throttle.authLimit },
{ name: 'webhook', ttl, limit: throttle.webhookLimit },
];
},
}),
Expand Down
8 changes: 6 additions & 2 deletions src/common/decorators/throttle-tier.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { SetMetadata } from '@nestjs/common';

export const THROTTLE_TIER_KEY = 'astroid:throttleTier';

export type ThrottleTier = 'auth' | 'api';
export type ThrottleTier = 'auth' | 'api' | 'webhook';

/**
* Selects the rate-limit tier for a route. `auth` = 10/min, `api` = 120/min.
* Selects the rate-limit tier for a route:
* - `auth` = sensitive auth endpoints (login, register, passkey)
* - `api` = general API traffic (default)
* - `webhook` = webhook delivery callbacks (stricter)
*
* Defaults to `api` when unset. Consumed by the AstroidThrottlerGuard.
*/
export const ThrottleTierDecorator = (tier: ThrottleTier) =>
Expand Down
154 changes: 154 additions & 0 deletions src/common/guards/throttler.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ConfigService } from '@nestjs/config';
import { AstroidThrottlerGuard } from './throttler.guard';

function createGuard(): AstroidThrottlerGuard {
const config = {
getOrThrow: () => ({
throttle: {
apiLimit: 120,
authLimit: 10,
webhookLimit: 30,
ttl: 60,
apiBurst: 10,
authBurst: 3,
webhookBurst: 5,
},
}),
};
const guard = new AstroidThrottlerGuard(config as unknown as ConfigService);
// Inject mock reflector
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as any).reflector = {
getAllAndOverride: vi.fn().mockReturnValue(undefined),
};
return guard;
}

/** Call the protected handleRequest via prototype access. */
async function callHandleRequest(guard: AstroidThrottlerGuard, requestProps: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (guard as any).handleRequest(requestProps);
}

function mockRequestProps(throttlerName: string, overrides?: { setHeader?: ReturnType<typeof vi.fn> }) {
const setHeader = overrides?.setHeader ?? vi.fn();
return {
context: {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { organizationId: 'org-1' }, ip: '127.0.0.1', headers: {} }),
getResponse: () => ({ setHeader }),
}),
},
throttler: { name: throttlerName, ttl: 60000, limit: 120 },
limit: 120,
ttl: 60000,
key: `org:org-1`,
};
}

describe('AstroidThrottlerGuard', () => {
let guard: AstroidThrottlerGuard;

beforeEach(() => {
guard = createGuard();
// Default reflector to return 'api' tier for all routes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as any).reflector = {
getAllAndOverride: vi.fn().mockReturnValue('api'),
};
});

it('allows requests when the throttler name matches the route tier', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true);

const result = await callHandleRequest(guard, mockRequestProps('api'));
expect(result).toBe(true);
});

it('skips counting when throttler name does not match route tier', async () => {
// Reflects 'api' tier but throttler name is 'auth' → should skip
const result = await callHandleRequest(guard, mockRequestProps('auth'));
expect(result).toBe(true);
});

it('defaults to api tier when reflector returns undefined', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as any).reflector = {
getAllAndOverride: vi.fn().mockReturnValue(undefined),
};

const result = await callHandleRequest(guard, mockRequestProps('api'));
expect(result).toBe(true);
});

it('sets X-RateLimit-Limit header on allowed requests', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true);

const setHeader = vi.fn();
const result = await callHandleRequest(guard, mockRequestProps('api', { setHeader }));
expect(result).toBe(true);
expect(setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 120);
expect(setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(Number));
});

it('sets Retry-After header when parent guard rejects', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(false);

const setHeader = vi.fn();
await callHandleRequest(guard, mockRequestProps('api', { setHeader }));
expect(setHeader).toHaveBeenCalledWith('Retry-After', expect.any(Number));
});

describe('burst limiting', () => {
it('allows the first request in a burst window', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as any).reflector = {
getAllAndOverride: vi.fn().mockReturnValue('auth'),
};

const result = await callHandleRequest(guard, mockRequestProps('auth'));
expect(result).toBe(true);
});

it('rejects requests exceeding the burst limit within 1 second', async () => {
vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'handleRequest').mockResolvedValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as any).reflector = {
getAllAndOverride: vi.fn().mockReturnValue('auth'),
};

// Auth burst limit is 3 — send 4 requests rapidly
for (let i = 0; i < 3; i++) {
await callHandleRequest(guard, mockRequestProps('auth'));
}
// 4th request should be burst-exceeded
const result = await callHandleRequest(guard, mockRequestProps('auth'));
expect(result).toBe(false);
});
});

describe('getTracker', () => {
it('returns org-scoped tracker when user is authenticated', async () => {
const req = { user: { organizationId: 'org-42' }, ip: '10.0.0.1', headers: {} };
const tracker = await guard['getTracker'](req);
expect(tracker).toBe('org:org-42');
});

it('falls back to IP tracker for anonymous requests', async () => {
const req = { ip: '192.168.1.1', headers: {} };
const tracker = await guard['getTracker'](req);
expect(tracker).toBe('ip:192.168.1.1');
});

it('uses x-forwarded-for header when available', async () => {
const req = { ip: '127.0.0.1', headers: { 'x-forwarded-for': '203.0.113.50' } };
const tracker = await guard['getTracker'](req);
expect(tracker).toBe('ip:203.0.113.50');
});
});
});
120 changes: 110 additions & 10 deletions src/common/guards/throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerRequest } from '@nestjs/throttler';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import {
THROTTLE_TIER_KEY,
ThrottleTier,
} from '../decorators/throttle-tier.decorator';
import { QueueConfig } from '../../config/queue.config';

/** Per-tier burst defaults (requests per second). */
const BURST_DEFAULTS: Record<ThrottleTier, number> = {
api: 10,
auth: 3,
webhook: 5,
};

/**
* Rate-limit guard with two tiers. Every route is evaluated against both named
* throttlers ('api' = 120/min, 'auth' = 10/min by default), but each throttler
* only counts a request when its name matches the route's tier — so the auth
* endpoints (marked `@ThrottleTierDecorator('auth')`) get the stricter limit
* while everything else falls back to the `api` tier.
* Rate-limit guard with three tiers — api, auth, and webhook.
*
* Every route is evaluated against all named throttlers, but each throttler
* only counts a request when its name matches the route's tier. The tier is
* selected via @ThrottleTierDecorator; routes without an explicit tier
* default to `api`.
*
* Burst limiting: each tier has a per-second burst ceiling (burstLimit).
* If the request rate exceeds the burst ceiling within any 1-second window,
* the request is rejected immediately — regardless of the per-minute steady
* state limit.
*
* Response headers:
* X-RateLimit-Limit — steady-state limit for the matched tier
* X-RateLimit-Remaining — remaining requests in the current TTL window
* X-RateLimit-Reset — UTC epoch seconds when the window resets
* Retry-After — seconds until the next request is allowed (only on 429)
*
* The counter is scoped to the authenticated organization, falling back to the
* client IP for anonymous auth endpoints.
* The counter is scoped to the authenticated organization, falling back to
* the client IP for anonymous/auth endpoints.
*/
@Injectable()
export class AstroidThrottlerGuard extends ThrottlerGuard {
/** Per-second burst tracking: keyed by "tier:scope". */
private readonly burstWindows = new Map<string, { count: number; resetAt: number }>();

/** Burst limits resolved from config at first request. */
private burstLimits: Record<string, number> | null = null;

constructor(
private readonly cfg: ConfigService,
) {
// ThrottlerGuard's constructor is injected by NestJS; we pass through.
// The `cfg` param is used only for burst limits; the parent handles the rest.
super(undefined as never, undefined as never, undefined as never);
}

/**
* Enforce a named throttler only when it matches the route's declared tier.
* Routes without an explicit tier default to `api`.
* Also enforces burst limits and sets rate-limit response headers.
*/
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context, throttler } = requestProps;
Expand All @@ -36,7 +71,33 @@ export class AstroidThrottlerGuard extends ThrottlerGuard {
return true;
}

return super.handleRequest(requestProps);
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>();
const response = context.switchToHttp().getResponse<Response>();

// ── Burst check ────────────────────────────────────────────────────────
const burstKey = this.burstKey(request, routeTier);
if (this.isBurstExceeded(routeTier, burstKey)) {
response.setHeader('Retry-After', 1);
return false;
}

// ── Steady-state check ─────────────────────────────────────────────────
const result = await super.handleRequest(requestProps);

// ── Response headers ───────────────────────────────────────────────────
// Resolvable<T> = T | (() => T | Promise<T>); resolve to a plain number.
const resolve = (v: unknown): number => typeof v === 'function' ? Number(v(context)) : Number(v);
const limit = resolve(throttler.limit);
const ttl = resolve(throttler.ttl);

response.setHeader('X-RateLimit-Limit', limit);
response.setHeader('X-RateLimit-Reset', Math.ceil((Date.now() + ttl) / 1000));

if (!result) {
response.setHeader('Retry-After', Math.ceil(ttl / 1000));
}

return result;
}

protected async getTracker(req: Record<string, unknown>): Promise<string> {
Expand All @@ -53,4 +114,43 @@ export class AstroidThrottlerGuard extends ThrottlerGuard {
'anonymous';
return `ip:${ip}`;
}

// ── Burst internals ────────────────────────────────────────────────────

private burstKey(request: Request & { user?: AuthenticatedUser }, tier: ThrottleTier): string {
const org = request.user?.organizationId;
const scope = org ? `org:${org}` : `ip:${request.ip ?? 'anonymous'}`;
return `${tier}:${scope}`;
}

/**
* Simple fixed-window burst limiter: tracks the number of requests in the
* current 1-second window. Returns true when the burst ceiling is hit.
*/
private isBurstExceeded(tier: ThrottleTier, key: string): boolean {
const now = Date.now();
const burstLimit = this.getBurstLimit(tier);
const window = this.burstWindows.get(key);

if (!window || now > window.resetAt) {
// New 1-second window
this.burstWindows.set(key, { count: 1, resetAt: now + 1000 });
return false;
}

window.count++;
return window.count > burstLimit;
}

private getBurstLimit(tier: ThrottleTier): number {
if (!this.burstLimits) {
const throttle = this.cfg.getOrThrow<QueueConfig>('queue').throttle;
this.burstLimits = {
api: throttle.apiBurst,
auth: throttle.authBurst,
webhook: throttle.webhookBurst,
};
}
return this.burstLimits[tier] ?? BURST_DEFAULTS[tier];
}
}
5 changes: 5 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export const queueEnvSchema = z.object({
export const throttleEnvSchema = z.object({
THROTTLE_AUTH_LIMIT: z.coerce.number().int().positive().default(10),
THROTTLE_API_LIMIT: z.coerce.number().int().positive().default(120),
THROTTLE_WEBHOOK_LIMIT: z.coerce.number().int().positive().default(30),
THROTTLE_TTL: z.coerce.number().int().positive().default(60),
// Burst limits — short-term spike allowance per tier (requests per second).
THROTTLE_API_BURST: z.coerce.number().int().positive().default(10),
THROTTLE_AUTH_BURST: z.coerce.number().int().positive().default(3),
THROTTLE_WEBHOOK_BURST: z.coerce.number().int().positive().default(5),
});

export const rateLimitEnvSchema = z.object({
Expand Down
Loading
Loading