From c4d41685a953bed3ecee1c4294267b7ba91ba7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Fri, 9 Oct 2026 16:40:17 +0200 Subject: [PATCH 1/2] Make request budgets configurable --- README.md | 8 +++- src/core/server.ts | 10 ++++- src/index.ts | 3 ++ src/rate-limiting.spec.ts | 83 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/rate-limiting.spec.ts diff --git a/README.md b/README.md index ba57238..89b01db 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,12 @@ 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 })`. Rate-limit headers and HTTP 429 +responses with `Retry-After` still reflect the configured budget. 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 +1740,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..ebe806d 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,13 +138,13 @@ 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-Limit', String(requestsPerMinute)); c.header('X-RateLimit-Remaining', String(counter.remaining)); c.header('X-RateLimit-Reset', String(counter.resetAt)); 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('retains the default budget and throttles exhausted requests', async () => { + const emulator = await createEmulator({ port: 0 }); + try { + for (let count = 0; count < 999; count++) { + const response = await request(emulator); + expect(response.status).toBe(200); + expect(response.headers.get('X-RateLimit-Limit')).toBe('1000'); + } + 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('uses the configured budget while keeping API keys isolated', async () => { + const emulator = await createEmulator({ + port: 0, + requestsPerMinute: 3, + seed: { + apiKeys: { + sk_test_first: { environment: 'test' }, + sk_test_second: { environment: 'test' }, + }, + }, + }); + try { + expect((await request(emulator, 'sk_test_first')).status).toBe(200); + const available = await request(emulator, 'sk_test_first'); + expect(available.status).toBe(200); + expect(available.headers.get('X-RateLimit-Limit')).toBe('3'); + expect(available.headers.get('X-RateLimit-Remaining')).toBe('1'); + 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 independent = await request(emulator, 'sk_test_second'); + expect(independent.status).toBe(200); + expect(independent.headers.get('X-RateLimit-Remaining')).toBe('2'); + } finally { + await emulator.close(); + } + }); + + 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', + ); + }, + ); +}); From fbd92814e6b549c50ad80585aac9b91898f1c943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Fri, 9 Oct 2026 20:08:05 +0200 Subject: [PATCH 2/2] Accept the full configured request budget --- README.md | 7 +++-- src/core/server.ts | 7 +++-- src/rate-limiting.spec.ts | 61 ++++++++++++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 89b01db..82e67df 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,10 @@ 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 })`. Rate-limit headers and HTTP 429 -responses with `Retry-After` still reflect the configured budget. Anonymous requests share a -separate budget. +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 diff --git a/src/core/server.ts b/src/core/server.ts index ebe806d..8f7a008 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -142,13 +142,11 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) rateLimitCounters.set(key, counter); } - counter.remaining = Math.max(0, counter.remaining - 1); - c.header('X-RateLimit-Limit', String(requestsPerMinute)); - c.header('X-RateLimit-Remaining', String(counter.remaining)); 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( { @@ -159,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/rate-limiting.spec.ts b/src/rate-limiting.spec.ts index 9ab9e7a..89a1df3 100644 --- a/src/rate-limiting.spec.ts +++ b/src/rate-limiting.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, setSystemTime } from 'bun:test'; import { createEmulator, type Emulator } from './index.js'; async function request(emulator: Emulator, apiKey = emulator.apiKey): Promise { @@ -10,13 +10,15 @@ async function request(emulator: Emulator, apiKey = emulator.apiKey): Promise { - it('retains the default budget and throttles exhausted requests', async () => { + it('accepts the full default budget before throttling', async () => { const emulator = await createEmulator({ port: 0 }); try { - for (let count = 0; count < 999; count++) { + 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); @@ -27,10 +29,10 @@ describe('request budgets', () => { } }); - it('uses the configured budget while keeping API keys isolated', async () => { + it.each([1, 3])('accepts exactly %s requests per API key before throttling', async (requestsPerMinute) => { const emulator = await createEmulator({ port: 0, - requestsPerMinute: 3, + requestsPerMinute, seed: { apiKeys: { sk_test_first: { environment: 'test' }, @@ -39,23 +41,60 @@ describe('request budgets', () => { }, }); try { - expect((await request(emulator, 'sk_test_first')).status).toBe(200); - const available = await request(emulator, 'sk_test_first'); - expect(available.status).toBe(200); - expect(available.headers.get('X-RateLimit-Limit')).toBe('3'); - expect(available.headers.get('X-RateLimit-Remaining')).toBe('1'); + 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('2'); + 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 {