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.
|
||||
@@ -0,0 +1,88 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-rate-limit (api-rate-limit)
|
||||
|
||||
**Package**: `@suaveplan/api-rate-limit`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Token-bucket gate with route-declared rpm and burst
|
||||
|
||||
The package SHALL produce a `Middleware` enforcing a token bucket per key, parametrised by the route's `rateLimit.rpm` and `rateLimit.burst`. The bucket SHALL refill at `rpm/60` tokens per second up to `burst` tokens. Each request consumes one token; an empty bucket returns 429 with a `Retry-After` header equal to the refill ETA in seconds.
|
||||
|
||||
#### Scenario: burst window allows N requests
|
||||
|
||||
- WHEN a route declares `rpm: 60, burst: 5` and 5 requests arrive within 100ms for the same key
|
||||
- THEN all 5 requests pass
|
||||
|
||||
#### Scenario: 6th request in burst window is rejected
|
||||
|
||||
- WHEN a 6th request arrives in the same 100ms window
|
||||
- THEN the response is 429 with `Retry-After` set to a positive integer AND `api.rate_limit.exceeded_total{key_kind}` increments by 1
|
||||
|
||||
**Verified by:** `src/middleware/middleware.test.ts::"burst window allows N requests"` AND `src/middleware/middleware.test.ts::"sixth request in burst window is rejected with Retry-After"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Refill is deterministic against a FakeClock
|
||||
|
||||
The package SHALL refill the bucket based on the supplied `Clock`. Advancing the clock by 1 second on an empty bucket with `rpm: 60` MUST restore exactly 1 token.
|
||||
|
||||
#### Scenario: clock advance refills tokens
|
||||
|
||||
- WHEN the bucket is empty and `clock.advance("1s")` runs
|
||||
- THEN the next request consuming 1 token succeeds
|
||||
|
||||
**Verified by:** `src/middleware/middleware.test.ts::"clock advance refills tokens deterministically"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Key selector resolves per route
|
||||
|
||||
The package SHALL resolve the rate-limit key from the route's `rateLimit.key` option. Selector kinds: `"tenant"`, `"principal"`, `"ip"`, and `{ kind: "custom", select }`. When the route does not specify a kind, the default is `"principal"` if a principal is resolved, else `"ip"`.
|
||||
|
||||
#### Scenario: tenant key isolates buckets
|
||||
|
||||
- WHEN two requests with different `tenantId` hit a route declaring `key: "tenant"`
|
||||
- THEN each tenant has its own bucket AND neither affects the other
|
||||
|
||||
#### Scenario: default falls back to IP when no principal
|
||||
|
||||
- WHEN no principal is resolved AND the route does not specify a key
|
||||
- THEN the IP address is used
|
||||
|
||||
**Verified by:** `src/selector/selector.test.ts::"tenant key isolates buckets"` AND `src/selector/selector.test.ts::"default falls back to IP without principal"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Denial counter increments only on the denial transition
|
||||
|
||||
The package SHALL increment `api.rate_limit.exceeded_total` once per denied request — every denied request contributes one increment. The counter MUST be labelled with `plugin`, `route`, and `key_kind`.
|
||||
|
||||
#### Scenario: each denied request increments by exactly 1
|
||||
|
||||
- WHEN 10 requests are denied for the same key within the same window
|
||||
- THEN the counter increases by exactly 10 AND each increment carries the correct label set
|
||||
|
||||
**Verified by:** `src/middleware/middleware.spec-conformance.test.ts::"denial increments api.rate_limit.exceeded_total exactly once per denied request"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Host-level defaults override route declarations cleanly
|
||||
|
||||
The package SHALL accept default budgets from `host.json` (per-tenant default rpm/burst, per-IP default rpm/burst). Route-declared budgets MUST override the defaults. When a route declares no `rateLimit` and no host default applies, the middleware is a no-op.
|
||||
|
||||
#### Scenario: route override beats host default
|
||||
|
||||
- WHEN a host default is `rpm: 100` and a route declares `rpm: 10`
|
||||
- THEN the route's limit (10) applies to that route
|
||||
|
||||
#### Scenario: no declaration, no host default → no-op
|
||||
|
||||
- WHEN neither route nor host declares a budget
|
||||
- THEN the middleware passes the request through without checking any bucket
|
||||
|
||||
**Verified by:** `src/middleware/middleware.test.ts::"route override beats host default"` AND `src/middleware/middleware.test.ts::"no-op when neither route nor host declares"`
|
||||
68
openspec/archive/2026-05-26-api-rate-limit/tasks.md
Normal file
68
openspec/archive/2026-05-26-api-rate-limit/tasks.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Tasks — `@suaveplan/api-rate-limit`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc (§6 step 4a, §12.3, §12.4)
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md`
|
||||
- [x] 1.4 Draft `specs/api-rate-limit/spec.md`
|
||||
- [x] 1.5 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/api/api-rate-limit/`
|
||||
- [x] 2.2 `src/middleware/` — `createRateLimitMiddleware`
|
||||
- [x] 2.3 `src/selector/` — `RateLimitKeySelector` resolution
|
||||
- [x] 2.4 `src/store/memory-store/` — default in-memory token-bucket store
|
||||
- [x] 2.5 `src/store/store-like.ts` — `RateLimitStoreLike` abstract interface (types only)
|
||||
- [x] 2.6 `src/error-codes.ts`
|
||||
- [x] 2.7 `src/instruments/` — `api.rate_limit.exceeded_total`
|
||||
- [x] 2.8 `src/index.ts` — explicit named re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/middleware/middleware.test.ts`
|
||||
- [x] 3.2 `src/middleware/middleware.spec-conformance.test.ts`
|
||||
- [x] 3.3 `src/store/memory-store/memory-store.test.ts`
|
||||
- [x] 3.4 `src/selector/selector.test.ts`
|
||||
- [x] 3.5 `tests/dep-graph.test.ts`
|
||||
- [x] 3.6 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per implementation `.ts`
|
||||
- [x] 4.2 `README.md` `## Modules`
|
||||
- [x] 4.3 Update `CLAUDE.md` package listing
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check`
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-rate-limit`
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-rate-limit` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-rate-limit`
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-rate-limit`
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` on `RateLimitKeySelector`
|
||||
- [x] 6.2 Zod schemas wired at route-option ingestion boundary
|
||||
- [x] 6.3 Every `RATELIMIT_*` code thrown by a production path
|
||||
- [x] 6.4 Strict-mode: missing principal selector falls back to IP, never crashes
|
||||
- [x] 6.5 OTel counter asserted with real `MeterProvider` (denial transition increments only)
|
||||
- [x] 6.6 Lifecycle dedup — store key eviction is idempotent
|
||||
- [x] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-rate-limit/spec.md`
|
||||
- [x] 7.2 Move folder to archive
|
||||
- [x] 7.3 Tick `8.5` in umbrella tasks
|
||||
- [x] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user