11 KiB
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", anddefaultspopulated with the resolved config
Scenario: invalid options surface as INVALID_INPUT
- WHEN caller passes
{ defaultLimit: -1 }(negative) - THEN the call throws
RateLimitPluginErrorwithcode === 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
onInitis invoked with a context whose registry has noapp - THEN it throws
RateLimitPluginError(SERVICE_NOT_AVAILABLE)
Scenario: full lifecycle installs middleware and registers service
- WHEN the plugin's
onInitruns against a real registry withapp - 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"hasmultiplier: 10anddefaultLimitis 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"]anddefaultLimit = 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 }]anddefaultLimit = 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
emitRetryAfteristrue - THEN the response status is 429,
Content-Typeisapplication/problem+json, the body parses asRateLimitProblemBody, andRetry-Afteris 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, andX-RateLimit-Resetheaders
Scenario: "both" style emits both families
- WHEN an allowed request flows through with
headerStyle: "both" - THEN the response carries both
X-RateLimit-*andRateLimit-*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
Aexhausts alimit: 3policy and tenantBmakes its first request immediately afterwards - THEN tenant
B's request is allowed withremaining: 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
.getrejects with a custom error - THEN
service.check(...)throwsRateLimitPluginError(STORAGE_FAILURE)with the custom error attached ascause
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}anddenials_totaldata 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
onInithas completed - THEN
ctx.getService("rateLimitService")returns the service instance with the liveconfig
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 sameRateLimitDecisionthe middleware computed
Verified by: src/middleware/rate-limit.test.ts::"attaches the decision to the Hono context"