# 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`. ### 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`: - `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` — 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`. - `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` 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`.