Initial Commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# 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`).
|
||||
Reference in New Issue
Block a user