208 lines
12 KiB
Markdown
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`.
|