Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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`).

View File

@@ -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"`

View File

@@ -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