Files
2026-05-27 23:29:53 +01:00

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", 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"