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

8.6 KiB

Add @suaveplan/api-plugin-rate-limit — Hono rate-limit middleware as a definePlugin

Package: @suaveplan/api-plugin-rate-limit (NEW, server-tier) Status: PROPOSED Type: new-package

  • Spec delta: ./specs/api-plugin-rate-limit/spec.md

Why

Genesis ships @suaveplan/api (orchestrator), @suaveplan/api-plugin (definePlugin foundation), and @suaveplan/rate-limiter (universal rate-limit core with TokenBucket / SlidingWindow / FixedWindow algorithms, OTel instrumentation, pluggable storage). What's missing is the glue that turns the universal limiter into a one-line api.use(...) install on the @suaveplan/api Hono host, with per-route + per-tenant configuration, IP whitelist, parameterised route matching, RFC 7807 Problem+JSON 429 body, and standard X-RateLimit-* / RateLimit-* / Retry-After headers.

The legacy upstream api-plugin-rate-limit (audited in enterprise/api-plugin-rate-limit.md) had visible gaps the audit called out specifically: tenant multiplier was only used as a bucket-key prefix (never applied to the numeric limit), the path matcher supported only exact match + trailing /* (no :id params), and no HTTP middleware shipped at all — callers had to wire service.check() by hand from their routers. This genesis package fixes all three from day one.

What changes

Net-new package at 0.1.0. ADDED:

Plugin factory + descriptor surface

  • createRateLimitPlugin(options: RateLimitPluginOptions): PluginDescriptor<RateLimitPluginConfig> — Zod-validated factory returning a PluginDescriptor from @suaveplan/api-plugin. The descriptor's lifecycle hooks implement the canonical 6-step onInit flow shared by every @suaveplan/api-plugin-* package.
  • RateLimitPluginOptions (Zod-validated via RateLimitPluginOptionsSchema): { defaultLimit, defaultWindowMs, routes?, tenants?, ipWhitelist?, keyResolver?, storage?, headerStyle?, emitRetryAfter?, logger? }.
  • RateLimitPluginConfig — the resolved options.

RateLimitService (registered in the host ServiceRegistry)

  • createRateLimitService({ config, storage, clock, logger }): RateLimitService — exported factory so sibling plugins can build their own service instance if needed.
  • RateLimitService.check({ ip, tenantId?, path, method }): Promise<RateLimitDecision> — returns the decision the middleware consumes.
  • RateLimitService.reset(key) — resets the counter for a resolved key.
  • ServiceRegistry module augmentation: declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { rateLimitService: RateLimitService } }.

Hono middleware

  • rateLimit({ service, headerStyle?, emitRetryAfter?, resolveIp?, resolveTenantId?, onDenied? }): MiddlewareHandler — Hono middleware factory.
  • Sets c.set("rateLimit", decision) via ContextVariableMap augmentation so downstream handlers can read the decision typed.
  • Returns 429 + Problem+JSON body on denial; emits configured headers on every response.

Path matcher

  • compilePathPattern(source) / matchPath(pattern, path) / isPathMatch(source, path) — Hono-compatible path-pattern compilation and matching: exact, trailing wildcard (/*), inline wildcard (/api/*), named params (:id).

Headers + Problem+JSON

  • buildRateLimitHeaders({ decision, style, emitRetryAfter, now? }) — assembles X-RateLimit-* and/or RateLimit-* triples plus Retry-After.
  • buildRateLimitProblem(decision) — RFC 7807 application/problem+json body for a denied decision.

Errors + observability

  • RateLimitPluginError extends SuaveplanError<RateLimitPluginErrorCode> via defineCodes("RATE_LIMIT_PLUGIN", [...]). Codes: INVALID_INPUT, SERVICE_NOT_AVAILABLE, LIMIT_EXCEEDED, STORAGE_FAILURE, INVALID_PATH_PATTERN.
  • OTel instruments: counter rate_limit.checks_total{outcome,routeId,tenantId}, counter rate_limit.denials_total{routeId,tenantId}, histogram rate_limit.check_duration_ms, counter rate_limit.bypassed_total{reason}.

Subpath exports

., ./plugin/plugin, ./middleware/rate-limit, ./services/rate-limit-service, ./schemas/options, ./path-matcher/path-matcher, ./headers/headers, ./error-codes/error-codes, ./otel/instruments, ./types/types.

Out of scope

  • Redis / SurrealDB storage adapters. The plugin accepts any caller-supplied RateLimitStorage so operators can plug in a distributed backend, but no concrete Redis adapter ships in this package (that lives in @suaveplan/rate-limiter follow-ups).
  • Adaptive rate limiting based on observed system load. Architectural follow-up — out of scope for the initial ship.
  • Weighted request cost (a route charges N tokens per call). The underlying limiter has one token per request; a cost option on RouteRule is a follow-up.
  • Admin endpoint for dynamic tenant overrides. The current surface treats tenants as a boot-time array; runtime mutation is a separate plugin.
  • Honey-pot IPs / auto-ban. Operational policy belongs above the rate-limit layer.

Source mapping

This change derives from ../suaveplan/enterprise/api-plugin-rate-limit.md.

Requirement Source lines Notes
Wraps @suaveplan/rate-limiter as an @suaveplan/api plugin L13 verbatim
Per-route configuration L13 verbatim
Per-tenant multiplier + bypass L13, L31, L44 extended (multiplier APPLIED to limit, not just bucket key — fixes audit smell)
IP whitelist L13 verbatim
Token-bucket / sliding-window algorithm choice L13 inherited via @suaveplan/rate-limiter defaults
X-RateLimit-* / Retry-After headers L13, L50 extended with RFC 9238 style and Problem+JSON body
RateLimitService registered on plugin context L13 extended into ServiceRegistry slot (genesis pattern)
service.check({ ip, tenantId, key, path, method }) shape L13 reworded — drops key (resolver derives it)
MemoryStorage default + pluggable RateLimitStorage L13, L33, L45 extended (legacy only wired Memory)
Tenant multiplier APPLIED to numeric limit L35, L44 fixes legacy smell
Parameterised path matcher (:id support) L34, L46 fixes legacy smell
Hono middleware shipped L36, L47 fixes legacy gap
OTel counters (allowed / denied / bypassed) + duration histogram L48 extended
RFC 7807 Problem+JSON 429 body L65 (acceptance row 7) extended
Zod-validated boundary options L13 (genesis-wide policy), L55 genesis-wide policy
RateLimitPluginError + 5 codes L55 genesis-wide policy
ServiceRegistry module augmentation L13 genesis pattern (post-audit)

Requirements with no source line

Requirement Justification
100% test coverage threshold Genesis-wide policy
@suaveplan/testing/runner test imports Genesis-wide policy (CLAUDE.md §11)
Sibling .md per implementation file Genesis-wide policy (AGENTS.md §4)
Five-files-per-feature layout Genesis-wide policy (CLAUDE.md)
Subpath exports per feature module Genesis-wide policy (AGENTS.md §6)
6-step canonical onInit Genesis pattern (mirrors api-plugin-auth)

Verification approach

  • src/path-matcher/path-matcher.test.ts — every pattern form + match outcome.
  • src/headers/headers.test.ts — every header style + Problem+JSON body shape.
  • src/error-codes/error-codes.test.ts — every code reachable + instanceof narrowing.
  • src/otel/instruments.test.ts — lazy build + cache + reset against in-memory MeterProvider.
  • src/schemas/options.test.ts — boundary validation + defaults applied.
  • src/services/rate-limit-service.test.ts — IP whitelist, tenant bypass, tenant multiplier applied to limit, OTel emission, storage failure wrap.
  • src/middleware/rate-limit.test.ts — 429 emission, headers on allowed AND denied responses, decision attached to context.
  • src/plugin/plugin.test.ts — Zod boundary, 6-step onInit, app missing → typed error, registers service, installs middleware.
  • src/api-plugin-rate-limit.spec-conformance.test.ts — every code reachable from a real production path, expectTypeOf on RateLimitService / RateLimitDecision / ContextVariableMap augmentation.
  • tests/integration/full-app.test.ts — real @suaveplan/api host + the plugin + multiple routes; verifies 429 emission, Retry-After header, per-tenant isolation, header families on every response.
  • tests/dep-graph.test.ts — deps + 10 subpaths + sibling .md per feature.

Migration impact

None — additive only. No package in genesis currently depends on this name.

Impact

Affected dependents

No dependents (verified by bunx turbo ls --filter=...@suaveplan/api-plugin-rate-limit).