Skip to content
Open
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}/<route>`, 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.
Expand Down Expand Up @@ -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 };
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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(
{
Expand All @@ -153,6 +157,9 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
);
}

counter.remaining -= 1;
c.header('X-RateLimit-Remaining', String(counter.remaining));

await next();
});

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,6 +141,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu

const { app, store, jwt, ctx } = createServer(workosPlugin, {
port,
requestsPerMinute: options.requestsPerMinute,
baseUrl,
apiKeys,
issuer: options.issuer,
Expand Down
122 changes: 122 additions & 0 deletions src/rate-limiting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it, setSystemTime } from 'bun:test';
import { createEmulator, type Emulator } from './index.js';

async function request(emulator: Emulator, apiKey = emulator.apiKey): Promise<Response> {
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',
);
},
);
});
Loading