From 5d9de6887f0bc17e98e705e4b16687f6ed4acefe Mon Sep 17 00:00:00 2001 From: Derry255 Date: Sun, 28 Jun 2026 04:02:47 +0100 Subject: [PATCH] feat: add rate-limit pre-check endpoint - Add peek() method to InMemoryRestRateLimiter (no token consumption) - Create GET /api/limits/check endpoint returning ok|deny with reason - Scope auth to caller via requireAuth - Add 1-second per-key cache for efficiency - Add comprehensive tests for peek() and the endpoint --- src/app.ts | 13 +- src/middleware/restRateLimit.test.ts | 61 ++++++++- src/middleware/restRateLimit.ts | 17 +++ src/routes/index.ts | 7 + src/routes/limits.test.ts | 190 +++++++++++++++++++++++++++ src/routes/limits.ts | 96 ++++++++++++++ 6 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 src/routes/limits.test.ts create mode 100644 src/routes/limits.ts diff --git a/src/app.ts b/src/app.ts index f9cd89f..a7757d8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,7 +38,8 @@ import { TransactionBuilderService } from './services/transactionBuilder.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { validate } from './middleware/validate.js'; import { requestLogger } from './middleware/logging.js'; -import { createConfiguredRestRateLimitMiddleware } from './middleware/restRateLimit.js'; +import { InMemoryRestRateLimiter, createRestRateLimitMiddleware } from './middleware/restRateLimit.js'; +import type { RestRateLimitOptions } from './middleware/restRateLimit.js'; import { metricsMiddleware, metricsEndpoint } from './metrics.js'; import { config } from './config/index.js'; import { validateUpstreamBaseUrl } from './lib/upstreamTarget.js'; @@ -86,7 +87,12 @@ const vaultBalanceQuerySchema = z.object({ export const createApp = (dependencies?: Partial) => { const app = express(); - const restRateLimit = createConfiguredRestRateLimitMiddleware(); + const restRateLimitOptions: RestRateLimitOptions = { + windowMs: config.restRateLimit.windowMs, + maxRequests: config.restRateLimit.maxRequests, + }; + const restRateLimiter = new InMemoryRestRateLimiter(restRateLimitOptions.windowMs, restRateLimitOptions.maxRequests); + const restRateLimit = createRestRateLimitMiddleware(restRateLimitOptions, restRateLimiter); // Set database pool in locals for billing routes app.locals.dbPool = pool; @@ -263,9 +269,10 @@ export const createApp = (dependencies?: Partial) => { }), ); - // Mount all routes including billing + // Mount all routes including billing and limits app.use('/api', createApiRouter({ restRateLimit, + restRateLimiter, usageEventsRepository, apiRepository, developerRepository diff --git a/src/middleware/restRateLimit.test.ts b/src/middleware/restRateLimit.test.ts index cb56451..07e31f5 100644 --- a/src/middleware/restRateLimit.test.ts +++ b/src/middleware/restRateLimit.test.ts @@ -1,7 +1,7 @@ import express from 'express'; import request from 'supertest'; import { errorHandler } from './errorHandler.js'; -import { createRestRateLimitMiddleware } from './restRateLimit.js'; +import { InMemoryRestRateLimiter, createRestRateLimitMiddleware } from './restRateLimit.js'; import { requireAuth, type AuthenticatedLocals } from './requireAuth.js'; import { TEST_JWT_SECRET, signTestToken } from '../../tests/helpers/jwt.js'; @@ -111,3 +111,62 @@ describe('restRateLimit middleware', () => { expect(retryAfterMs).toBeGreaterThan(0); }); }); + +describe('InMemoryRestRateLimiter.peek', () => { + let now: number; + + beforeEach(() => { + now = 100_000; + }); + + test('returns allowed=true when no bucket exists (would create on check)', () => { + const limiter = new InMemoryRestRateLimiter(1000, 5); + expect(limiter.peek('new-key', now)).toEqual({ allowed: true }); + }); + + test('returns allowed=true when bucket is expired', () => { + const limiter = new InMemoryRestRateLimiter(1000, 5); + limiter.check('key', now); + expect(limiter.peek('key', now + 2000)).toEqual({ allowed: true }); + }); + + test('returns allowed=true when count is under the limit', () => { + const limiter = new InMemoryRestRateLimiter(1000, 5); + limiter.check('key', now); + limiter.check('key', now); + expect(limiter.peek('key', now)).toEqual({ allowed: true }); + }); + + test('returns allowed=false with retryAfterMs when limit is exceeded', () => { + const limiter = new InMemoryRestRateLimiter(1000, 2); + limiter.check('key', now); + limiter.check('key', now); + const peekResult = limiter.peek('key', now); + expect(peekResult).toEqual({ allowed: false, retryAfterMs: 1000 }); + }); + + test('does NOT consume a token (peek is idempotent)', () => { + const limiter = new InMemoryRestRateLimiter(1000, 2); + limiter.check('key', now); + limiter.check('key', now); + + // Peek should return deny + expect(limiter.peek('key', now)).toEqual({ allowed: false, retryAfterMs: 1000 }); + // Additional peeks should still return deny (not consuming tokens) + expect(limiter.peek('key', now)).toEqual({ allowed: false, retryAfterMs: 1000 }); + expect(limiter.peek('key', now)).toEqual({ allowed: false, retryAfterMs: 1000 }); + + // check should still also deny (tokens not consumed by peek) + expect(limiter.check('key', now)).toEqual({ allowed: false, retryAfterMs: 1000 }); + }); + + test('returns accurate retryAfterMs as window elapses', () => { + const limiter = new InMemoryRestRateLimiter(1000, 1); + limiter.check('elapsing-key', now); + + expect(limiter.peek('elapsing-key', now + 250)).toEqual({ allowed: false, retryAfterMs: 750 }); + expect(limiter.peek('elapsing-key', now + 500)).toEqual({ allowed: false, retryAfterMs: 500 }); + expect(limiter.peek('elapsing-key', now + 999)).toEqual({ allowed: false, retryAfterMs: 1 }); + expect(limiter.peek('elapsing-key', now + 1000)).toEqual({ allowed: true }); + }); +}); diff --git a/src/middleware/restRateLimit.ts b/src/middleware/restRateLimit.ts index 18ffbc4..0ba94e9 100644 --- a/src/middleware/restRateLimit.ts +++ b/src/middleware/restRateLimit.ts @@ -48,6 +48,23 @@ export class InMemoryRestRateLimiter { return { allowed: true }; } + peek(key: string, now = Date.now()): RateLimitCheckResult { + const bucket = this.buckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + return { allowed: true }; + } + + if (bucket.count >= this.maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(bucket.resetAt - now, 0), + }; + } + + return { allowed: true }; + } + reset(): void { this.buckets.clear(); } diff --git a/src/routes/index.ts b/src/routes/index.ts index 3f7b113..ca4d2d8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,12 +7,15 @@ import billingRouter from './billing.js'; import healthRouter from './health.js'; import { createApisRouter, type ApisRouterDeps } from './apis.js'; import { createUsageRouter, type UsageRouterDeps } from './usage.js'; +import { createLimitsRouter } from './limits.js'; +import { InMemoryRestRateLimiter } from '../middleware/restRateLimit.js'; const openApiPath = path.join(process.cwd(), 'docs/openapi.json'); const openApiSpec = JSON.parse(readFileSync(openApiPath, 'utf8')); export interface ApiRouterDeps extends Partial, Partial { restRateLimit?: RequestHandler; + restRateLimiter?: InMemoryRestRateLimiter; } export function createApiRouter(deps: ApiRouterDeps = {}): Router { @@ -35,6 +38,10 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { router.use('/billing', billingRouter); } + if (deps.restRateLimiter) { + router.use('/limits', createLimitsRouter(deps.restRateLimiter).router); + } + // Serve OpenAPI 3.1 JSON contract router.get('/openapi.json', (_req, res) => { res.setHeader('Content-Type', 'application/json'); diff --git a/src/routes/limits.test.ts b/src/routes/limits.test.ts new file mode 100644 index 0000000..b0ceccd --- /dev/null +++ b/src/routes/limits.test.ts @@ -0,0 +1,190 @@ +import express from 'express'; +import request from 'supertest'; +import { errorHandler } from '../middleware/errorHandler.js'; +import { InMemoryRestRateLimiter } from '../middleware/restRateLimit.js'; +import { TEST_JWT_SECRET, signTestToken } from '../../tests/helpers/jwt.js'; +import { createLimitsRouter } from './limits.js'; + +function buildApp(rateLimiter?: InMemoryRestRateLimiter) { + const app = express(); + app.use(express.json()); + + const limiter = rateLimiter ?? new InMemoryRestRateLimiter(60_000, 5); + const { router, _resetCache } = createLimitsRouter(limiter); + app.use('/api/limits', router); + + return { app, limiter, _resetCache }; +} + +describe('/api/limits/check', () => { + const originalSecret = process.env.JWT_SECRET; + + beforeEach(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + }); + + afterEach(() => { + if (originalSecret !== undefined) { + process.env.JWT_SECRET = originalSecret; + } else { + delete process.env.JWT_SECRET; + } + }); + + test('returns ok when rate limit is not exceeded', async () => { + const { app, _resetCache } = buildApp(); + _resetCache(); + + const res = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-ok'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + test('returns deny with reason when rate limit is exceeded', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 2); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + + // Exhaust the user's budget + limiter.check('user:user-deny'); + limiter.check('user:user-deny'); + + const res = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-deny'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: 'deny', + reason: 'rate_limit_exceeded', + }); + expect(typeof res.body.retryAfterMs).toBe('number'); + expect(res.body.retryAfterMs).toBeGreaterThan(0); + }); + + test('returns ok when a different user has remaining budget', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 2); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + + limiter.check('user:user-exhausted'); + limiter.check('user:user-exhausted'); + + const res = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-still-ok'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + test('does NOT consume a token (peek is idempotent)', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 2); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + + // Peek once + await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-nc') + .expect(200); + + // Budget should still be 2, so two actual requests through the rate limiter should pass + expect(limiter.check('user:user-nc').allowed).toBe(true); + expect(limiter.check('user:user-nc').allowed).toBe(true); + expect(limiter.check('user:user-nc').allowed).toBe(false); + }); + + test('requires authentication', async () => { + const { app, _resetCache } = buildApp(); + _resetCache(); + + const res = await request(app).get('/api/limits/check'); + + expect(res.status).toBe(401); + }); + + test('works with JWT Bearer token authentication', async () => { + const { app, _resetCache } = buildApp(); + _resetCache(); + const token = signTestToken({ + userId: 'jwt-user', + walletAddress: 'GDTEST123', + }); + + const res = await request(app) + .get('/api/limits/check') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + test('returns deny when rate limit is exceeded using JWT auth', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 1); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + const token = signTestToken({ + userId: 'jwt-limited', + walletAddress: 'GDTEST', + }); + + limiter.check('user:jwt-limited'); + + const res = await request(app) + .get('/api/limits/check') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: 'deny', + reason: 'rate_limit_exceeded', + }); + }); + + test('tracks limits per user independently', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 2); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + + limiter.check('user:user-a'); + limiter.check('user:user-a'); + + const resA = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-a'); + expect(resA.body.status).toBe('deny'); + + const resB = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'user-b'); + expect(resB.body.status).toBe('ok'); + }); + + test('caches the result for 1 second', async () => { + const limiter = new InMemoryRestRateLimiter(60_000, 1); + const { app, _resetCache } = buildApp(limiter); + _resetCache(); + + limiter.check('user:cache-test'); + + // First call - should be deny (cached) + const res1 = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'cache-test'); + expect(res1.body.status).toBe('deny'); + + // Clear the limiter state + limiter.reset(); + expect(limiter.peek('user:cache-test').allowed).toBe(true); + + // Second call - should still be cached deny + const res2 = await request(app) + .get('/api/limits/check') + .set('x-user-id', 'cache-test'); + expect(res2.body.status).toBe('deny'); + }); +}); diff --git a/src/routes/limits.ts b/src/routes/limits.ts new file mode 100644 index 0000000..84ba7b6 --- /dev/null +++ b/src/routes/limits.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; +import { InMemoryRestRateLimiter, getRestRateLimitKey } from '../middleware/restRateLimit.js'; + +interface CacheEntry { + allowed: boolean; + retryAfterMs?: number; + expiresAt: number; +} + +export interface LimitsRouter { + router: Router; + _resetCache: () => void; +} + +function createCache() { + const cache = new Map(); + + function get(key: string, now: number): { allowed: boolean; retryAfterMs?: number } | null { + const entry = cache.get(key); + if (entry && now < entry.expiresAt) { + return { allowed: entry.allowed, retryAfterMs: entry.retryAfterMs }; + } + return null; + } + + function set(key: string, result: { allowed: boolean; retryAfterMs?: number }, now: number): void { + cache.set(key, { + allowed: result.allowed, + retryAfterMs: result.retryAfterMs, + expiresAt: now + 1000, + }); + } + + function reset(): void { + cache.clear(); + } + + const interval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of cache) { + if (now >= entry.expiresAt) { + cache.delete(key); + } + } + }, 10_000); + interval.unref(); + + return { get, set, reset }; +} + +export function createLimitsRouter(rateLimiter: InMemoryRestRateLimiter): LimitsRouter { + const router = Router(); + const cache = createCache(); + + router.get( + '/check', + requireAuth, + (req: Request, res: Response): void => { + const now = Date.now(); + const key = getRestRateLimitKey(req); + + const cached = cache.get(key, now); + if (cached) { + if (cached.allowed) { + res.json({ status: 'ok' }); + } else { + res.json({ + status: 'deny', + reason: 'rate_limit_exceeded', + retryAfterMs: cached.retryAfterMs, + }); + } + return; + } + + const result = rateLimiter.peek(key, now); + cache.set(key, result, now); + + if (result.allowed) { + res.json({ status: 'ok' }); + } else { + res.json({ + status: 'deny', + reason: 'rate_limit_exceeded', + retryAfterMs: result.retryAfterMs, + }); + } + }, + ); + + return { router, _resetCache: cache.reset }; +} + +export default createLimitsRouter;