Files
suaveplan/openspec/archive/2026-04-24-enterprise-rate-limiter/tasks.md
2026-05-27 23:29:53 +01:00

208 lines
12 KiB
Markdown

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