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 aPluginDescriptorfrom@suaveplan/api-plugin. The descriptor's lifecycle hooks implement the canonical 6-steponInitflow shared by every@suaveplan/api-plugin-*package.RateLimitPluginOptions(Zod-validated viaRateLimitPluginOptionsSchema):{ 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)viaContextVariableMapaugmentation 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? })— assemblesX-RateLimit-*and/orRateLimit-*triples plusRetry-After.buildRateLimitProblem(decision)— RFC 7807application/problem+jsonbody for a denied decision.
Errors + observability
RateLimitPluginError extends SuaveplanError<RateLimitPluginErrorCode>viadefineCodes("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}, counterrate_limit.denials_total{routeId,tenantId}, histogramrate_limit.check_duration_ms, counterrate_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
RateLimitStorageso operators can plug in a distributed backend, but no concrete Redis adapter ships in this package (that lives in@suaveplan/rate-limiterfollow-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
costoption onRouteRuleis 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 +instanceofnarrowing.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,appmissing → typed error, registers service, installs middleware.src/api-plugin-rate-limit.spec-conformance.test.ts— every code reachable from a real production path,expectTypeOfonRateLimitService/RateLimitDecision/ContextVariableMapaugmentation.tests/integration/full-app.test.ts— real@suaveplan/apihost + 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).