Initial Commit
This commit is contained in:
47
openspec/archive/2026-05-26-api-rate-limit/proposal.md
Normal file
47
openspec/archive/2026-05-26-api-rate-limit/proposal.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# `@suaveplan/api-rate-limit` — per-tenant / per-principal / per-IP token-bucket middleware
|
||||
|
||||
**Package**: `@suaveplan/api-rate-limit`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
Per design doc §6 step 4a, the per-request pipeline includes an optional rate-limit gate between tenant resolution and RBAC. Plugin routes opt in via `rateLimit: { rpm, burst, key }`. The host needs a deterministic, in-memory-first implementation that can be lifted onto a shared store (Redis) for multi-host fleets later without changing the contract.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createRateLimitMiddleware({ store, clock })` — produces a `Middleware` that consumes the route's `rateLimit` option (rpm, burst, key selector). Implements a token-bucket per key. 429 on deny with `Retry-After`. Emits `api.rate_limit.exceeded_total{plugin, route, key_kind}`.
|
||||
- ADDED: `RateLimitKeySelector` — discriminated union: `"tenant"`, `"principal"`, `"ip"`, `{ kind: "custom", select: (ctx) => string }`. The selector is per route; default is `"principal"` when authenticated, else `"ip"`.
|
||||
- ADDED: `RateLimitStoreLike` — abstract storage interface (`take(key, weight) → { allowed, retryAfterMs, remaining }`). Default in-memory implementation with TTL-evict ring buffer. Pluggable; an `api-rate-limit-redis` adapter can land in a later change.
|
||||
- ADDED: Default host budgets configurable in `host.json` — global per-tenant default, global per-IP default. Route-declared budgets override.
|
||||
- ADDED: Error codes (`RATELIMIT_*`): `EXCEEDED`, `STORE_UNAVAILABLE`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Distributed/Redis backend — abstract interface present, adapter ships separately.
|
||||
- Sliding-window or leaky-bucket alternatives — token bucket only in v1.
|
||||
- Per-plugin global rate limiting (across all its routes) — out of scope, plugins can compose multiple route declarations.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| Per-request rate-limit gate (optional) | §6 step 4a |
|
||||
| Per-tenant / per-principal / per-IP keys | §6 step 4a |
|
||||
| `api.rate_limit.exceeded_total` counter | §12.3, §12.4 |
|
||||
| 429 + `Retry-After` response | §6 |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/middleware/middleware.test.ts` — token-bucket allow / deny matrices with `FakeClock`, including burst behaviour and refill correctness.
|
||||
- `src/middleware/middleware.spec-conformance.test.ts` — pins observability: `api.rate_limit.exceeded_total` increments only on the denial transition (not every subsequent denied call within the same window); `Retry-After` header matches the bucket's refill estimate.
|
||||
- `src/store/memory-store.test.ts` — TTL eviction, key isolation, concurrent take correctness.
|
||||
- `src/selector/selector.test.ts` — every key kind resolves correctly; missing principal falls back to IP.
|
||||
- `tests/dep-graph.test.ts` — runtime deps (`api-types`, `error`, `logger`, `types` Clock).
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. `api-host` wires this middleware when any route declares `rateLimit` or when host-level defaults are set.
|
||||
Reference in New Issue
Block a user