first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
# @suaveplan/api-plugin-rate-limit - Rate-limit plugin for @suaveplan/api. Wraps @suaveplan/rate-limiter as a definePlugin descriptor: per-route + per-tenant configuration, parameterised path matcher, IP whitelist, tenant multiplier applied to numeric limit, Hono middleware factory, 429 + Retry-After + X-RateLimit-* / RateLimit-* headers, OTel counters, RFC 7807 Problem+JSON body. Registers `rateLimitService` in the host ServiceRegistry.
Rate-limit plugin for `@suaveplan/api`. Wraps `@suaveplan/rate-limiter`
as a `definePlugin` descriptor exposing per-route + per-tenant
configuration, parameterised path matching, IP whitelist, applied
tenant multiplier, Hono middleware, `X-RateLimit-*` / `RateLimit-*` /
`Retry-After` headers, RFC 7807 Problem+JSON 429 body, and OTel
counters / histogram. Registers a typed `rateLimitService` slot in the
host `ServiceRegistry` so sibling plugins can run manual checks.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable, namespaced error-code registry every failure path in `@suaveplan/api-plugin-rate-limit` reports th…
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable, namespaced error-code registry every failure path in `@suaveplan/api-plugin-rate-limit` reports th…
- [headers](./src/headers/headers.md) — Builds the HTTP response headers and Problem+JSON body the rate-limit middleware writes onto every response (allowed AN…
- [headers](./src/headers/headers.md) — Builds the HTTP response headers and Problem+JSON body the rate-limit middleware writes onto every response (allowed AN…
- [context-augmentation](./src/middleware/context-augmentation.md) — Augments Hono's `ContextVariableMap` interface so handlers calling `c.get("rateLimit")` get a typed `RateLimitDecision`…
- [rate-limit](./src/middleware/rate-limit.md) — Constructs a Hono `MiddlewareHandler` that consults a `RateLimitService` on every request, emits the configured `X-Rate…
- [otel](./src/otel/instruments.md) — Builds (and caches) the bundle of OpenTelemetry instruments the rate-limit plugin emits on every check. Operators wirin…
- [instruments](./src/otel/instruments.md) — Builds (and caches) the bundle of OpenTelemetry instruments the rate-limit plugin emits on every check. Operators wirin…
- [path-matcher](./src/path-matcher/path-matcher.md) — Compiles a Hono-style path pattern string into a regular expression
- [path-matcher](./src/path-matcher/path-matcher.md) — Compiles a Hono-style path pattern string into a regular expression
- [plugin](./src/plugin/plugin.md) — Factory that returns a typed `PluginDescriptor` for the rate-limit plugin, ready to be passed to `@suaveplan/api`'s plu…
- [plugin](./src/plugin/plugin.md) — Factory that returns a typed `PluginDescriptor` for the rate-limit plugin, ready to be passed to `@suaveplan/api`'s plu…
- [schemas](./src/schemas/options.md) — Validates the caller-supplied `RateLimitPluginOptions` at the boundary and fills in defaults for every optional field,…
- [options](./src/schemas/options.md) — Validates the caller-supplied `RateLimitPluginOptions` at the boundary and fills in defaults for every optional field,…
- [rate-limit-service](./src/services/rate-limit-service.md) — Wraps the universal `@suaveplan/rate-limiter` core in the plugin's policy layer: IP whitelist, tenant bypass, tenant mu…
- [registry-augmentation](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface owned by `@suaveplan/api-plugin/service-registry` so consumers see the `rateLi…
- [types](./src/types/types.md) — Defines the public type surface every other module in the package references — the caller-facing `RateLimitPluginOption…
- [types](./src/types/types.md) — Defines the public type surface every other module in the package references — the caller-facing `RateLimitPluginOption…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-rate-limit
```
## Quick start
```ts
import { createApi } from "@suaveplan/api";
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
const api = createApi();
api.use(
createRateLimitPlugin({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [
{
path: "/api/login",
method: "POST",
limit: 5,
windowMs: 60_000,
id: "login",
},
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
],
tenants: [
{ tenantId: "enterprise", multiplier: 10 },
{ tenantId: "internal", bypass: true },
],
ipWhitelist: ["127.0.0.1"],
headerStyle: "both",
})
);
```
Every request flowing through the host Hono app is now rate-limited.
Responses carry both legacy `X-RateLimit-Limit` / `X-RateLimit-Remaining`
/ `X-RateLimit-Reset` and IETF `RateLimit-Limit` / `RateLimit-Remaining`
/ `RateLimit-Reset` headers. Denied requests return `429` with
`Content-Type: application/problem+json` and a `Retry-After` header.
## Manual `service.check(...)` from sibling plugins
```ts
import {
rateLimitExceeded,
type RateLimitService,
} from "@suaveplan/api-plugin-rate-limit";
const service = ctx.getService("rateLimitService") as RateLimitService;
const decision = await service.check({
ip,
tenantId,
path: "/api/billing/invoice",
method: "POST",
});
if (!decision.allowed) throw rateLimitExceeded(decision);
```
## The 3-way plug-and-play contract
This package follows the same contract every `@suaveplan/api-plugin-*`
package follows: it owns its boundary Zod schemas
(`RateLimitPluginOptionsSchema`), registers a typed slot in the host
`ServiceRegistry` (`rateLimitService`), and installs a global Hono
middleware via `app.use("*", rateLimit({ service }))` from its
canonical 6-step `onInit` lifecycle (resolve services → build storage
→ construct service → register slot → install middleware → log
readiness).
## Modules
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) —
`RATE_LIMIT_PLUGIN_CODES` registry + `RateLimitPluginError`.
- [`src/types/types.md`](src/types/types.md) — public type surface.
- [`src/schemas/options.md`](src/schemas/options.md) — Zod schema for
caller-supplied options.
- [`src/path-matcher/path-matcher.md`](src/path-matcher/path-matcher.md)
— Hono-compatible path-pattern compilation + matching.
- [`src/headers/headers.md`](src/headers/headers.md) — header bag +
Problem+JSON body construction.
- [`src/otel/instruments.md`](src/otel/instruments.md) — OpenTelemetry
instruments bundle.
- [`src/services/rate-limit-service.md`](src/services/rate-limit-service.md)
— `RateLimitService` factory.
- [`src/services/registry-augmentation.md`](src/services/registry-augmentation.md)
— `ServiceRegistry` module augmentation.
- [`src/middleware/rate-limit.md`](src/middleware/rate-limit.md) —
Hono middleware factory.
- [`src/middleware/context-augmentation.md`](src/middleware/context-augmentation.md)
— Hono `ContextVariableMap` augmentation.
- [`src/plugin/plugin.md`](src/plugin/plugin.md) —
`createRateLimitPlugin` factory.
## Status
`0.1.0` — initial ship. Surface is final for the 0.1.x line.

View File

@@ -0,0 +1,98 @@
# `error-codes` — rate-limit plugin error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable, namespaced error-code registry every failure path
in `@suaveplan/api-plugin-rate-limit` reports through. The codes plug
into the `@suaveplan/error` ecosystem so consumers can pattern-match
errors programmatically, the host's error-handler middleware can
translate them into structured HTTP responses, and OpenTelemetry can
tag span events with the code attribute.
The package ships a single typed exception class, `RateLimitPluginError`,
that carries one of five codes plus the standard `SuaveplanError`
metadata (`cause`, `context`, `messageKey`, span-event adapter,
redaction-aware serialisation).
## Features
- Five stable codes: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`,
`LIMIT_EXCEEDED`, `STORAGE_FAILURE`, `INVALID_PATH_PATTERN`.
- Public constructor — middleware and callers may throw directly
without inheriting protected base-class constructor visibility.
- `RateLimitPluginError extends SuaveplanError<Code>` so `instanceof`
narrows the `code` discriminant for downstream branching.
- Codes registered through `defineCodes("RATE_LIMIT_PLUGIN", …)` so
the OpenSpec conformance gate finds every code in the catalogue.
## Usage: Basic
```ts
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "@suaveplan/api-plugin-rate-limit";
if (!options.defaultLimit) {
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT,
"defaultLimit is required",
{ context: { statusCode: 500 } }
);
}
```
## Usage: Advanced
```ts
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "@suaveplan/api-plugin-rate-limit";
try {
await service.check({ ip, tenantId, path, method });
} catch (err) {
if (
err instanceof RateLimitPluginError &&
err.code === RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE
) {
metrics.add("rate_limit.storage_failures", 1);
// Fail open — allow the request rather than denying it during
// a transient storage outage.
return next();
}
throw err;
}
```
## API Reference
- `RATE_LIMIT_PLUGIN_CODES` — `Readonly<Record<string, string>>` produced
by `defineCodes`. Keys: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`,
`LIMIT_EXCEEDED`, `STORAGE_FAILURE`, `INVALID_PATH_PATTERN`. Values
are the namespaced code strings (e.g. `"RATE_LIMIT_PLUGIN.INVALID_INPUT"`).
- `RateLimitPluginErrorCode` — union of every code value.
- `class RateLimitPluginError extends SuaveplanError<RateLimitPluginErrorCode>` —
carries `code`, `rateLimitCode` (alias), `cause`, `context`. Constructor:
`new RateLimitPluginError(code, message, options?)` where `options`
matches `SuaveplanErrorOptions`.
## Implementation Notes
- Codes are deliberately namespaced (`RATE_LIMIT_PLUGIN.*`) so the
registry survives package renaming or merging without collisions.
- `LIMIT_EXCEEDED` carries `statusCode: 429`, `retryAfter`, `limit`,
`remaining` on its context so the host's error-handler can render
the headers + Problem+JSON body without re-deriving them from the
decision.
- `STORAGE_FAILURE` is raised by `RateLimitService.check` whenever the
injected storage adapter rejects; the caller may decide to fail
open or closed.
- Adding a new code requires bumping the version, appending to the
`defineCodes` array, and wiring it into a production path — the
OpenSpec conformance gate will refuse to fold the spec otherwise.

View File

@@ -0,0 +1,94 @@
# `headers` — HTTP header + RFC 7807 body construction for rate-limit responses
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the HTTP response headers and Problem+JSON body the rate-limit
middleware writes onto every response (allowed AND denied). Centralises
the legacy `X-RateLimit-*` triple, the IETF draft `RateLimit-*`
triple, the `Retry-After` denial signal, and the RFC 7807 problem
detail body so the middleware itself stays thin and the formats are
unit-testable in isolation.
Both header families are supported so consumers can opt into the IETF
draft (RFC 9238) once their clients understand it without losing the
legacy headers their tooling already reads. The Problem+JSON body
matches the structure operators expect from a 429 — `type`, `title`,
`status`, `detail`, plus the live `limit`, `remaining`, `retryAfter`
fields clients use to retry intelligently.
## Features
- Three header styles: `"x-rate-limit"`, `"rfc-9238"`, or `"both"`.
- `Retry-After` always whole-second-rounded, never `0` (minimum 1).
- `X-RateLimit-Reset` carries Unix epoch seconds (legacy convention);
`RateLimit-Reset` carries seconds-until-reset (RFC convention).
- Problem+JSON body conforms to RFC 7807 `application/problem+json`
shape — `type` is a stable URI, `status: 429` is literal.
- Returned header bag is frozen so the middleware cannot mutate the
shared object by accident.
## Usage: Basic
```ts
import { buildRateLimitHeaders } from "@suaveplan/api-plugin-rate-limit";
const headers = buildRateLimitHeaders({
decision,
style: "x-rate-limit",
emitRetryAfter: true,
});
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
```
## Usage: Advanced
```ts
import {
buildRateLimitHeaders,
buildRateLimitProblem,
} from "@suaveplan/api-plugin-rate-limit";
if (!decision.allowed) {
const headers = buildRateLimitHeaders({
decision,
style: "both",
emitRetryAfter: true,
});
const body = buildRateLimitProblem(decision);
return new Response(JSON.stringify(body), {
status: 429,
headers: { ...headers, "Content-Type": "application/problem+json" },
});
}
```
## API Reference
- `type RateLimitHeaders = Readonly<Record<string, string>>` — the
frozen header bag.
- `function buildRateLimitHeaders(args: { decision, style, emitRetryAfter, now? }): RateLimitHeaders`
— assembles the header bag for a decision. `now` is injectable for
deterministic tests; defaults to `Date.now()`.
- `interface RateLimitProblemBody` — `{ type, title, status: 429,
detail, limit, remaining, retryAfter }`.
- `function buildRateLimitProblem(decision: RateLimitDecision): RateLimitProblemBody`
— assembles the body for a denied decision.
## Implementation Notes
- `Math.ceil` is used for second conversion so a 1-ms remaining
window does not round down to `0` and confuse a client that polls
immediately.
- The Problem `type` URI is a stable namespace under
`https://suaveplan.dev/problems/` so consumers can ship a
type-specific error page without further coordination.
- The body intentionally omits the rate-limit key (PII risk) and the
route id (information leakage). Clients learn the schedule from the
headers, not the body.
- `Retry-After` is only emitted on a denied decision and only when
`emitRetryAfter` is `true` — operators who prefer pure headerless
back-off can disable it.

View File

@@ -0,0 +1,66 @@
# `middleware/context-augmentation` — Hono context-variable augmentation
## Purpose
Augments Hono's `ContextVariableMap` interface so handlers calling
`c.get("rateLimit")` get a typed `RateLimitDecision` rather than
`unknown`. Without this declaration downstream handlers would have to
narrow the value at the call site every time.
The augmentation is global (Hono's `ContextVariableMap` is a single
declaration-merged interface across the whole project), so loading
this module via the package barrel is sufficient — consumers do not
need to import it explicitly.
## Features
- Single side-effect-free module that contributes one slot to
`ContextVariableMap`.
- The package barrel imports this file for its side effects so the
augmentation surfaces transparently.
- The slot type is exact (`RateLimitDecision`), not the loose
`unknown` Hono ships by default.
## Usage: Basic
```ts
import { Hono } from "hono";
import "@suaveplan/api-plugin-rate-limit"; // side-effect import surfaces the slot
const app = new Hono();
app.get("/api/users", (c) => {
const decision = c.get("rateLimit"); // typed RateLimitDecision
return c.json({ remaining: decision.remaining });
});
```
## Usage: Advanced
```ts
import { createMiddleware } from "hono/factory";
import type { RateLimitDecision } from "@suaveplan/api-plugin-rate-limit";
export const enrichResponseWithRateLimit = createMiddleware(async (c, next) => {
await next();
const decision: RateLimitDecision | undefined = c.get("rateLimit");
if (decision !== undefined) {
c.res.headers.set("X-Service-Quota", String(decision.remaining));
}
});
```
## API Reference
- Module augmentation: `declare module "hono" { interface ContextVariableMap { rateLimit: RateLimitDecision } }`.
## Implementation Notes
- The slot is declared without `?` so handlers may not read it before
the middleware has run. In practice this is fine — the middleware
is the only thing that writes the slot and it does so before
`next()`.
- Adding additional slots requires editing this file and bumping the
package's minor version (the augmentation is part of the public
surface).
- The augmentation file is co-located with the middleware that
populates the slot so future readers find both in one place.

View File

@@ -0,0 +1,101 @@
# `middleware/rate-limit` — Hono middleware wrapping the rate-limit service
## Purpose
Constructs a Hono `MiddlewareHandler` that consults a
`RateLimitService` on every request, emits the configured
`X-RateLimit-*` / `RateLimit-*` headers on every response (allowed AND
denied), and on denial writes an RFC 7807 `application/problem+json`
body with the 429 status. The middleware is the canonical mount point
operators wire into their app — `app.use("*", rateLimit({ service }))`
covers every route; selective mounts let consumers apply different
policies per path prefix.
The middleware also publishes the resolved `RateLimitDecision` to the
Hono context (`c.set("rateLimit", decision)`) so downstream handlers
can inspect the matched route, the remaining budget, or the resolved
tenant id without re-running the check.
## Features
- Per-request IP and tenant resolution via injectable resolvers
(defaults: `X-Forwarded-For` / `X-Real-IP` for IP,
`X-Tenant-ID` header for tenant).
- Header bag merged onto the outgoing response after `next()` so
downstream handlers cannot accidentally strip them.
- RFC 7807 Problem+JSON body on 429.
- `onDenied` hook fires before the response is written — useful for
custom audit logging.
- Decision attached to the Hono context so handlers can act on it.
## Usage: Basic
```ts
import { Hono } from "hono";
import { rateLimit } from "@suaveplan/api-plugin-rate-limit";
const app = new Hono();
app.use("*", rateLimit({ service }));
```
## Usage: Advanced
```ts
import { Hono } from "hono";
import {
rateLimit,
type RateLimitDecision,
} from "@suaveplan/api-plugin-rate-limit";
const app = new Hono();
app.use(
"/api/*",
rateLimit({
service,
headerStyle: "both",
emitRetryAfter: true,
resolveIp: (c) => c.req.header("CF-Connecting-IP") ?? "unknown",
resolveTenantId: (c) => c.get("organisation")?.id,
onDenied: (decision: RateLimitDecision) => {
auditLog.warn("rate-limit denied", {
routeId: decision.matchedRouteId,
key: decision.key,
});
},
})
);
app.get("/api/users", (c) => {
const decision = c.get("rateLimit");
return c.json({ remaining: decision.remaining });
});
```
## API Reference
- `interface RateLimitMiddlewareOptions` — `{ service, headerStyle?,
emitRetryAfter?, resolveTenantId?, resolveIp?, onDenied? }`.
- `function rateLimit(options: RateLimitMiddlewareOptions): MiddlewareHandler`
— returns the Hono middleware. Defaults pulled from
`service.config` when not overridden.
- `function rateLimitExceeded(decision: RateLimitDecision): RateLimitPluginError`
— construct a typed `LIMIT_EXCEEDED` error for callers that want to
throw rather than return a Response.
## Implementation Notes
- The middleware returns the 429 response inline rather than throwing
the typed error — operators who installed an error-handler
translating `RateLimitPluginError` into Problem+JSON would get a
double translation otherwise. Callers wiring `service.check`
manually can use `rateLimitExceeded(decision)` to surface the same
error through their handler chain.
- Header bag is merged onto `c.res.headers` both before
the denial response and after `next()` returns — Hono sometimes
swaps the `res` between phases, so both writes are required.
- The default IP resolver respects `X-Forwarded-For` (first hop) and
falls back to `X-Real-IP`. Operators behind a trusted proxy should
override this with a header their proxy guarantees (e.g.
`CF-Connecting-IP` on Cloudflare).
- The default tenant resolver reads `X-Tenant-ID` — consumers using
JWT-derived tenant ids should override `resolveTenantId` to read
from `c.get("user")`.

View File

@@ -0,0 +1,93 @@
# `otel/instruments` — OpenTelemetry instruments for the rate-limit plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds (and caches) the bundle of OpenTelemetry instruments the
rate-limit plugin emits on every check. Operators wiring an SDK at
boot get accurate counters and histograms without the plugin owning
the SDK lifecycle. Tests installing an `InMemoryMetricExporter` can
call `resetRateLimitInstruments()` to force a re-bind against the
freshly-installed provider, so assertions run against real meter
output rather than a snapshot taken before the provider was swapped
in.
The bundle covers the four signals operators actually alert on: total
checks (with outcome), denials (subset of checks), check duration, and
bypasses (with reason).
## Features
- Lazy, idempotent meter construction — first call binds against the
global provider; subsequent calls return the cached bundle.
- `resetRateLimitInstruments()` drops the cache so the next call
re-binds; required for test isolation across files.
- Counters and histograms carry stable units and descriptions so the
collector's metadata pipeline can render dashboards correctly.
- Compatible with `@opentelemetry/sdk-metrics` in-memory exporters,
Prometheus exporters, and OTLP exporters out of the box.
## Usage: Basic
```ts
import { getRateLimitInstruments } from "@suaveplan/api-plugin-rate-limit";
const instruments = getRateLimitInstruments();
instruments.checksTotal.add(1, { outcome: "allowed", routeId: "default" });
```
## Usage: Advanced
```ts
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
getRateLimitInstruments,
resetRateLimitInstruments,
} from "@suaveplan/api-plugin-rate-limit";
const exporter = new InMemoryMetricExporter(/* … */);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 1_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetRateLimitInstruments();
const instruments = getRateLimitInstruments();
// every subsequent check() records into `exporter`.
```
## API Reference
- `interface RateLimitInstruments` — bundle of the four instruments:
`checksTotal: Counter`, `denialsTotal: Counter`,
`checkDurationMs: Histogram`, `bypassedTotal: Counter`.
- `function getRateLimitInstruments(): RateLimitInstruments` — lazy
accessor; returns the cached bundle bound to the current global
meter provider.
- `function resetRateLimitInstruments(): void` — drops the cache.
## Implementation Notes
- The meter name is `@suaveplan/api-plugin-rate-limit` so collectors
can route metrics from this plugin to a dedicated scope without
pattern-matching attribute values.
- `checksTotal` and `denialsTotal` are intentionally overlapping —
alerting on the rejection RATE is easier when the denominator
(`checksTotal`) and numerator (`denialsTotal`) live in the same
metric family.
- The `outcome` attribute on `checksTotal` is one of
`"allowed" | "denied" | "bypassed"`; the `routeId` attribute is the
rule's `id` (or `"default"`); the `tenantId` attribute is present
only when the request carried a resolved tenant.
- Counter additions and histogram records are intentionally
fire-and-forget — the OpenTelemetry API is non-blocking and any
collector backpressure is the SDK's concern, not the plugin's.

View File

@@ -0,0 +1,86 @@
# `path-matcher` — Hono-compatible path-pattern compilation and matching
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compiles a Hono-style path pattern string into a regular expression
+ parameter-name list, then matches it against concrete inbound paths
and returns the captured params. The plugin pre-compiles every
`RouteRule.path` once at init so per-request route lookup is constant
time per rule and zero-allocation in the happy path.
The matcher accepts the same forms Hono's router accepts so route
rules written for the framework's router transparently apply to
rate-limit rules without translation. The compiled-pattern cache is
exposed so consumers wiring `RateLimitService.check(...)` manually
(without the middleware) can pre-compile their own rules.
## Features
+ Exact-literal, trailing wildcard (`/api/users/*`), inline segment
wildcard (`/api/*/avatar`), and named-param (`/api/users/:id`)
pattern forms.
+ Captured params returned as a frozen `Record<string, string>` so
callers can rely on referential stability.
+ All metacharacters in the literal portions are escaped before
pattern composition — `?`, `.`, `+` in a path do not turn into
regex operators by accident.
+ Defensive: every `RegExp` construction is wrapped so a future
pattern-syntax mistake surfaces as `RateLimitPluginError(INVALID_PATH_PATTERN)`
rather than a bare `SyntaxError`.
## Usage: Basic
```ts
import { isPathMatch } from "@suaveplan/api-plugin-rate-limit";
isPathMatch("/api/users/:id", "/api/users/123"); // true
isPathMatch("/api/users/:id", "/api/posts/123"); // false
```
## Usage: Advanced
```ts
import {
compilePathPattern,
matchPath,
} from "@suaveplan/api-plugin-rate-limit";
const pattern = compilePathPattern("/api/orgs/:org/users/:user");
const result = matchPath(pattern, "/api/orgs/acme/users/u42");
if (result.matched) {
console.log(result.params); // { org: "acme", user: "u42" }
}
```
## API Reference
+ `interface CompiledPathPattern` — `{ source, regex, paramNames }`.
+ `interface PathMatchResult` — `{ matched, params }`.
+ `function compilePathPattern(source: string): CompiledPathPattern` —
compile a pattern. Throws `RateLimitPluginError(INVALID_PATH_PATTERN)`
on regex construction failure.
+ `function matchPath(pattern: CompiledPathPattern, path: string): PathMatchResult`
— match a compiled pattern against a path.
+ `function isPathMatch(source: string, path: string): boolean` —
compile + match convenience helper.
## Implementation Notes
+ Pattern compilation is `O(n)` over the source length; matching is
`O(n)` over the path length once the regex is JIT'd by the engine.
+ Trailing `/*` matches zero or more path segments (the trailing slash
is optional), so `/api/users/*` matches both `/api/users/123` and
`/api/users/` — but not `/api/users` (no trailing slash). This
mirrors Hono's behavior.
+ Inline `*` matches exactly one segment (`[^/]+`) — `/api/*/avatar`
matches `/api/u1/avatar` but not `/api/u1/sub/avatar`.
+ Named params (`:name`) bind to the captured value; the value is
not currently used by the rate-limit service (rules apply to the
pattern as a whole) but is preserved so downstream consumers may
use it.
+ The matcher is universal — zero runtime dependencies.

View File

@@ -0,0 +1,100 @@
# `plugin/plugin` — `createRateLimitPlugin` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory that returns a typed `PluginDescriptor` for the rate-limit
plugin, ready to be passed to `@suaveplan/api`'s plugin registry.
The descriptor's `onInit` implements the canonical 6-step lifecycle
shared by every `@suaveplan/api-plugin-*` package: resolve services,
build storage, construct the service, register the service slot,
install the Hono middleware, log readiness.
The factory Zod-validates the supplied options at the boundary so
invalid input fails fast with `RateLimitPluginError(INVALID_INPUT)`
carrying the underlying `ZodError` as `cause`. Once `onInit` returns
the plugin is fully wired: every request that flows through the host
Hono app is rate-limited by the configured policy, the
`rateLimitService` slot is available for sibling plugins, and the
plugin's OTel instruments are emitting against the host meter
provider.
## Features
- Zod-validated options at the boundary.
- Six-step canonical `onInit` lifecycle.
- Defaults to bundled `MemoryStorage`; accepts any
`RateLimitStorage` for distributed deployments.
- Registers `rateLimitService` in the host `ServiceRegistry`.
- Installs the global Hono middleware on `*` automatically.
- `onClose` releases the service so subsequent re-inits start fresh.
## Usage: Basic
```ts
import { createApi } from "@suaveplan/api";
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
const api = createApi();
api.use(
createRateLimitPlugin({
defaultLimit: 100,
defaultWindowMs: 60_000,
})
);
```
## Usage: Advanced
```ts
import { createApi } from "@suaveplan/api";
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
import { createMyRedisStorage } from "./my-redis-storage.js";
const api = createApi();
api.use(
createRateLimitPlugin({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
],
tenants: [
{ tenantId: "enterprise", multiplier: 10 },
{ tenantId: "internal", bypass: true },
],
ipWhitelist: ["10.0.0.0/8"],
headerStyle: "both",
emitRetryAfter: true,
storage: createMyRedisStorage({ url: process.env.REDIS_URL }),
})
);
```
## API Reference
- `function createRateLimitPlugin(options: RateLimitPluginOptions): PluginDescriptor<RateLimitPluginConfig>`
— factory.
## Implementation Notes
- The 6-step `onInit` mirrors the shape every other
`@suaveplan/api-plugin-*` follows so operators reading multiple
plugins find a consistent narrative.
- The plugin's `name` is `"rate-limit"` — sibling plugins that
depend on it declare `dependencies: ["rate-limit"]` so the topo
sort orders them correctly.
- `onClose` nulls the service reference; the storage adapter (if
caller-supplied) is the operator's responsibility to dispose.
- The plugin DOES install a wildcard middleware (`app.use("*", …)`)
by default. Consumers who want per-route middleware instead can
skip the wildcard mount, resolve the service from the registry,
and call `rateLimit({ service })` themselves — but the default is
the safe, secure-by-default position.
- The plugin requires the host `app` slot. The `clock` slot is
optional and defaults to `Date.now`; the `logger` slot is also
optional and defaults to `ctx.logger`.

View File

@@ -0,0 +1,87 @@
# `schemas/options` — Zod schema for `createRateLimitPlugin` options
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates the caller-supplied `RateLimitPluginOptions` at the
boundary and fills in defaults for every optional field, producing the
resolved `RateLimitPluginConfig` the plugin's `onInit` lifecycle and
the `RateLimitService` rely on. The schema is the single source of
truth for what shape `createRateLimitPlugin` accepts — the factory
calls `safeParse(...)` and throws `RateLimitPluginError(INVALID_INPUT)`
with the underlying `ZodError` attached as `cause` on validation
failure, so callers see a typed error rather than a runtime crash.
## Features
- `defaultLimit` / `defaultWindowMs` mandatory positive integers.
- `routes`, `tenants`, `ipWhitelist` default to empty arrays.
- `keyResolver` defaults to `${ip}|${tenantId ?? "_"}` — composable
isolation between IPs and tenants out of the box.
- `headerStyle` enum: `"x-rate-limit"` (default), `"rfc-9238"`,
`"both"`.
- `emitRetryAfter` defaults to `true`.
- `storage` is a custom Zod runtime check accepting any
`RateLimitStorage`-shaped object — caller-supplied adapters
(Redis, SurrealDB) pass through untouched.
- `.strict()` so typos in option names fail loudly instead of
silently doing nothing.
## Usage: Basic
```ts
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
```
## Usage: Advanced
```ts
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
],
tenants: [
{ tenantId: "enterprise", multiplier: 10 },
{ tenantId: "internal", bypass: true },
],
ipWhitelist: ["127.0.0.1"],
headerStyle: "both",
});
if (!result.success) console.error(result.error.issues);
```
## API Reference
- `RateLimitPluginOptionsSchema` — Zod schema instance. Parses
`RateLimitPluginOptions` (input) into `RateLimitPluginConfig`
(output) with every default filled in.
- `RateLimitPluginOptionsInput` — `z.input<typeof
RateLimitPluginOptionsSchema>` — what callers supply.
- `RateLimitPluginOptionsResolved` — `z.output<typeof
RateLimitPluginOptionsSchema>` — what the plugin receives.
## Implementation Notes
- `keyResolver` uses `z.custom<RateLimitKeyResolver>` — functions
cannot be expressed as a JSON-Schema-compatible Zod shape; the
custom check enforces `typeof === "function"`.
- `storage` and `logger` are runtime references; the schema preserves
them as-is via `z.custom` rather than attempting to validate their
internal shape.
- `defaults` declared inline so `RateLimitPluginOptionsResolved` is a
total interface — no `undefined` fallbacks in the consumer.
- The schema is `.strict()` to catch typos like
`defaultLimti: 100` at the boundary.

View File

@@ -0,0 +1,122 @@
# `services/rate-limit-service` — Plugin-level rate-limit service factory
## Purpose
Wraps the universal `@suaveplan/rate-limiter` core in the
plugin's policy layer: IP whitelist, tenant bypass, tenant multiplier
applied to the numeric limit, parameterised route matching, and OTel
instrumentation. Exposed in the host `ServiceRegistry` under the
`rateLimitService` slot so other plugins and route handlers can call
`service.check({ ip, tenantId, path, method })` directly without
going through the Hono middleware.
This service is the public extension point of the plugin — operators
who want a custom storage backend, a custom key resolver, or
fine-grained per-route policy use the same surface the middleware
itself uses.
## Features
- IP whitelist short-circuits the entire check (bypassed counter
recorded, no storage write).
- Tenant bypass short-circuits per-tenant.
- Tenant multiplier multiplies the matched rule's numeric `limit` and
is wired into the algorithm itself (one limiter per
(route, tenant) pair, lazily constructed).
- Route lookup respects `method` when present and uses
`compilePathPattern` for the `path`.
- Storage failure is wrapped as `RateLimitPluginError(STORAGE_FAILURE)`
so consumers can decide fail-open vs fail-closed.
- OTel counters and histograms recorded on every code path
(allowed / denied / bypassed).
## Usage: Basic
```ts
import {
createDefaultStorage,
createRateLimitService,
RateLimitPluginOptionsSchema,
} from "@suaveplan/api-plugin-rate-limit";
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
const service = createRateLimitService({
config,
storage: createDefaultStorage({ clock: Date.now }),
clock: Date.now,
logger: console,
});
const decision = await service.check({
ip: "127.0.0.1",
path: "/api/users",
method: "GET",
});
```
## Usage: Advanced
```ts
import { createRateLimitService } from "@suaveplan/api-plugin-rate-limit";
const service = createRateLimitService({
config: {
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
],
tenants: [
{ tenantId: "enterprise", multiplier: 10 },
{ tenantId: "internal", bypass: true },
],
ipWhitelist: ["127.0.0.1"],
keyResolver: (input) => `${input.tenantId ?? "_"}::${input.ip}`,
headerStyle: "rfc-9238",
emitRetryAfter: true,
},
storage,
clock,
logger,
});
const decision = await service.check({
ip: "10.0.0.42",
tenantId: "enterprise",
path: "/api/login",
method: "POST",
});
if (!decision.allowed) {
return new Response("rate limit exceeded", { status: 429 });
}
```
## API Reference
- `interface RateLimitService` — `{ check, reset, config }`.
- `interface RateLimitCheckInput` — `{ ip, tenantId?, path, method }`.
- `function createRateLimitService(args: { config, storage, clock,
logger }): RateLimitService`.
- `function createDefaultStorage(args: { clock }): RateLimitStorage` —
`MemoryStorage` wrapped with the injected clock.
## Implementation Notes
- Lazy limiter construction (one per `(routeId, tenantId)` pair) keeps
init cost proportional to the configured routes, not the number of
active tenants. The first hit by tenant `T` against route `R`
builds a fresh limiter; subsequent hits reuse it.
- The reset path iterates every limiter built so far — operators
calling `service.reset(key)` after a successful login pay
`O(routes × tenants_seen)` for the broadcast. In practice both
factors are small.
- The default route (no rule matched) uses `id: "default"` and the
plugin's `defaultLimit` / `defaultWindowMs`. It is treated as a
rule like any other so the tenant multiplier and storage isolation
apply uniformly.
- OTel attributes always carry the `routeId` and (when present) the
`tenantId` — alerts can filter by either dimension without
re-aggregation.
- `RateLimitPluginError(STORAGE_FAILURE)` carries the original error
as `cause` so structured logs preserve the stack trace.

View File

@@ -0,0 +1,73 @@
# `services/registry-augmentation` — TypeScript module augmentation for the host ServiceRegistry
## Purpose
Augments the `ServiceRegistry` interface owned by
`@suaveplan/api-plugin/service-registry` so consumers see the
`rateLimitService` slot typed as `RateLimitService` (rather than
`unknown`) when they call `ctx.getService("rateLimitService")`. Without
this declaration the host's typed lookup would fall back to the
default `unknown` and downstream plugins would have to cast.
Re-declares the canonical host-side slots (`app`, `logger`, `eventBus`,
`clock`) too so the plugin's own `onInit(ctx)` can resolve them
without forcing the consumer to import `@suaveplan/api`'s
registry-augmentation manually. TypeScript merges duplicate `declare
module` blocks, so this addition is purely additive.
## Features
- Module augmentation lives in a side-effect-only import — the
package barrel imports it first so the declaration is in scope for
every consumer.
- Exports a no-op `registerRateLimitServiceSlot()` so the module is
preserved through dead-code elimination and a side-effect import
surfaces the augmentation reliably.
- Cumulative — does not collide with `@suaveplan/api`'s
augmentation; TypeScript merges them.
## Usage: Basic
```ts
// Anywhere that imports the package barrel:
import "@suaveplan/api-plugin-rate-limit";
// `ctx.getService("rateLimitService")` now narrows to
// `RateLimitService | undefined`.
```
## Usage: Advanced
```ts
// Inside another plugin's onInit:
import { registerRateLimitServiceSlot } from "@suaveplan/api-plugin-rate-limit";
registerRateLimitServiceSlot(); // ensures bundler keeps the side-effect import
const service = ctx.getService("rateLimitService");
if (service === undefined) {
throw new MyError(MY_CODES.MISSING_DEP, "rateLimitService not registered");
}
await service.check({ ip, path, method });
```
## API Reference
- Module augmentation: `declare module "@suaveplan/api-plugin/service-registry"`
adds `rateLimitService: RateLimitService` plus the canonical host
slots.
- `function registerRateLimitServiceSlot(): void` — no-op runtime
export. Call it inside a function body if your bundler is
aggressive about pruning side-effect-only imports.
## Implementation Notes
- The declaration uses a regular `import type` chain so the runtime
cost is zero — no module is loaded at runtime to satisfy the
augmentation.
- The host `app` slot is typed as `OpenAPIHono` from
`@hono/zod-openapi` to mirror `@suaveplan/api`'s declaration. Plain
`Hono` apps work too; the `OpenAPIHono` type is a structural
superset.
- Adding new slots requires updating the augmentation here AND
publishing a major version — consumers' compile output depends on
the interface shape.

View File

@@ -0,0 +1,108 @@
# `types/types` — Public type surface of the rate-limit plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public type surface every other module in the package
references — the caller-facing `RateLimitPluginOptions`, the resolved
`RateLimitPluginConfig`, the `RouteRule` and `TenantRule` shapes, the
`RateLimitDecision` returned by `service.check(...)`, and the
`RateLimitKeyResolver` callers can plug in to customise key
derivation. These types are the contract; the schema in
`schemas/options.ts` is the runtime validator that enforces them.
Co-locating the type surface here keeps the module-by-module reading
order predictable: read this file, then `schemas/options.ts`, then
`services/rate-limit-service.ts`, then `middleware/rate-limit.ts`.
## Features
- Every option is documented with prose describing the runtime
semantics, not just the static shape.
- `RouteRule` accepts three pattern forms (exact, `/*` suffix,
`:param` named segments) consistent with Hono's router.
- `TenantRule.multiplier` is APPLIED to the numeric limit (legacy
upstream plugin only used it as a bucket-key prefix).
- `RateLimitKeyResolver` is a pure function — no async, no side
effects — so the resolver can run in the hot path safely.
- `RateLimitDecision` carries every field the middleware needs to
build headers and the body without an extra round trip to the
service.
## Usage: Basic
```ts
import type {
RateLimitDecision,
RateLimitPluginOptions,
} from "@suaveplan/api-plugin-rate-limit";
const opts: RateLimitPluginOptions = {
defaultLimit: 100,
defaultWindowMs: 60_000,
};
function logDecision(d: RateLimitDecision): void {
console.log(`allowed=${d.allowed} remaining=${d.remaining}`);
}
```
## Usage: Advanced
```ts
import type {
RateLimitKeyResolver,
RateLimitPluginOptions,
RouteRule,
} from "@suaveplan/api-plugin-rate-limit";
const byUser: RateLimitKeyResolver = (input) =>
`${input.tenantId ?? "_"}::${input.ip}::${input.method}`;
const loginRule: RouteRule = {
path: "/api/auth/:provider/login",
method: "POST",
limit: 5,
windowMs: 60_000,
id: "login-per-provider",
};
const opts: RateLimitPluginOptions = {
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: [loginRule],
keyResolver: byUser,
headerStyle: "rfc-9238",
};
```
## API Reference
- `type RateLimitHeaderStyle = "x-rate-limit" | "rfc-9238" | "both"`.
- `interface RouteRule` — `{ path, method?, limit, windowMs, id? }`.
- `interface TenantRule` — `{ tenantId, multiplier?, bypass? }`.
- `interface RateLimitPluginOptions` — caller-facing.
- `interface RateLimitPluginConfig` — resolved, complete config.
- `type RateLimitKeyResolver = (input: RateLimitKeyResolverInput) => string`.
- `interface RateLimitKeyResolverInput` — `{ ip, tenantId, path, method }`.
- `interface RateLimitDecision` — `{ allowed, limit, remaining,
resetIn, retryAfter, bypassed, matchedRouteId, tenantId, key }`.
## Implementation Notes
- Every interface is `readonly` to make the shape immutable at
compile time; consumers building options assemble plain objects
that satisfy the shape without `as const` ceremony.
- `RateLimitKeyResolver` is intentionally synchronous — running an
async resolver per request would balloon the hot path. Operators
who need async tenant lookup do that ONCE inside their auth
middleware and stash the result in the Hono context for the
rate-limit middleware to read synchronously.
- `RateLimitConfig` and `RateLimitStorage` are re-exported from
`@suaveplan/rate-limiter` so consumers writing custom storage
adapters do not need to add another package dependency.
- The decision's `key` field is the resolved storage key — exposed
for debug + admin endpoints, not for client consumption.