# 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` — 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` — 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` 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`).