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