Initial Commit
This commit is contained in:
372
openspec/archive/2026-04-24-enterprise-rate-limiter/proposal.md
Normal file
372
openspec/archive/2026-04-24-enterprise-rate-limiter/proposal.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# OpenSpec Change: enterprise-rate-limiter
|
||||
|
||||
- **Change ID**: enterprise-rate-limiter
|
||||
- **Package**: `@suaveplan/rate-limiter`
|
||||
- **Category**: universal
|
||||
- **Target path**: `universal/auth/rate-limiter`
|
||||
- **Status**: PENDING
|
||||
- **Date**: 2026-04-24
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/rate-limiter.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/rate-limiter` on the genesis branch from scratch at enterprise quality. The package provides tenant-specific rate limiting with multiple algorithms: `TokenBucket` (burst-friendly), `SlidingWindow` (smooth), and `FixedWindow` (simple); a `RateLimiter` orchestrator that applies rule-based routing to the right algorithm; pluggable `RateLimitStorage` adapters (`MemoryStorage`, adapter interface); multi-tenant multipliers; and framework-agnostic middleware helpers. The genesis rewrite removes Hono coupling from the core types, adds structured errors via `@suaveplan/error`, Zod schemas, injectable clock and RNG for determinism, OTel instrumentation, `AbortSignal` on async storage paths, property-based tests, and mitata benchmarks.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/error` (`workspace:^`) | Base class for `RateLimiterError`; `defineCodes` for `RATE.*` error codes |
|
||||
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable window computation), `Rng` (injectable jitter), `LoggerLike`, `EventBusLike` |
|
||||
| `@suaveplan/events` (`workspace:^`) | `EventBus` for `rate:limit:exceeded` events |
|
||||
| `@suaveplan/logger` (`workspace:^`) | Structured logging with trace correlation |
|
||||
| `zod` (`catalog:forms`) | Zod schemas for all option types |
|
||||
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
|
||||
| `tslib` (`catalog:build`) | TypeScript runtime helpers |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`).
|
||||
|
||||
---
|
||||
|
||||
## Preserved public API surface (from main branch)
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
interface RateLimitResult {
|
||||
readonly allowed: boolean
|
||||
readonly remaining: number
|
||||
readonly limit: number
|
||||
readonly resetIn: number // ms until window resets
|
||||
readonly retryAfter?: number // ms to wait (only when allowed === false)
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface RateLimitConfig {
|
||||
readonly limit: number
|
||||
readonly window: number // ms
|
||||
readonly id?: string
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
interface RateLimitRule extends RateLimitConfig {
|
||||
readonly priority?: number
|
||||
readonly conditions?: RateLimitCondition[]
|
||||
readonly bypass?: boolean
|
||||
}
|
||||
|
||||
interface RateLimitCondition {
|
||||
readonly field: string
|
||||
readonly operator: "eq" | "neq" | "in" | "nin" | "matches"
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface RateLimitContext {
|
||||
readonly key: string
|
||||
readonly tenantId?: string
|
||||
readonly userId?: string
|
||||
readonly ip?: string
|
||||
readonly path?: string
|
||||
readonly method?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface RateLimiterOptions {
|
||||
readonly defaultConfig: RateLimitConfig
|
||||
readonly storage?: RateLimitStorage
|
||||
readonly rules?: RateLimitRule[]
|
||||
readonly enableTenantLimits?: boolean
|
||||
readonly tenantMultipliers?: Record<string, number>
|
||||
readonly keyPrefix?: string
|
||||
readonly clock?: Clock
|
||||
readonly bus?: EventBusLike
|
||||
readonly logger?: LoggerLike
|
||||
}
|
||||
|
||||
// State shapes (serialisable for persistence)
|
||||
interface TokenBucketState {
|
||||
tokens: number
|
||||
lastRefill: number // epoch ms
|
||||
}
|
||||
|
||||
interface SlidingWindowState {
|
||||
requests: number[] // array of epoch ms timestamps
|
||||
}
|
||||
|
||||
interface CounterState {
|
||||
count: number
|
||||
windowStart: number // epoch ms
|
||||
}
|
||||
|
||||
interface RateLimitState {
|
||||
key: string
|
||||
algorithm: RateLimitAlgorithm
|
||||
state: TokenBucketState | SlidingWindowState | CounterState
|
||||
config: RateLimitConfig
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type RateLimitAlgorithm = "token-bucket" | "sliding-window" | "fixed-window"
|
||||
|
||||
interface TenantRateLimitConfig extends RateLimitConfig {
|
||||
tenantId: string
|
||||
multiplier?: number
|
||||
}
|
||||
|
||||
// Events
|
||||
interface RateLimiterEvents {
|
||||
'rate:limit:exceeded': RateLimitedPayload
|
||||
'rate:limit:reset': RateLimitResetPayload
|
||||
}
|
||||
|
||||
interface RateLimitedPayload {
|
||||
key: string
|
||||
tenantId?: string
|
||||
remaining: number
|
||||
resetIn: number
|
||||
}
|
||||
|
||||
interface RateLimitResetPayload {
|
||||
key: string
|
||||
tenantId?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Storage adapter
|
||||
|
||||
```ts
|
||||
interface RateLimitStorage {
|
||||
get(key: string): Promise<RateLimitState | null>
|
||||
set(key: string, state: RateLimitState, ttl: number): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
interface MemoryStorageOptions {
|
||||
maxSize?: number
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
class MemoryStorage implements RateLimitStorage {
|
||||
constructor(options?: MemoryStorageOptions)
|
||||
}
|
||||
```
|
||||
|
||||
### Core algorithms
|
||||
|
||||
```ts
|
||||
interface TokenBucketOptions {
|
||||
capacity: number
|
||||
refillRate: number // tokens per ms
|
||||
initialTokens?: number
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
class TokenBucket {
|
||||
constructor(options: TokenBucketOptions)
|
||||
consume(tokens?: number): RateLimitResult
|
||||
refill(tokens?: number): void
|
||||
readonly available: number
|
||||
readonly capacity: number
|
||||
}
|
||||
|
||||
interface SlidingWindowOptions {
|
||||
limit: number
|
||||
windowMs: number
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
class SlidingWindow {
|
||||
constructor(options: SlidingWindowOptions)
|
||||
check(key: string): RateLimitResult
|
||||
reset(key: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware helpers
|
||||
|
||||
```ts
|
||||
function createRateLimiter(options: RateLimiterOptions): RateLimiterInstance
|
||||
function createIpRateLimiter(config: RateLimitConfig, storage?: RateLimitStorage): MiddlewareFn
|
||||
function createRouteRateLimiter(routes: Record<string, RateLimitConfig>, storage?: RateLimitStorage): MiddlewareFn
|
||||
function createRateLimitChecker(options: RateLimiterOptions): (context: RateLimitContext) => Promise<RateLimitResult>
|
||||
function getRateLimitResult(context: RateLimitContext, storage: RateLimitStorage, config: RateLimitConfig): Promise<RateLimitResult>
|
||||
|
||||
interface RateLimiterMiddlewareOptions {
|
||||
keyExtractor?: (req: unknown) => string
|
||||
onLimited?: (result: RateLimitResult) => void
|
||||
skip?: (req: unknown) => boolean
|
||||
}
|
||||
|
||||
interface RateLimiterInstance {
|
||||
check(context: RateLimitContext): Promise<RateLimitResult>
|
||||
reset(key: string): Promise<void>
|
||||
getState(key: string): Promise<RateLimitState | null>
|
||||
}
|
||||
```
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type RateLimiterErrorCode =
|
||||
| "RATE_STORAGE_ERROR"
|
||||
| "RATE_INVALID_CONFIG"
|
||||
| "RATE_KEY_MISSING"
|
||||
| "RATE_STATE_CORRUPT"
|
||||
|
||||
class RateLimiterError extends SuaveplanError<RateLimiterErrorCode>
|
||||
function registerRateLimiterErrorCodes(): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
### 1. FixedWindow algorithm
|
||||
|
||||
A third algorithm (`FixedWindow`) complements `TokenBucket` and `SlidingWindow`. Uses a simple counter with a hard window boundary — lowest memory overhead, suitable for coarse limits.
|
||||
|
||||
```ts
|
||||
interface FixedWindowOptions {
|
||||
limit: number
|
||||
windowMs: number
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
class FixedWindow {
|
||||
constructor(options: FixedWindowOptions)
|
||||
check(key: string): RateLimitResult
|
||||
reset(key: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Clock and Rng injection
|
||||
|
||||
`TokenBucket` refill timing and `SlidingWindow` / `FixedWindow` window boundaries use injectable `Clock` (from `@suaveplan/types`). No bare `Date.now()` at module scope. `MemoryStorage` TTL checks also use injectable `Clock`.
|
||||
|
||||
### 3. `RateLimiterInstance` orchestrator
|
||||
|
||||
`createRateLimiter` returns a `RateLimiterInstance` that:
|
||||
- Selects algorithm (token-bucket / sliding-window / fixed-window) per rule or default config.
|
||||
- Applies `rules` in priority order — first matching rule wins; bypass rules skip limiting.
|
||||
- Applies `tenantMultipliers` to effective limit for matching tenant contexts.
|
||||
- Emits `rate:limit:exceeded` on optional `bus` when `allowed === false`.
|
||||
- Emits `rate:limit:reset` on `reset()`.
|
||||
|
||||
### 4. OTel instrumentation
|
||||
|
||||
- `rate_limiter.check` span + `rate_limiter.checks_total` counter (labels: `algorithm`, `allowed: bool`, `tenant_id`)
|
||||
- `rate_limiter.check_duration_ms` histogram
|
||||
- `rate_limiter.exceeded_total` counter (label: `key_prefix`, `tenant_id`)
|
||||
- `rate_limiter.storage_get_total` / `rate_limiter.storage_set_total` counters
|
||||
|
||||
### 5. Zod schemas
|
||||
|
||||
`RateLimitConfigSchema`, `RateLimitRuleSchema`, `RateLimitContextSchema`, `RateLimiterOptionsSchema`, `TokenBucketOptionsSchema`, `SlidingWindowOptionsSchema`, `FixedWindowOptionsSchema`, `MemoryStorageOptionsSchema`.
|
||||
|
||||
### 6. Property-based tests (fast-check)
|
||||
|
||||
- TokenBucket invariant: `available` never exceeds `capacity`; `available` never goes below 0.
|
||||
- SlidingWindow monotonicity: `remaining` never increases within a window without a `reset`.
|
||||
- Tenant multiplier correctness: effective limit for tenant with multiplier `m` equals `config.limit * m`.
|
||||
- Rule priority ordering: when two rules match, the one with higher `priority` is applied.
|
||||
- Storage round-trip: `set(key, state)` then `get(key)` returns a deep-equal state (within TTL).
|
||||
|
||||
### 7. Benchmark suite
|
||||
|
||||
- `TokenBucket.consume` — 1 000 000 iterations (pure in-memory).
|
||||
- `SlidingWindow.check` — 100 000 iterations with a 1 000-entry timestamp window.
|
||||
- `FixedWindow.check` — 100 000 iterations.
|
||||
- `createRateLimiter.check` with MemoryStorage — 100 000 iterations.
|
||||
- `MemoryStorage.get` + `set` — 1 000 000 round-trips.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
universal/auth/rate-limiter/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
constants/
|
||||
index.ts
|
||||
types/
|
||||
rate-limiter.types.ts
|
||||
rate-limiter.types.md
|
||||
core/
|
||||
token-bucket/
|
||||
token-bucket.ts
|
||||
token-bucket.md
|
||||
token-bucket.types.ts
|
||||
token-bucket.test.ts
|
||||
index.ts
|
||||
sliding-window/
|
||||
sliding-window.ts
|
||||
sliding-window.md
|
||||
sliding-window.types.ts
|
||||
sliding-window.test.ts
|
||||
index.ts
|
||||
fixed-window/
|
||||
fixed-window.ts
|
||||
fixed-window.md
|
||||
fixed-window.types.ts
|
||||
fixed-window.test.ts
|
||||
index.ts
|
||||
rate-limiter/
|
||||
rate-limiter.ts # createRateLimiter + RateLimiterInstance
|
||||
rate-limiter.md
|
||||
rate-limiter.types.ts
|
||||
rate-limiter.test.ts
|
||||
index.ts
|
||||
storage/
|
||||
storage-adapter/
|
||||
storage-adapter.ts # RateLimitStorage interface
|
||||
storage-adapter.md
|
||||
storage-adapter.types.ts
|
||||
index.ts
|
||||
memory-storage/
|
||||
memory-storage.ts
|
||||
memory-storage.md
|
||||
memory-storage.test.ts
|
||||
index.ts
|
||||
middleware/
|
||||
rate-limit-middleware/
|
||||
rate-limit-middleware.ts # createIpRateLimiter, createRouteRateLimiter, etc.
|
||||
rate-limit-middleware.md
|
||||
rate-limit-middleware.types.ts
|
||||
rate-limit-middleware.test.ts
|
||||
index.ts
|
||||
schemas/
|
||||
rate-limiter.schemas.ts
|
||||
rate-limiter.schemas.md
|
||||
rate-limiter.schemas.test.ts
|
||||
__benchmarks__/
|
||||
rate-limiter.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
property.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Downstream packages**: API gateway and server packages will use `createRateLimiter` for request throttling. Auth plugins will use the tenant-specific configuration.
|
||||
|
||||
**Breaking changes from main-branch API**: Hono-specific types in `RateLimiterMiddlewareOptions` are replaced with framework-agnostic `MiddlewareFn`; a `createHonoAdapter` bridge is exported for compat. All other symbols are preserved. `FixedWindow` is a net-new addition.
|
||||
|
||||
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger` must be published to Verdaccio before this package can be built or tested.
|
||||
207
openspec/archive/2026-04-24-enterprise-rate-limiter/tasks.md
Normal file
207
openspec/archive/2026-04-24-enterprise-rate-limiter/tasks.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Tasks: enterprise-rate-limiter
|
||||
|
||||
**Change ID**: enterprise-rate-limiter
|
||||
**Package**: `@suaveplan/rate-limiter`
|
||||
**Target path**: `universal/auth/rate-limiter`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory `universal/auth/rate-limiter/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [x] Write `package.json` at version `0.1.0` with:
|
||||
- `"type": "module"`, `"sideEffects": false`
|
||||
- `exports` map with top-level `"."` plus subpaths: `"./core/token-bucket/token-bucket"`, `"./core/sliding-window/sliding-window"`, `"./core/fixed-window/fixed-window"`, `"./core/rate-limiter/rate-limiter"`, `"./storage/storage-adapter/storage-adapter"`, `"./storage/memory-storage/memory-storage"`, `"./middleware/rate-limit-middleware/rate-limit-middleware"`, `"./schemas/rate-limiter.schemas"`, `"./types/rate-limiter.types"`
|
||||
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/logger workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
|
||||
- `devDependencies`: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^`
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`).
|
||||
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`, `bench` scripts to `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types and errors
|
||||
|
||||
### 2a. Types (`src/types/rate-limiter.types.ts` + `.md`)
|
||||
|
||||
- [x] Define all public interfaces from `proposal.md`: `RateLimitResult`, `RateLimitConfig`, `RateLimitRule`, `RateLimitCondition`, `RateLimitContext`, `RateLimiterOptions`, `TokenBucketState`, `SlidingWindowState`, `CounterState`, `RateLimitState`, `RateLimitAlgorithm`, `TenantRateLimitConfig`, `RateLimiterEvents`, `RateLimitedPayload`, `RateLimitResetPayload`, `RateLimiterInstance`, `RateLimiterMiddlewareOptions`, `MiddlewareFn`.
|
||||
- [x] Write `rate-limiter.types.md` (≥200 words, API reference for every type).
|
||||
|
||||
### 2b. Error codes (`src/error-codes.ts`)
|
||||
|
||||
- [x] Define `RateLimiterErrorCode` union — four codes: `RATE_STORAGE_ERROR`, `RATE_INVALID_CONFIG`, `RATE_KEY_MISSING`, `RATE_STATE_CORRUPT`.
|
||||
- [x] Define `RATE_ERROR_METADATA` with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` per code.
|
||||
- [x] Export `registerRateLimiterErrorCodes()` as idempotent eager side-effect.
|
||||
- [x] Export `RateLimiterError extends SuaveplanError<RateLimiterErrorCode>`.
|
||||
|
||||
### 2c. Zod schemas (`src/schemas/rate-limiter.schemas.ts` + `.md`)
|
||||
|
||||
- [x] Write Zod schemas: `RateLimitConfigSchema`, `RateLimitRuleSchema`, `RateLimitContextSchema`, `RateLimiterOptionsSchema`, `TokenBucketOptionsSchema`, `SlidingWindowOptionsSchema`, `FixedWindowOptionsSchema`, `MemoryStorageOptionsSchema`.
|
||||
- [x] Write `rate-limiter.schemas.md`.
|
||||
- [x] Write `rate-limiter.schemas.test.ts` — parse valid/invalid for each schema.
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage adapter (`src/storage/storage-adapter/`)
|
||||
|
||||
- [x] Define `RateLimitStorage` interface: `get`, `set`, `delete`, `clear`.
|
||||
- [x] Write `storage-adapter.types.ts`.
|
||||
- [x] Write `storage-adapter.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. MemoryStorage (`src/storage/memory-storage/`)
|
||||
|
||||
- [x] Implement `MemoryStorage` using a `Map<string, { state: RateLimitState; expiresAt: number }>`:
|
||||
- `get(key)` — returns `null` if missing or expired (lazy check via injectable `clock`).
|
||||
- `set(key, state, ttl)` — stores with `expiresAt = clock.now() + ttl`.
|
||||
- `delete(key)` — removes entry.
|
||||
- `clear()` — wipes map.
|
||||
- `maxSize` enforcement: on `set`, if map exceeds `maxSize`, evict the entry with the soonest `expiresAt`.
|
||||
- [x] Write `memory-storage.test.ts` — get/set round-trip, TTL expiry with FakeClock, maxSize eviction, delete, clear. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `memory-storage.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. TokenBucket (`src/core/token-bucket/`)
|
||||
|
||||
- [x] Implement `TokenBucket(options)`:
|
||||
- Internal state: `tokens: number`, `lastRefill: number` (epoch ms via `clock`).
|
||||
- `consume(tokens?)`:
|
||||
- First, compute elapsed ms since `lastRefill` via `clock.now()`; add `elapsed * refillRate` tokens (capped at `capacity`).
|
||||
- If `tokens` available, decrement and return `{ allowed: true, remaining: ..., limit: capacity, resetIn: timeUntilNextToken }`.
|
||||
- If insufficient, return `{ allowed: false, remaining: 0, limit: capacity, resetIn: ..., retryAfter: ... }`.
|
||||
- `refill(tokens?)` — explicitly adds tokens (used in tests and manual top-ups).
|
||||
- Getters: `available`, `capacity`.
|
||||
- Validate options with `TokenBucketOptionsSchema`; throw `RateLimiterError` with `RATE_INVALID_CONFIG` on invalid options.
|
||||
- [x] Write `token-bucket.test.ts` — consume within capacity, consume exhausted, automatic refill via FakeClock, explicit refill, `available` getter, capacity cap, invalid options error. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `token-bucket.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. SlidingWindow (`src/core/sliding-window/`)
|
||||
|
||||
- [x] Implement `SlidingWindow(options)`:
|
||||
- Internal state: `Map<string, number[]>` — per-key array of request timestamps.
|
||||
- `check(key)`:
|
||||
- Prune timestamps older than `clock.now() - windowMs`.
|
||||
- If `remaining > 0`, push current timestamp, return `{ allowed: true, remaining: ... }`.
|
||||
- Otherwise return `{ allowed: false, remaining: 0, retryAfter: oldest_ts + windowMs - now }`.
|
||||
- `reset(key)` — clears the key's timestamp array.
|
||||
- Validate options with `SlidingWindowOptionsSchema`.
|
||||
- [x] Write `sliding-window.test.ts` — allow up to limit, deny on exceed, timestamps pruned outside window (FakeClock), reset, multiple keys isolated. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `sliding-window.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. FixedWindow (`src/core/fixed-window/`)
|
||||
|
||||
- [x] Implement `FixedWindow(options)`:
|
||||
- Internal state: `Map<string, { count: number; windowStart: number }>`.
|
||||
- `check(key)`:
|
||||
- Compute current window start = `Math.floor(clock.now() / windowMs) * windowMs`.
|
||||
- If stored `windowStart !== currentWindowStart`, reset count to 0 and update `windowStart`.
|
||||
- If `count < limit`, increment and return `{ allowed: true, remaining: limit - count - 1, resetIn: currentWindowStart + windowMs - clock.now() }`.
|
||||
- Otherwise return `{ allowed: false, remaining: 0, retryAfter: currentWindowStart + windowMs - clock.now() }`.
|
||||
- `reset(key)` — removes the key's state.
|
||||
- Validate options with `FixedWindowOptionsSchema`.
|
||||
- [x] Write `fixed-window.test.ts` — allow within limit, deny on exceed, window rolls over (FakeClock), reset, multiple keys. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `fixed-window.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. RateLimiter orchestrator (`src/core/rate-limiter/`)
|
||||
|
||||
- [x] Implement `createRateLimiter(options)` returning `RateLimiterInstance`:
|
||||
- `check(context)`:
|
||||
1. Evaluate `options.rules` in descending priority order; first matching rule (conditions met) wins.
|
||||
2. If a rule has `bypass: true`, return `{ allowed: true, remaining: Infinity, limit: Infinity, resetIn: 0 }`.
|
||||
3. Apply `tenantMultipliers` to effective limit if `context.tenantId` matches.
|
||||
4. Build a composite storage key: `${keyPrefix}:${context.key}`.
|
||||
5. Delegate to the selected algorithm (default: `sliding-window`; configurable per-rule via `rule.algorithm`).
|
||||
6. If `!result.allowed`, emit `rate:limit:exceeded` on `bus`.
|
||||
7. Instrument with `rate_limiter.check` span, `rate_limiter.checks_total` counter, `rate_limiter.check_duration_ms` histogram.
|
||||
- `reset(key)` — deletes all storage keys matching the composite key pattern; emits `rate:limit:reset`.
|
||||
- `getState(key)` — delegates to `storage.get`.
|
||||
- [x] Write `rate-limiter.test.ts` (≤500 lines) — default algorithm, rule priority selection, bypass rule, tenant multiplier, bus event on denied, storage delegation, reset, getState. `{ timeout: 5000 }` / `{ timeout: 30000 }`.
|
||||
- [x] Write `rate-limiter.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Middleware helpers (`src/middleware/rate-limit-middleware/`)
|
||||
|
||||
- [x] Implement `createRateLimitChecker(options)` — returns `(context) => Promise<RateLimitResult>` closure over a `RateLimiterInstance`.
|
||||
- [x] Implement `createIpRateLimiter(config, storage?)` — creates a limiter keyed on IP with default sliding-window.
|
||||
- [x] Implement `createRouteRateLimiter(routes, storage?)` — creates a limiter with per-route `RateLimitConfig`, keyed on `path`.
|
||||
- [x] Implement `getRateLimitResult(context, storage, config)` — one-shot check without creating a full `RateLimiterInstance`.
|
||||
- [x] All functions return `MiddlewareFn` (framework-agnostic); include `createHonoAdapter` bridge.
|
||||
- [x] Write `rate-limit-middleware.test.ts` — each helper: allowed path, denied path, IP extraction, route matching, Hono adapter. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `rate-limit-middleware.md`.
|
||||
|
||||
---
|
||||
|
||||
## 10. OTel instrumentation (cross-cutting review)
|
||||
|
||||
- [x] Confirm all spans and counters from `proposal.md §4` are wired.
|
||||
- [x] Add `tests/otel.test.ts` — in-memory exporter; assert `rate_limiter.checks_total` increments, `rate_limiter.exceeded_total` increments on denied checks, `rate_limiter.check_duration_ms` records.
|
||||
|
||||
---
|
||||
|
||||
## 11. Property-based tests (`tests/property.test.ts`)
|
||||
|
||||
Use `fast-check`. All test timeouts `{ timeout: 30000 }`.
|
||||
|
||||
- [x] **TokenBucket `available` invariant**: after any sequence of `consume` and `refill` calls, `available` is always in `[0, capacity]`.
|
||||
- [x] **SlidingWindow monotonicity**: within a window, `remaining` never increases without a `reset` or window rollover.
|
||||
- [x] **Tenant multiplier correctness**: effective limit = `config.limit * multiplier`; `remaining` at the effective limit is 0.
|
||||
- [x] **Rule priority**: when two rules both match a context, the higher-priority rule's config is applied.
|
||||
- [x] **Storage round-trip**: `MemoryStorage.set(k, s, ttl)` then `MemoryStorage.get(k)` returns a structurally equal state (within TTL).
|
||||
|
||||
---
|
||||
|
||||
## 12. Benchmarks (`src/__benchmarks__/rate-limiter.bench.ts`)
|
||||
|
||||
Use `mitata`.
|
||||
|
||||
- [x] `token_bucket_consume`: 1 000 000 `consume()` calls (pure in-memory, no storage).
|
||||
- [x] `sliding_window_check`: 100 000 `check()` calls with a 1 000-timestamp window.
|
||||
- [x] `fixed_window_check`: 100 000 `check()` calls.
|
||||
- [x] `rate_limiter_memory_storage`: 100 000 `check()` calls via `createRateLimiter` + `MemoryStorage`.
|
||||
- [x] `memory_storage_get_set`: 1 000 000 round-trips.
|
||||
- [x] Document placeholder numbers in `README.md` under `## Benchmarks`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Docs
|
||||
|
||||
Per-file `.md` (all ≥200 words):
|
||||
|
||||
- [x] `src/types/rate-limiter.types.md`
|
||||
- [x] `src/storage/storage-adapter/storage-adapter.md`
|
||||
- [x] `src/storage/memory-storage/memory-storage.md`
|
||||
- [x] `src/core/token-bucket/token-bucket.md`
|
||||
- [x] `src/core/sliding-window/sliding-window.md`
|
||||
- [x] `src/core/fixed-window/fixed-window.md`
|
||||
- [x] `src/core/rate-limiter/rate-limiter.md`
|
||||
- [x] `src/middleware/rate-limit-middleware/rate-limit-middleware.md`
|
||||
- [x] `src/schemas/rate-limiter.schemas.md`
|
||||
|
||||
Root README:
|
||||
|
||||
- [x] Write `universal/auth/rate-limiter/README.md`: purpose, install, quick-start (token-bucket + sliding-window comparison), multi-tenant guide, middleware adapter guide, modules table, benchmarks placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 14. Verify and publish
|
||||
|
||||
- [x] Run `bunx turbo lint --filter=@suaveplan/rate-limiter` — zero errors.
|
||||
- [x] Run `bunx turbo typecheck --filter=@suaveplan/rate-limiter` — zero errors.
|
||||
- [x] Run `bunx turbo test --filter=@suaveplan/rate-limiter` — all tests pass.
|
||||
- [x] Run `bunx turbo test:coverage --filter=@suaveplan/rate-limiter` — 100/100/100/100.
|
||||
- [x] Run `bun run docs:check --filter=@suaveplan/rate-limiter` — zero errors.
|
||||
- [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore`, or `.skip` in `src/` or `tests/`.
|
||||
- [x] Confirm `"sideEffects": false` in `package.json`.
|
||||
- [x] Run `bun scripts/publish.ts --package @suaveplan/rate-limiter`.
|
||||
- [x] Update `CLAUDE.md` package listing to include `@suaveplan/rate-limiter`.
|
||||
- [x] Archive: move `openspec/changes/enterprise-rate-limiter/` to `openspec/archive/2026-04-24-enterprise-rate-limiter/` and fold spec delta into `openspec/specs/rate-limiter/spec.md`.
|
||||
Reference in New Issue
Block a user