211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-plugin-rate-limit (add-api-plugin-rate-limit)
|
|
|
|
**Package**: `@suaveplan/api-plugin-rate-limit`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: `createRateLimitPlugin` returns a typed `PluginDescriptor`
|
|
|
|
The package SHALL export `createRateLimitPlugin(options): PluginDescriptor<RateLimitPluginConfig>`. The factory SHALL `safeParse` the options against `RateLimitPluginOptionsSchema` and SHALL throw `RateLimitPluginError(INVALID_INPUT)` carrying the underlying `ZodError` as `cause` on failure.
|
|
|
|
#### Scenario: factory returns a descriptor with name "rate-limit"
|
|
|
|
- **WHEN** caller passes valid options
|
|
- **THEN** the returned descriptor has `name === "rate-limit"`, `version === "0.1.0"`, and `defaults` populated with the resolved config
|
|
|
|
#### Scenario: invalid options surface as INVALID_INPUT
|
|
|
|
- **WHEN** caller passes `{ defaultLimit: -1 }` (negative)
|
|
- **THEN** the call throws `RateLimitPluginError` with `code === RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"returns a descriptor with name rate-limit and the resolved defaults"` AND `src/plugin/plugin.test.ts::"throws INVALID_INPUT when defaultLimit is negative"`
|
|
|
|
---
|
|
|
|
### Requirement: canonical 6-step `onInit` lifecycle
|
|
|
|
The plugin's `onInit` SHALL execute the canonical 6 steps in order: resolve `app` + `clock` + `logger` from the `ServiceRegistry`; build the `RateLimitStorage` (caller-supplied or bundled `MemoryStorage`); construct the `RateLimitService`; register `rateLimitService` in the `ServiceRegistry`; install the global `rateLimit` middleware on `ctx.app`; emit a single info log naming the resolved policy summary. If `app` is absent, `onInit` SHALL throw `RateLimitPluginError(SERVICE_NOT_AVAILABLE)` with `context.missing === ["app"]`.
|
|
|
|
#### Scenario: missing `app` slot raises SERVICE_NOT_AVAILABLE
|
|
|
|
- **WHEN** the plugin's `onInit` is invoked with a context whose registry has no `app`
|
|
- **THEN** it throws `RateLimitPluginError(SERVICE_NOT_AVAILABLE)`
|
|
|
|
#### Scenario: full lifecycle installs middleware and registers service
|
|
|
|
- **WHEN** the plugin's `onInit` runs against a real registry with `app`
|
|
- **THEN** `ctx.getService("rateLimitService")` returns the constructed service and the app's middleware stack contains the rate-limit handler
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"throws SERVICE_NOT_AVAILABLE when the app slot is missing"` AND `src/plugin/plugin.test.ts::"registers rateLimitService and installs the global middleware"`
|
|
|
|
---
|
|
|
|
### Requirement: tenant multiplier is APPLIED to the numeric limit
|
|
|
|
`RateLimitService.check` SHALL multiply the matched rule's `limit` by the resolved tenant's `multiplier` (defaulting to 1) and round to the nearest integer. The decision's `limit` field SHALL reflect the multiplied value, and the underlying `RateLimiterInstance` SHALL be constructed with the multiplied value so the algorithm enforces it.
|
|
|
|
#### Scenario: multiplier 10 raises a default limit of 5 to 50
|
|
|
|
- **WHEN** tenant `"enterprise"` has `multiplier: 10` and `defaultLimit` is 5
|
|
- **THEN** the first 50 requests for that tenant are allowed and the 51st is denied
|
|
|
|
**Verified by:** `src/services/rate-limit-service.test.ts::"applies tenant multiplier to the numeric limit"`
|
|
|
|
---
|
|
|
|
### Requirement: IP whitelist short-circuits the check
|
|
|
|
`RateLimitService.check` SHALL return `{ allowed: true, bypassed: true }` without consulting storage when the resolved IP is in `config.ipWhitelist`. The `bypassed_total{reason: "ip-whitelist"}` counter SHALL be incremented.
|
|
|
|
#### Scenario: whitelisted IP is allowed regardless of policy
|
|
|
|
- **WHEN** `config.ipWhitelist = ["127.0.0.1"]` and `defaultLimit = 0`
|
|
- **THEN** `service.check({ ip: "127.0.0.1", path: "/", method: "GET" })` returns `{ allowed: true, bypassed: true }`
|
|
|
|
**Verified by:** `src/services/rate-limit-service.test.ts::"short-circuits when the IP is whitelisted"`
|
|
|
|
---
|
|
|
|
### Requirement: tenant bypass short-circuits the check
|
|
|
|
`RateLimitService.check` SHALL return `{ allowed: true, bypassed: true }` without consulting storage when the resolved tenant's `bypass` flag is `true`. The `bypassed_total{reason: "tenant-bypass"}` counter SHALL be incremented.
|
|
|
|
#### Scenario: bypass tenant always allowed
|
|
|
|
- **WHEN** `config.tenants = [{ tenantId: "internal", bypass: true }]` and `defaultLimit = 0`
|
|
- **THEN** every request from tenant `"internal"` returns `{ allowed: true, bypassed: true }`
|
|
|
|
**Verified by:** `src/services/rate-limit-service.test.ts::"short-circuits when the tenant has bypass set"`
|
|
|
|
---
|
|
|
|
### Requirement: parameterised path matcher
|
|
|
|
`compilePathPattern` SHALL accept four pattern forms: exact literal, trailing `/*` wildcard, inline `*` segment wildcard, and named `:name` params. `matchPath` SHALL return `{ matched: true, params: { name: value } }` for the named-param form. Unparseable patterns SHALL throw `RateLimitPluginError(INVALID_PATH_PATTERN)`.
|
|
|
|
#### Scenario: `:id` named-param captures the segment
|
|
|
|
- **WHEN** `matchPath(compilePathPattern("/api/users/:id"), "/api/users/42")` is called
|
|
- **THEN** the result is `{ matched: true, params: { id: "42" } }`
|
|
|
|
#### Scenario: trailing `/*` matches sub-paths
|
|
|
|
- **WHEN** `isPathMatch("/api/users/*", "/api/users/42/profile")` is called
|
|
- **THEN** it returns `true`
|
|
|
|
**Verified by:** `src/path-matcher/path-matcher.test.ts::"captures :id named param"` AND `src/path-matcher/path-matcher.test.ts::"matches trailing wildcard sub-paths"`
|
|
|
|
---
|
|
|
|
### Requirement: Hono middleware emits 429 + Problem+JSON + Retry-After on denial
|
|
|
|
The `rateLimit` middleware SHALL return an HTTP 429 response with `Content-Type: application/problem+json` and a body matching `RateLimitProblemBody` on denial. The response SHALL carry the configured `X-RateLimit-*` and/or `RateLimit-*` headers AND, when `emitRetryAfter` is true, a `Retry-After` header carrying the rounded-up seconds until next allowed request.
|
|
|
|
#### Scenario: denied request gets 429 + problem body + Retry-After
|
|
|
|
- **WHEN** the service denies the request and `emitRetryAfter` is `true`
|
|
- **THEN** the response status is 429, `Content-Type` is `application/problem+json`, the body parses as `RateLimitProblemBody`, and `Retry-After` is present
|
|
|
|
**Verified by:** `src/middleware/rate-limit.test.ts::"returns 429 + problem body + Retry-After when denied"`
|
|
|
|
---
|
|
|
|
### Requirement: rate-limit headers emitted on every response
|
|
|
|
The `rateLimit` middleware SHALL set the configured header families on the response on BOTH allowed and denied paths. `headerStyle: "x-rate-limit"` emits the legacy triple; `"rfc-9238"` emits the draft RFC triple; `"both"` emits both.
|
|
|
|
#### Scenario: allowed request gets X-RateLimit-Remaining
|
|
|
|
- **WHEN** an allowed request flows through the middleware with `headerStyle: "x-rate-limit"`
|
|
- **THEN** the response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers
|
|
|
|
#### Scenario: "both" style emits both families
|
|
|
|
- **WHEN** an allowed request flows through with `headerStyle: "both"`
|
|
- **THEN** the response carries both `X-RateLimit-*` and `RateLimit-*` triples
|
|
|
|
**Verified by:** `src/middleware/rate-limit.test.ts::"emits X-RateLimit headers on allowed responses"` AND `src/middleware/rate-limit.test.ts::"emits both header families when style is both"`
|
|
|
|
---
|
|
|
|
### Requirement: per-tenant isolation
|
|
|
|
Independent tenants SHALL maintain independent rate-limit counters. Exhausting tenant `A`'s budget MUST NOT affect tenant `B`'s remaining budget.
|
|
|
|
#### Scenario: tenant A's exhaustion does not affect tenant B
|
|
|
|
- **WHEN** tenant `A` exhausts a `limit: 3` policy and tenant `B` makes its first request immediately afterwards
|
|
- **THEN** tenant `B`'s request is allowed with `remaining: 2`
|
|
|
|
**Verified by:** `tests/integration/full-app.test.ts::"isolates rate-limit counters per tenant"`
|
|
|
|
---
|
|
|
|
### Requirement: storage failure wrapped as STORAGE_FAILURE
|
|
|
|
`RateLimitService.check` SHALL wrap any rejection from the injected `RateLimitStorage` as `RateLimitPluginError(STORAGE_FAILURE)` carrying the original error as `cause`.
|
|
|
|
#### Scenario: throwing storage adapter surfaces STORAGE_FAILURE
|
|
|
|
- **WHEN** the storage adapter's `.get` rejects with a custom error
|
|
- **THEN** `service.check(...)` throws `RateLimitPluginError(STORAGE_FAILURE)` with the custom error attached as `cause`
|
|
|
|
**Verified by:** `src/services/rate-limit-service.test.ts::"wraps storage failure as STORAGE_FAILURE"`
|
|
|
|
---
|
|
|
|
### Requirement: OTel instruments emitted on every check
|
|
|
|
`RateLimitService.check` SHALL increment `rate_limit.checks_total{outcome,routeId,tenantId}` on every code path (allowed / denied / bypassed). Denied checks SHALL also increment `rate_limit.denials_total{routeId,tenantId}`. Bypassed checks SHALL increment `rate_limit.bypassed_total{reason}`. The check duration SHALL be recorded against `rate_limit.check_duration_ms{outcome}`.
|
|
|
|
#### Scenario: denied request increments denials_total
|
|
|
|
- **WHEN** a denied check completes
|
|
- **THEN** the next exporter flush includes both `checks_total{outcome=denied}` and `denials_total` data points
|
|
|
|
**Verified by:** `src/services/rate-limit-service.test.ts::"emits OTel counters and histogram"`
|
|
|
|
---
|
|
|
|
### Requirement: `rateLimitService` registered in the host ServiceRegistry
|
|
|
|
After `onInit` completes, `ctx.getService("rateLimitService")` SHALL return the constructed `RateLimitService`. A module augmentation against `@suaveplan/api-plugin/service-registry` SHALL type the slot as `RateLimitService`.
|
|
|
|
#### Scenario: getService returns the live service
|
|
|
|
- **WHEN** the plugin's `onInit` has completed
|
|
- **THEN** `ctx.getService("rateLimitService")` returns the service instance with the live `config`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"registers rateLimitService and installs the global middleware"` AND `src/api-plugin-rate-limit.spec-conformance.test.ts::"ServiceRegistry augmentation types rateLimitService"`
|
|
|
|
---
|
|
|
|
### Requirement: every error code reachable from a production path
|
|
|
|
Every code in `RATE_LIMIT_PLUGIN_CODES` SHALL be thrown by — or carried on `Report.code` of — at least one production code path inside `src/`. The conformance test SHALL pin this contract.
|
|
|
|
#### Scenario: every code is reachable
|
|
|
|
- **WHEN** the conformance test enumerates `RATE_LIMIT_PLUGIN_CODES`
|
|
- **THEN** it locates a real throw site or surface for each code
|
|
|
|
**Verified by:** `src/api-plugin-rate-limit.spec-conformance.test.ts::"every code in RATE_LIMIT_PLUGIN_CODES is reachable from src"`
|
|
|
|
---
|
|
|
|
### Requirement: `RateLimitDecision` attached to Hono context
|
|
|
|
The `rateLimit` middleware SHALL call `c.set("rateLimit", decision)` before returning so downstream handlers read the typed decision via `c.get("rateLimit")`. A `ContextVariableMap` module augmentation SHALL declare the slot type.
|
|
|
|
#### Scenario: downstream handler reads the decision
|
|
|
|
- **WHEN** the middleware allows a request and `next()` runs
|
|
- **THEN** `c.get("rateLimit")` returns the same `RateLimitDecision` the middleware computed
|
|
|
|
**Verified by:** `src/middleware/rate-limit.test.ts::"attaches the decision to the Hono context"`
|