diff --git a/README.md b/README.md index ba57238..82e67df 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,13 @@ emulator.reset(); await emulator.close(); ``` +The emulator's request budget defaults to 1,000 per API key in each 60-second window. Larger +integration suites that share an API key can set `requestsPerMinute` to a positive safe integer, +for example `createEmulator({ port: 0, requestsPerMinute: 10_000 })`. The full configured budget is +accepted, so a budget of 1 allows one request before HTTP 429 responses with `Retry-After`. The +last accepted request has `X-RateLimit-Remaining: 0`; the budget resets 60 seconds after the first +request in the window. Anonymous requests share a separate budget. + ### ⚠️ Important: EventBus Reset Limitation The `reset()` method clears all data and re-seeds from the original config, but **route-level authentication events will not work after reset**. This is because Hono's router cannot be modified after it's built, so the EventBus cannot be re-registered with the collection hooks. @@ -1734,7 +1741,7 @@ The WorkOS Emulator is designed for testing and development environments. When u ### Authentication & Authorization - **No real authentication**: The emulator uses a simple API key system (`sk_test_default`) that provides no real security. Anyone with access to the emulator can make API calls. -- **No rate limiting**: By default, the emulator has no rate limiting. In production scenarios, implement rate limiting to prevent abuse. +- **Local rate limiting**: The emulator defaults to a 1,000-request budget per API key per minute, configurable through `requestsPerMinute`. This in-memory limit is intended for tests, not production abuse protection. - **Public error hook endpoints**: The `/_emulate/hooks` endpoints require no authentication and can be used by anyone who can reach the server. ### Data Security diff --git a/src/core/server.ts b/src/core/server.ts index 2732f9e..8f7a008 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -9,6 +9,8 @@ import type { ServicePlugin, RouteContext } from './plugin.js'; export interface ServerOptions { port?: number; + /** Maximum requests per API key in each 60-second window; defaults to 1,000. */ + requestsPerMinute?: number; /** * The externally reachable root at which the emulator's routes are served. Every link the * emulator mints — invitation, password reset, device verification, SSO logout — and the default @@ -34,6 +36,10 @@ export interface ServerOptions { export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) { const port = options.port ?? 4100; + const requestsPerMinute = options.requestsPerMinute ?? 1000; + if (!Number.isSafeInteger(requestsPerMinute) || requestsPerMinute < 1) { + throw new RangeError('requestsPerMinute must be a positive safe integer'); + } // Every link the emulator mints is `${baseUrl}/`, so a trailing slash on the option would // double up in all of them (and in the default `iss`, built the same way). A path prefix is kept: // see ServerOptions.baseUrl for the contract it states. @@ -132,17 +138,15 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) let counter = rateLimitCounters.get(key); if (!counter || counter.resetAt <= now) { - counter = { remaining: 1000, resetAt: now + 60 }; + counter = { remaining: requestsPerMinute, resetAt: now + 60 }; rateLimitCounters.set(key, counter); } - counter.remaining = Math.max(0, counter.remaining - 1); - - c.header('X-RateLimit-Limit', '1000'); - c.header('X-RateLimit-Remaining', String(counter.remaining)); + c.header('X-RateLimit-Limit', String(requestsPerMinute)); c.header('X-RateLimit-Reset', String(counter.resetAt)); if (counter.remaining === 0) { + c.header('X-RateLimit-Remaining', '0'); c.header('Retry-After', String(counter.resetAt - now)); return c.json( { @@ -153,6 +157,9 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) ); } + counter.remaining -= 1; + c.header('X-RateLimit-Remaining', String(counter.remaining)); + await next(); }); diff --git a/src/index.ts b/src/index.ts index 5e7a191..3b177c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,8 @@ export interface EmulatorSeedConfig { export interface EmulatorOptions { port?: number; + /** Maximum requests per API key in each 60-second window; defaults to 1,000. */ + requestsPerMinute?: number; /** * Network interface to bind to. Defaults to `localhost`, which keeps the * emulator's unauthenticated endpoints reachable only from the local @@ -139,6 +141,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise { + const response = await fetch(`${emulator.url}/organizations`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + await response.text(); + return response; +} + +describe('request budgets', () => { + it('accepts the full default budget before throttling', async () => { + const emulator = await createEmulator({ port: 0 }); + try { + for (let count = 1; count <= 1000; count++) { + const response = await request(emulator); + expect(response.status).toBe(200); + expect(response.headers.get('X-RateLimit-Limit')).toBe('1000'); + expect(response.headers.get('X-RateLimit-Remaining')).toBe(String(1000 - count)); + expect(response.headers.get('Retry-After')).toBeNull(); + } + const response = await request(emulator); + expect(response.status).toBe(429); + expect(response.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(Number(response.headers.get('Retry-After'))).toBeGreaterThan(0); + } finally { + await emulator.close(); + } + }); + + it.each([1, 3])('accepts exactly %s requests per API key before throttling', async (requestsPerMinute) => { + const emulator = await createEmulator({ + port: 0, + requestsPerMinute, + seed: { + apiKeys: { + sk_test_first: { environment: 'test' }, + sk_test_second: { environment: 'test' }, + }, + }, + }); + try { + for (let count = 1; count <= requestsPerMinute; count++) { + const response = await request(emulator, 'sk_test_first'); + expect(response.status).toBe(200); + expect(response.headers.get('X-RateLimit-Limit')).toBe(String(requestsPerMinute)); + expect(response.headers.get('X-RateLimit-Remaining')).toBe(String(requestsPerMinute - count)); + expect(response.headers.get('Retry-After')).toBeNull(); + } + const exhausted = await request(emulator, 'sk_test_first'); + expect(exhausted.status).toBe(429); + expect(exhausted.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(Number(exhausted.headers.get('Retry-After'))).toBeGreaterThan(0); + const stillExhausted = await request(emulator, 'sk_test_first'); + expect(stillExhausted.status).toBe(429); + expect(stillExhausted.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(stillExhausted.headers.get('X-RateLimit-Reset')).toBe(exhausted.headers.get('X-RateLimit-Reset')); + const independent = await request(emulator, 'sk_test_second'); + expect(independent.status).toBe(200); + expect(independent.headers.get('X-RateLimit-Remaining')).toBe(String(requestsPerMinute - 1)); + } finally { + await emulator.close(); + } + }); + + it('replenishes the budget when the 60-second window expires', async () => { + const now = Date.UTC(2026, 0, 1); + setSystemTime(now); + let emulator: Emulator | undefined; + try { + emulator = await createEmulator({ port: 0, requestsPerMinute: 1 }); + const first = await request(emulator); + expect(first.status).toBe(200); + expect(first.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(first.headers.get('X-RateLimit-Reset')).toBe(String(now / 1000 + 60)); + + setSystemTime(now + 59_000); + const exhausted = await request(emulator); + expect(exhausted.status).toBe(429); + expect(exhausted.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(exhausted.headers.get('X-RateLimit-Reset')).toBe(first.headers.get('X-RateLimit-Reset')); + expect(exhausted.headers.get('Retry-After')).toBe('1'); + + setSystemTime(now + 60_000); + const replenished = await request(emulator); + expect(replenished.status).toBe(200); + expect(replenished.headers.get('X-RateLimit-Remaining')).toBe('0'); + expect(replenished.headers.get('X-RateLimit-Reset')).toBe(String(now / 1000 + 120)); + expect(replenished.headers.get('Retry-After')).toBeNull(); + expect((await request(emulator)).status).toBe(429); + } finally { + if (emulator) await emulator.close(); + setSystemTime(); + } + }); + + it('allows integration suites to exceed the default budget', async () => { + const emulator = await createEmulator({ port: 0, requestsPerMinute: 10_000 }); + try { + for (let count = 0; count < 1000; count++) { + const response = await request(emulator); + expect(response.status).toBe(200); + } + const response = await request(emulator); + expect(response.status).toBe(200); + expect(response.headers.get('X-RateLimit-Limit')).toBe('10000'); + expect(response.headers.get('X-RateLimit-Remaining')).toBe('8999'); + } finally { + await emulator.close(); + } + }); + + it.each([0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request budget %s before starting the server', + async (requestsPerMinute) => { + await expect(createEmulator({ port: 0, requestsPerMinute })).rejects.toThrow( + 'requestsPerMinute must be a positive safe integer', + ); + }, + ); +});