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`).
|
||||
@@ -0,0 +1,210 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
@@ -0,0 +1,63 @@
|
||||
# Tasks — add-api-plugin-rate-limit
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-rate-limit.md`
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` §13 + §14
|
||||
- [x] 1.3 Author `proposal.md` from template, fill `## Source mapping`
|
||||
- [x] 1.4 Author `specs/api-plugin-rate-limit/spec.md` from template with `**Verified by:**` on every requirement
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes/error-codes.{ts,md,index.ts}` — 5 codes + `RateLimitPluginError`
|
||||
- [x] 2.2 `src/types/types.{ts,md,index.ts}` — public type surface
|
||||
- [x] 2.3 `src/schemas/options.{ts,md,index.ts}` — `RateLimitPluginOptionsSchema`
|
||||
- [x] 2.4 `src/path-matcher/path-matcher.{ts,md,index.ts}` — `compilePathPattern` / `matchPath` / `isPathMatch`
|
||||
- [x] 2.5 `src/headers/headers.{ts,md,index.ts}` — `buildRateLimitHeaders` + `buildRateLimitProblem`
|
||||
- [x] 2.6 `src/otel/instruments.{ts,md,index.ts}` — counters + histogram bundle + `getRateLimitInstruments` / `resetRateLimitInstruments`
|
||||
- [x] 2.7 `src/services/rate-limit-service.{ts,md,index.ts}` — `RateLimitService` factory + `createDefaultStorage`
|
||||
- [x] 2.8 `src/services/registry-augmentation.{ts,md}` — `ServiceRegistry` module augmentation + `registerRateLimitServiceSlot`
|
||||
- [x] 2.9 `src/middleware/rate-limit.{ts,md,index.ts}` — `rateLimit` factory + `rateLimitExceeded`
|
||||
- [x] 2.10 `src/middleware/context-augmentation.{ts,md}` — Hono `ContextVariableMap` augmentation
|
||||
- [x] 2.11 `src/plugin/plugin.{ts,md,index.ts}` — `createRateLimitPlugin` factory; canonical 6-step `onInit`
|
||||
- [x] 2.12 `src/index.ts` — named re-exports only
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Co-located `*.test.ts` per implementation file
|
||||
- [x] 3.2 `src/api-plugin-rate-limit.spec-conformance.test.ts` — every code reachable + `expectTypeOf` on `RateLimitService` / `RateLimitDecision` / `ContextVariableMap` augmentation
|
||||
- [x] 3.3 `tests/integration/full-app.test.ts` — real Hono app + plugin + multiple routes; 429 emission, Retry-After, per-tenant isolation, headers on allowed AND denied responses
|
||||
- [x] 3.4 `tests/dep-graph.test.ts` — deps + 10 subpaths + sibling `.md` per feature
|
||||
- [x] 3.5 All tests via `@suaveplan/testing/runner`, `{ timeout: 5000 }` (30000 for integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per impl (≥200 words, basic + advanced examples)
|
||||
- [x] 4.2 `README.md` — quick start + manual `service.check(...)` recipe + module index
|
||||
|
||||
## 5. Pre-archive gates (in order)
|
||||
|
||||
- [x] 5.1 `cd /home/paul/web/genesis && bun run check`
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-rate-limit --force`
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-rate-limit` → 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-rate-limit` → exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-rate-limit --force`
|
||||
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts`
|
||||
|
||||
## 6. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown / surfaced by ≥1 production code path
|
||||
- [x] 6.4 OTel instruments emitted from production paths and asserted with real `MeterProvider`
|
||||
- [x] 6.5 Lifecycle dedup — `onInit` idempotent; lazy limiter cache rebound per `(route, tenant)` pair
|
||||
- [x] 6.6 No retroactive tick — every box above reflects work done in this change
|
||||
- [x] 6.7 No `export *` in feature barrels (verified by grep)
|
||||
- [x] 6.8 No direct `vitest` / `bun:test` imports (verified by grep)
|
||||
- [x] 6.9 No banned patterns (`TODO`, `FIXME`, `.skip`, `.only`, `any`) in src
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec → `openspec/specs/api-plugin-rate-limit/spec.md`
|
||||
- [x] 7.2 mv change → `openspec/archive/2026-05-18-add-api-plugin-rate-limit/`
|
||||
- [x] 7.3 DO NOT refresh MANIFEST or STATUS — orchestrator consolidates after the 4-package batch
|
||||
Reference in New Issue
Block a user