Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ 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 { InMemoryRestRateLimiter, createRestRateLimitMiddleware } from './middleware/restRateLimit.js';
import type { RestRateLimitOptions } from './middleware/restRateLimit.js';
import { auditEnrichMiddleware } from './middleware/auditEnrich.js';
import { createConfiguredRestRateLimitMiddleware } from './middleware/restRateLimit.js';
import { metricsMiddleware, metricsEndpoint } from './metrics.js';
import { config } from './config/index.js';
import { validateUpstreamBaseUrl } from './lib/upstreamTarget.js';
Expand Down Expand Up @@ -92,8 +93,12 @@ const vaultBalanceQuerySchema = z.object({

export const createApp = (dependencies?: Partial<AppDependencies>) => {
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;
const usageEventsRepository =
Expand Down Expand Up @@ -287,9 +292,10 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
}),
);

// Mount all routes including billing
// Mount all routes including billing and limits
app.use('/api', createApiRouter({
restRateLimit,
restRateLimiter,
usageEventsRepository,
apiRepository,
developerRepository
Expand Down
61 changes: 60 additions & 1 deletion src/middleware/restRateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 });
});
});
17 changes: 17 additions & 0 deletions src/middleware/restRateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 7 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { createBillingPortalRouter } from './billing/portal.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';
import { createUsageCsvRouter } from './usage/csv.js';
import { createExportSchedulesRouter } from './exports/schedules.js';
import type { ScheduledExportsService } from '../services/scheduledExports.js';
Expand All @@ -17,6 +19,7 @@ const openApiSpec = JSON.parse(readFileSync(openApiPath, 'utf8'));

export interface ApiRouterDeps extends Partial<UsageRouterDeps>, Partial<ApisRouterDeps> {
restRateLimit?: RequestHandler;
restRateLimiter?: InMemoryRestRateLimiter;
scheduledExportsService?: ScheduledExportsService;
}

Expand Down Expand Up @@ -51,6 +54,10 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
router.use('/billing/portal', createBillingPortalRouter());
}

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');
Expand Down
190 changes: 190 additions & 0 deletions src/routes/limits.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading