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,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,120 @@
{
"name": "@suaveplan/api-plugin-rate-limit",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./middleware/rate-limit": {
"import": {
"types": "./dist/middleware/rate-limit.d.ts",
"default": "./dist/middleware/rate-limit.js"
}
},
"./services/rate-limit-service": {
"import": {
"types": "./dist/services/rate-limit-service.d.ts",
"default": "./dist/services/rate-limit-service.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./path-matcher/path-matcher": {
"import": {
"types": "./dist/path-matcher/path-matcher.d.ts",
"default": "./dist/path-matcher/path-matcher.js"
}
},
"./headers/headers": {
"import": {
"types": "./dist/headers/headers.d.ts",
"default": "./dist/headers/headers.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/rate-limiter": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/api": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,119 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "./error-codes/error-codes.js";
import type {
RateLimitCheckInput,
RateLimitService,
} from "./services/rate-limit-service.js";
import type {
RateLimitDecision,
RateLimitHeaderStyle,
RateLimitPluginConfig,
} from "./types/types.js";
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) walk(p, out);
else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".types.ts")
)
out.push(p);
}
return out;
}
const srcRoot = new URL("./", import.meta.url).pathname;
const allSources = walk(srcRoot)
.map((p) => readFileSync(p, "utf8"))
.join("\n");
describe("api-plugin-rate-limit spec conformance", () => {
it("every code in RATE_LIMIT_PLUGIN_CODES is reachable from src", {
timeout: 5000,
}, () => {
for (const [name, value] of Object.entries(RATE_LIMIT_PLUGIN_CODES)) {
const reachable =
allSources.includes(`RATE_LIMIT_PLUGIN_CODES.${name}`) ||
allSources.includes(value);
expect(reachable).toBe(true);
}
});
it("RateLimitService surface matches the contract", {
timeout: 5000,
}, () => {
expectTypeOf<RateLimitService>().toHaveProperty("check");
expectTypeOf<RateLimitService>().toHaveProperty("reset");
expectTypeOf<RateLimitService>().toHaveProperty("config");
expectTypeOf<
Parameters<RateLimitService["check"]>[0]
>().toMatchTypeOf<RateLimitCheckInput>();
});
it("RateLimitDecision carries every required field", {
timeout: 5000,
}, () => {
expectTypeOf<RateLimitDecision>().toHaveProperty("allowed");
expectTypeOf<RateLimitDecision>().toHaveProperty("limit");
expectTypeOf<RateLimitDecision>().toHaveProperty("remaining");
expectTypeOf<RateLimitDecision>().toHaveProperty("resetIn");
expectTypeOf<RateLimitDecision>().toHaveProperty("retryAfter");
expectTypeOf<RateLimitDecision>().toHaveProperty("bypassed");
expectTypeOf<RateLimitDecision>().toHaveProperty("matchedRouteId");
expectTypeOf<RateLimitDecision>().toHaveProperty("tenantId");
expectTypeOf<RateLimitDecision>().toHaveProperty("key");
});
it("RateLimitHeaderStyle is a closed union", { timeout: 5000 }, () => {
const a: RateLimitHeaderStyle = "x-rate-limit";
const b: RateLimitHeaderStyle = "rfc-9238";
const c: RateLimitHeaderStyle = "both";
expect([a, b, c]).toEqual(["x-rate-limit", "rfc-9238", "both"]);
});
it("RateLimitPluginError narrows the code discriminant", {
timeout: 5000,
}, () => {
const err: unknown = new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED,
"x"
);
expect(err).toBeInstanceOf(RateLimitPluginError);
});
it("ServiceRegistry augmentation types rateLimitService", {
timeout: 5000,
}, async () => {
// Importing the package barrel surfaces the augmentation; the
// type assertion below is the actual contract.
await import("./index.js");
// Type-level only — runtime side is covered by other tests.
type Slot =
import("@suaveplan/api-plugin/service-registry").ServiceRegistry["rateLimitService"];
expectTypeOf<Slot>().toMatchTypeOf<RateLimitService>();
});
it("every option key on RateLimitPluginConfig is referenced", {
timeout: 5000,
}, () => {
const keys: ReadonlyArray<keyof RateLimitPluginConfig> = [
"defaultLimit",
"defaultWindowMs",
"routes",
"tenants",
"ipWhitelist",
"keyResolver",
"headerStyle",
"emitRetryAfter",
];
for (const k of keys)
expect(allSources.includes(`config.${k}`)).toBe(true);
});
});

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,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
type RateLimitPluginErrorCode,
} from "./error-codes.js";
describe("RATE_LIMIT_PLUGIN_CODES", () => {
it("declares the five canonical codes", { timeout: 5000 }, () => {
expect(Object.keys(RATE_LIMIT_PLUGIN_CODES).sort()).toEqual(
[
"INVALID_INPUT",
"INVALID_PATH_PATTERN",
"LIMIT_EXCEEDED",
"SERVICE_NOT_AVAILABLE",
"STORAGE_FAILURE",
].sort()
);
});
it("namespaces every code under RATE_LIMIT_PLUGIN.*", {
timeout: 5000,
}, () => {
for (const value of Object.values(RATE_LIMIT_PLUGIN_CODES)) {
expect(value.startsWith("RATE_LIMIT_PLUGIN.")).toBe(true);
}
});
});
describe("RateLimitPluginError", () => {
it("constructs with code/message/options and carries rateLimitCode alias", {
timeout: 5000,
}, () => {
const err = new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED,
"blocked",
{ context: { statusCode: 429 } }
);
expect(err).toBeInstanceOf(RateLimitPluginError);
expect(err.code).toBe(RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED);
expect(err.rateLimitCode).toBe(RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED);
expect(err.message).toBe("blocked");
});
it("narrows the code discriminant via instanceof", {
timeout: 5000,
}, () => {
const err: unknown = new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE,
"boom"
);
if (err instanceof RateLimitPluginError) {
const code: RateLimitPluginErrorCode = err.code;
expect(code).toBe(RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE);
}
});
it("captures cause when supplied", { timeout: 5000 }, () => {
const inner = new Error("inner");
const err = new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE,
"outer",
{ cause: inner }
);
expect(err.cause).toBe(inner);
});
});

View File

@@ -0,0 +1,52 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-rate-limit`.
*
* Every failure path raises a {@link RateLimitPluginError} carrying one
* of these `RATE_LIMIT_PLUGIN.*` codes:
*
* - `INVALID_INPUT` — `createRateLimitPlugin(options)` received options
* that failed `RateLimitPluginOptionsSchema` validation. `cause`
* holds the `ZodError`.
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot
* (e.g. `app`) was absent at `onInit`. `context.missing` lists the
* missing slot names.
* - `LIMIT_EXCEEDED` — the rate-limit middleware blocked a request.
* `context.retryAfter` carries milliseconds until the next allowed
* request; `context.statusCode` is `429`.
* - `STORAGE_FAILURE` — the injected `RateLimitStorage` rejected;
* `cause` holds the original error.
* - `INVALID_PATH_PATTERN` — a route rule's `path` could not be
* compiled by `compilePathPattern` (e.g. unterminated regex).
*/
export const RATE_LIMIT_PLUGIN_CODES = defineCodes("RATE_LIMIT_PLUGIN", [
"INVALID_INPUT",
"SERVICE_NOT_AVAILABLE",
"LIMIT_EXCEEDED",
"STORAGE_FAILURE",
"INVALID_PATH_PATTERN",
] as const);
export type RateLimitPluginErrorCode =
(typeof RATE_LIMIT_PLUGIN_CODES)[keyof typeof RATE_LIMIT_PLUGIN_CODES];
/**
* Concrete error class for the api-plugin-rate-limit package. Extends
* {@link SuaveplanError} so `instanceof RateLimitPluginError` narrows
* `code` to {@link RateLimitPluginErrorCode}. Constructor is `public`
* so callers (and the Hono middleware) may construct errors directly.
*/
export class RateLimitPluginError extends SuaveplanError<RateLimitPluginErrorCode> {
public readonly rateLimitCode: RateLimitPluginErrorCode;
public constructor(
code: RateLimitPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.rateLimitCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { RateLimitPluginErrorCode } from "./error-codes.js";
export {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "./error-codes.js";

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,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { RateLimitDecision } from "../types/types.js";
import { buildRateLimitHeaders, buildRateLimitProblem } from "./headers.js";
const allowed: RateLimitDecision = {
allowed: true,
limit: 100,
remaining: 99,
resetIn: 60_000,
retryAfter: undefined,
bypassed: false,
matchedRouteId: "default",
tenantId: undefined,
key: "127.0.0.1|_",
};
const denied: RateLimitDecision = {
allowed: false,
limit: 5,
remaining: 0,
resetIn: 30_000,
retryAfter: 30_000,
bypassed: false,
matchedRouteId: "login",
tenantId: "enterprise",
key: "127.0.0.1|enterprise",
};
describe("buildRateLimitHeaders", () => {
it("emits X-RateLimit triple under x-rate-limit style", {
timeout: 5000,
}, () => {
const headers = buildRateLimitHeaders({
decision: allowed,
style: "x-rate-limit",
emitRetryAfter: true,
now: 0,
});
expect(headers["X-RateLimit-Limit"]).toBe("100");
expect(headers["X-RateLimit-Remaining"]).toBe("99");
expect(headers["X-RateLimit-Reset"]).toBe("60");
expect(headers["RateLimit-Limit"]).toBeUndefined();
});
it("emits RateLimit triple under rfc-9238 style", { timeout: 5000 }, () => {
const headers = buildRateLimitHeaders({
decision: allowed,
style: "rfc-9238",
emitRetryAfter: true,
now: 0,
});
expect(headers["RateLimit-Limit"]).toBe("100");
expect(headers["RateLimit-Remaining"]).toBe("99");
expect(headers["RateLimit-Reset"]).toBe("60");
expect(headers["X-RateLimit-Limit"]).toBeUndefined();
});
it("emits both header families when style is both", {
timeout: 5000,
}, () => {
const headers = buildRateLimitHeaders({
decision: allowed,
style: "both",
emitRetryAfter: true,
now: 0,
});
expect(headers["X-RateLimit-Limit"]).toBe("100");
expect(headers["RateLimit-Limit"]).toBe("100");
});
it("emits Retry-After only on denied decisions and only when enabled", {
timeout: 5000,
}, () => {
const onDenied = buildRateLimitHeaders({
decision: denied,
style: "x-rate-limit",
emitRetryAfter: true,
});
expect(onDenied["Retry-After"]).toBe("30");
const offDenied = buildRateLimitHeaders({
decision: denied,
style: "x-rate-limit",
emitRetryAfter: false,
});
expect(offDenied["Retry-After"]).toBeUndefined();
const onAllowed = buildRateLimitHeaders({
decision: allowed,
style: "x-rate-limit",
emitRetryAfter: true,
});
expect(onAllowed["Retry-After"]).toBeUndefined();
});
it("does not emit Retry-After when retryAfter is undefined", {
timeout: 5000,
}, () => {
const decisionNoRetry: RateLimitDecision = {
...denied,
retryAfter: undefined,
};
const h = buildRateLimitHeaders({
decision: decisionNoRetry,
style: "x-rate-limit",
emitRetryAfter: true,
});
expect(h["Retry-After"]).toBeUndefined();
});
it("uses Date.now when now is not supplied", { timeout: 5000 }, () => {
const h = buildRateLimitHeaders({
decision: allowed,
style: "x-rate-limit",
emitRetryAfter: true,
});
expect(h["X-RateLimit-Reset"]).toBeDefined();
});
});
describe("buildRateLimitProblem", () => {
it("constructs an RFC 7807-shaped body for a denied decision", {
timeout: 5000,
}, () => {
const body = buildRateLimitProblem(denied);
expect(body.status).toBe(429);
expect(body.title).toBe("Too Many Requests");
expect(body.type.startsWith("https://")).toBe(true);
expect(body.limit).toBe(5);
expect(body.remaining).toBe(0);
expect(body.retryAfter).toBe(30_000);
expect(body.detail.includes("30")).toBe(true);
});
it("falls back to resetIn when retryAfter is undefined", {
timeout: 5000,
}, () => {
const body = buildRateLimitProblem({
...denied,
retryAfter: undefined,
});
expect(body.retryAfter).toBe(30_000);
});
});

View File

@@ -0,0 +1,90 @@
import type {
RateLimitDecision,
RateLimitHeaderStyle,
} from "../types/types.js";
/**
* A bag of HTTP headers ready to be merged onto an outgoing response.
* Always emitted on both allowed and denied paths so clients can plan
* their request schedule before they hit the limit.
*/
export type RateLimitHeaders = Readonly<Record<string, string>>;
/**
* Build the rate-limit headers for a given decision, using the
* configured `style`:
*
* - `"x-rate-limit"` — `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
* `X-RateLimit-Reset` (Unix epoch seconds).
* - `"rfc-9238"` — `RateLimit-Limit`, `RateLimit-Remaining`,
* `RateLimit-Reset` (seconds until reset, per IETF draft).
* - `"both"` — the union of both header families.
*
* `Retry-After` is included when the decision was denied AND
* `emitRetryAfter` is `true`.
*/
export function buildRateLimitHeaders(args: {
decision: RateLimitDecision;
style: RateLimitHeaderStyle;
emitRetryAfter: boolean;
now?: number;
}): RateLimitHeaders {
const now = args.now ?? Date.now();
const headers: Record<string, string> = {};
const resetSeconds = Math.max(0, Math.ceil(args.decision.resetIn / 1000));
const resetEpoch = Math.ceil((now + args.decision.resetIn) / 1000);
if (args.style === "x-rate-limit" || args.style === "both") {
headers["X-RateLimit-Limit"] = String(args.decision.limit);
headers["X-RateLimit-Remaining"] = String(args.decision.remaining);
headers["X-RateLimit-Reset"] = String(resetEpoch);
}
if (args.style === "rfc-9238" || args.style === "both") {
headers["RateLimit-Limit"] = String(args.decision.limit);
headers["RateLimit-Remaining"] = String(args.decision.remaining);
headers["RateLimit-Reset"] = String(resetSeconds);
}
if (
args.emitRetryAfter &&
!args.decision.allowed &&
args.decision.retryAfter !== undefined
) {
headers["Retry-After"] = String(
Math.max(1, Math.ceil(args.decision.retryAfter / 1000))
);
}
return Object.freeze(headers);
}
/**
* Build the RFC 7807 Problem+JSON body for a denied request. The
* middleware sets `Content-Type: application/problem+json` and writes
* this as the response body. The body intentionally omits the rate-key
* (PII) and leaves the route id off — clients learn the schedule from
* the headers, not the body.
*/
export interface RateLimitProblemBody {
readonly type: string;
readonly title: string;
readonly status: 429;
readonly detail: string;
readonly limit: number;
readonly remaining: number;
readonly retryAfter: number;
}
/**
* Construct a {@link RateLimitProblemBody} from a denied decision.
*/
export function buildRateLimitProblem(
decision: RateLimitDecision
): RateLimitProblemBody {
return {
type: "https://suaveplan.dev/problems/rate-limit",
title: "Too Many Requests",
status: 429,
detail: `Rate limit of ${decision.limit} exceeded. Retry after ${Math.ceil((decision.retryAfter ?? decision.resetIn) / 1000)} seconds.`,
limit: decision.limit,
remaining: decision.remaining,
retryAfter: decision.retryAfter ?? decision.resetIn,
};
}

View File

@@ -0,0 +1,8 @@
export type {
RateLimitHeaders,
RateLimitProblemBody,
} from "./headers.js";
export {
buildRateLimitHeaders,
buildRateLimitProblem,
} from "./headers.js";

View File

@@ -0,0 +1,75 @@
// Side-effect imports — surface the registry + context augmentations
// to every consumer importing the package barrel.
import "./services/registry-augmentation.js";
import "./middleware/context-augmentation.js";
// Error codes
export {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
type RateLimitPluginErrorCode,
} from "./error-codes/error-codes.js";
// Headers
export {
buildRateLimitHeaders,
buildRateLimitProblem,
type RateLimitHeaders,
type RateLimitProblemBody,
} from "./headers/headers.js";
// Middleware
export {
type RateLimitMiddlewareOptions,
rateLimit,
rateLimitExceeded,
} from "./middleware/rate-limit.js";
// OTel
export {
getRateLimitInstruments,
type RateLimitInstruments,
resetRateLimitInstruments,
} from "./otel/instruments.js";
// Path matcher
export {
type CompiledPathPattern,
compilePathPattern,
isPathMatch,
matchPath,
type PathMatchResult,
} from "./path-matcher/path-matcher.js";
// Plugin factory
export { createRateLimitPlugin } from "./plugin/plugin.js";
// Schemas
export {
type RateLimitPluginOptionsInput,
type RateLimitPluginOptionsResolved,
RateLimitPluginOptionsSchema,
} from "./schemas/options.js";
// Service
export {
createDefaultStorage,
createRateLimitService,
type RateLimitCheckInput,
type RateLimitService,
} from "./services/rate-limit-service.js";
export { registerRateLimitServiceSlot } from "./services/registry-augmentation.js";
// Types
export type {
RateLimitConfig,
RateLimitDecision,
RateLimitHeaderStyle,
RateLimitKeyResolver,
RateLimitKeyResolverInput,
RateLimitPluginConfig,
RateLimitPluginOptions,
RateLimitStorage,
RouteRule,
TenantRule,
} from "./types/types.js";

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,14 @@
/**
* Hono `ContextVariableMap` augmentation — declares the `rateLimit`
* slot populated by {@link rateLimit} middleware. Downstream handlers
* read `c.get("rateLimit")` typed as `RateLimitDecision | undefined`
* thanks to this declaration; without it the value would type as
* `unknown`.
*/
import type { RateLimitDecision } from "../types/types.js";
declare module "hono" {
interface ContextVariableMap {
rateLimit: RateLimitDecision;
}
}

View File

@@ -0,0 +1,5 @@
export {
type RateLimitMiddlewareOptions,
rateLimit,
rateLimitExceeded,
} from "./rate-limit.js";

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,266 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { resetRateLimitInstruments } from "../otel/instruments.js";
import { RateLimitPluginOptionsSchema } from "../schemas/options.js";
import {
createDefaultStorage,
createRateLimitService,
type RateLimitService,
} from "../services/rate-limit-service.js";
import type {
RateLimitDecision,
RateLimitPluginConfig,
} from "../types/types.js";
import { rateLimit, rateLimitExceeded } from "./rate-limit.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let clockNow = 1_000_000;
const clock = () => clockNow;
function makeService(
extra: Partial<RateLimitPluginConfig> = {}
): RateLimitService {
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 2,
defaultWindowMs: 60_000,
...extra,
});
return createRateLimitService({
config,
storage: createDefaultStorage({ clock }),
clock,
logger: noopLogger,
});
}
beforeEach(() => {
clockNow = 1_000_000;
resetRateLimitInstruments();
});
afterEach(() => {
resetRateLimitInstruments();
});
describe("rateLimit middleware", () => {
it("emits X-RateLimit headers on allowed responses", {
timeout: 5000,
}, async () => {
const service = makeService();
const app = new Hono();
app.use("*", rateLimit({ service }));
app.get("/api/users", (c) => c.text("ok"));
const res = await app.request(
new Request("http://x/api/users", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
expect(res.status).toBe(200);
expect(res.headers.get("X-RateLimit-Limit")).toBe("2");
expect(res.headers.get("X-RateLimit-Remaining")).toBe("1");
});
it("emits both header families when style is both", {
timeout: 5000,
}, async () => {
const service = makeService({ headerStyle: "both" });
const app = new Hono();
app.use("*", rateLimit({ service }));
app.get("/", (c) => c.text("ok"));
const res = await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
expect(res.headers.get("X-RateLimit-Limit")).toBeTruthy();
expect(res.headers.get("RateLimit-Limit")).toBeTruthy();
});
it("returns 429 + problem body + Retry-After when denied", {
timeout: 5000,
}, async () => {
const service = makeService({ defaultLimit: 1 });
const app = new Hono();
app.use("*", rateLimit({ service }));
app.get("/", (c) => c.text("ok"));
await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
const res = await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
expect(res.status).toBe(429);
expect(res.headers.get("Content-Type") ?? "").toContain(
"application/problem+json"
);
expect(res.headers.get("Retry-After")).toBeTruthy();
const body = (await res.json()) as { status: number };
expect(body.status).toBe(429);
});
it("attaches the decision to the Hono context", {
timeout: 5000,
}, async () => {
const service = makeService();
const app = new Hono();
app.use("*", rateLimit({ service }));
let captured: RateLimitDecision | undefined;
app.get("/", (c) => {
captured = c.get("rateLimit");
return c.text("ok");
});
await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
expect(captured).toBeDefined();
expect(captured?.allowed).toBe(true);
});
it("invokes onDenied callback for denied requests", {
timeout: 5000,
}, async () => {
const service = makeService({ defaultLimit: 1 });
let calls = 0;
const app = new Hono();
app.use(
"*",
rateLimit({
service,
onDenied: () => {
calls += 1;
},
})
);
app.get("/", (c) => c.text("ok"));
await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1" },
})
);
expect(calls).toBe(1);
});
it("respects custom resolveIp and resolveTenantId", {
timeout: 5000,
}, async () => {
const service = makeService({ defaultLimit: 1 });
const app = new Hono();
app.use(
"*",
rateLimit({
service,
resolveIp: (c) =>
c.req.header("cf-connecting-ip") ?? "default-ip",
resolveTenantId: (c) => c.req.header("x-org") ?? undefined,
})
);
app.get("/", (c) => c.text("ok"));
const res1 = await app.request(
new Request("http://x/", {
headers: { "cf-connecting-ip": "5.5.5.5", "x-org": "A" },
})
);
expect(res1.status).toBe(200);
const res2 = await app.request(
new Request("http://x/", {
headers: { "cf-connecting-ip": "5.5.5.5", "x-org": "B" },
})
);
expect(res2.status).toBe(200); // different tenant -> independent
});
it("falls back to x-real-ip then 'unknown' when forwarded-for missing or empty", {
timeout: 5000,
}, async () => {
const service = makeService();
const app = new Hono();
app.use("*", rateLimit({ service }));
app.get("/", (c) => c.text("ok"));
// Empty first hop in xff exercises the trim-empty branch.
const r0 = await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": " , 1.1.1.1" },
})
);
expect(r0.status).toBe(200);
const r1 = await app.request(
new Request("http://x/", { headers: { "x-real-ip": "9.9.9.9" } })
);
expect(r1.status).toBe(200);
const r2 = await app.request(new Request("http://x/"));
expect(r2.status).toBe(200);
});
it("default tenant header read", { timeout: 5000 }, async () => {
const service = makeService({
defaultLimit: 1,
tenants: [{ tenantId: "T1", bypass: true }],
});
const app = new Hono();
app.use("*", rateLimit({ service }));
app.get("/", (c) => c.text("ok"));
await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1", "x-tenant-id": "T1" },
})
);
const res = await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.1.1.1", "x-tenant-id": "T1" },
})
);
expect(res.status).toBe(200); // bypass tenant — never denied
});
});
describe("rateLimitExceeded", () => {
it("constructs a LIMIT_EXCEEDED RateLimitPluginError carrying decision context", {
timeout: 5000,
}, () => {
const err = rateLimitExceeded({
allowed: false,
limit: 10,
remaining: 0,
resetIn: 60_000,
retryAfter: 30_000,
bypassed: false,
matchedRouteId: "login",
tenantId: "ent",
key: "k",
});
expect(err).toBeInstanceOf(RateLimitPluginError);
expect(err.code).toBe(RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED);
const ctx = err.context as Record<string, unknown>;
expect(ctx.statusCode).toBe(429);
expect(ctx.limit).toBe(10);
expect(ctx.routeId).toBe("login");
expect(ctx.tenantId).toBe("ent");
});
});

View File

@@ -0,0 +1,130 @@
import type { Context, MiddlewareHandler } from "hono";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import {
buildRateLimitHeaders,
buildRateLimitProblem,
} from "../headers/headers.js";
import type {
RateLimitCheckInput,
RateLimitService,
} from "../services/rate-limit-service.js";
import type {
RateLimitDecision,
RateLimitHeaderStyle,
} from "../types/types.js";
import "./context-augmentation.js";
/**
* Construction options for {@link rateLimit}. `service` is mandatory;
* the rest are optional knobs that override the plugin defaults
* per-route.
*/
export interface RateLimitMiddlewareOptions {
readonly service: RateLimitService;
readonly headerStyle?: RateLimitHeaderStyle;
readonly emitRetryAfter?: boolean;
readonly resolveTenantId?: (c: Context) => string | undefined;
readonly resolveIp?: (c: Context) => string;
readonly onDenied?: (decision: RateLimitDecision, c: Context) => void;
}
const DEFAULT_TENANT_RESOLVER = (c: Context): string | undefined => {
const header = c.req.header("x-tenant-id");
return header ?? undefined;
};
const DEFAULT_IP_RESOLVER = (c: Context): string => {
const forwarded = c.req.header("x-forwarded-for");
if (forwarded !== undefined && forwarded.length > 0) {
const first = forwarded.split(",")[0];
if (first !== undefined && first.trim().length > 0) return first.trim();
}
const real = c.req.header("x-real-ip");
if (real !== undefined && real.length > 0) return real;
return "unknown";
};
/**
* Construct a Hono `MiddlewareHandler` that consults the supplied
* {@link RateLimitService} on every request, emits the configured
* `X-RateLimit-*` / `RateLimit-*` headers on every response (allowed
* AND denied), and on denial:
*
* 1. Calls `onDenied(decision, c)` (if supplied) for side-effects.
* 2. Throws {@link RateLimitPluginError} `LIMIT_EXCEEDED` carrying
* `statusCode: 429`, the decision, and the headers — so the host's
* error-handler middleware can translate it into a proper response,
* OR returns the response itself if the host installs no handler.
*
* Sets `c.set("rateLimit", decision)` so downstream handlers can
* inspect the decision (typed via `ContextVariableMap`).
*/
export function rateLimit(
options: RateLimitMiddlewareOptions
): MiddlewareHandler {
const headerStyle =
options.headerStyle ?? options.service.config.headerStyle;
const emitRetryAfter =
options.emitRetryAfter ?? options.service.config.emitRetryAfter;
const resolveTenantId = options.resolveTenantId ?? DEFAULT_TENANT_RESOLVER;
const resolveIp = options.resolveIp ?? DEFAULT_IP_RESOLVER;
return async function rateLimitHandler(c, next) {
const input: RateLimitCheckInput = {
ip: resolveIp(c),
path: c.req.path,
method: c.req.method,
...(resolveTenantId(c) !== undefined
? { tenantId: resolveTenantId(c) as string }
: {}),
};
const decision = await options.service.check(input);
const headers = buildRateLimitHeaders({
decision,
style: headerStyle,
emitRetryAfter,
});
for (const [k, v] of Object.entries(headers)) c.res.headers.set(k, v);
c.set("rateLimit", decision);
if (!decision.allowed) {
options.onDenied?.(decision, c);
const body = buildRateLimitProblem(decision);
return c.json(body, 429, {
...headers,
"Content-Type": "application/problem+json",
});
}
await next();
for (const [k, v] of Object.entries(headers)) c.res.headers.set(k, v);
return;
};
}
/**
* Construct a `LIMIT_EXCEEDED` `RateLimitPluginError` for callers that
* want to throw the typed error rather than return a Response. The
* Hono middleware {@link rateLimit} returns the response directly so
* the host need not wire an error-handler; callers using the manual
* `service.check(...)` pattern can use this helper to raise a
* structured error compatible with `@suaveplan/error` translation.
*/
export function rateLimitExceeded(
decision: RateLimitDecision
): RateLimitPluginError {
return new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.LIMIT_EXCEEDED,
"Rate limit exceeded",
{
context: {
statusCode: 429,
limit: decision.limit,
remaining: decision.remaining,
retryAfter: decision.retryAfter,
routeId: decision.matchedRouteId,
tenantId: decision.tenantId,
},
}
);
}

View File

@@ -0,0 +1,5 @@
export {
getRateLimitInstruments,
type RateLimitInstruments,
resetRateLimitInstruments,
} from "./instruments.js";

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,47 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getRateLimitInstruments,
resetRateLimitInstruments,
} from "./instruments.js";
afterEach(() => {
resetRateLimitInstruments();
});
describe("getRateLimitInstruments", () => {
it("returns the same bundle on subsequent calls", { timeout: 5000 }, () => {
const a = getRateLimitInstruments();
const b = getRateLimitInstruments();
expect(a).toBe(b);
});
it("records data points that surface through the in-memory exporter", {
timeout: 5000,
}, async () => {
const instruments = getRateLimitInstruments();
instruments.checksTotal.add(2, {
outcome: "allowed",
routeId: "default",
});
instruments.denialsTotal.add(1, { routeId: "default" });
instruments.checkDurationMs.record(3.14, { outcome: "allowed" });
instruments.bypassedTotal.add(1, { reason: "ip-whitelist" });
const metrics = await flushTestMetrics();
const names = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("rate_limit.checks_total");
expect(names).toContain("rate_limit.denials_total");
expect(names).toContain("rate_limit.check_duration_ms");
expect(names).toContain("rate_limit.bypassed_total");
});
it("re-binds against the provider after reset", { timeout: 5000 }, () => {
const first = getRateLimitInstruments();
resetRateLimitInstruments();
const second = getRateLimitInstruments();
expect(second).not.toBe(first);
});
});

View File

@@ -0,0 +1,62 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the rate-limit plugin.
*
* - `checksTotal{outcome,routeId,tenantId}` — every call to
* `RateLimitService.check`. `outcome ∈ "allowed" | "denied" |
* "bypassed"`.
* - `denialsTotal{routeId,tenantId}` — incremented only when the
* request was denied (subset of `checksTotal`). Useful when the
* consumer wants a single counter for the rejection rate alert.
* - `checkDurationMs` — histogram of check duration in ms.
* - `bypassedTotal{reason}` — bypasses by `ip-whitelist` /
* `tenant-bypass`.
*/
export interface RateLimitInstruments {
readonly checksTotal: Counter;
readonly denialsTotal: Counter;
readonly checkDurationMs: Histogram;
readonly bypassedTotal: Counter;
}
let cached: RateLimitInstruments | undefined;
/**
* Lazily build (and cache) the rate-limit instruments bundle against
* the current global `MeterProvider`. Call
* {@link resetRateLimitInstruments} in test setup to force a re-bind
* against a freshly-installed provider.
*/
export function getRateLimitInstruments(): RateLimitInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-rate-limit");
cached = {
checksTotal: meter.createCounter("rate_limit.checks_total", {
description:
"Total rate-limit checks, partitioned by outcome / routeId / tenantId labels",
}),
denialsTotal: meter.createCounter("rate_limit.denials_total", {
description: "Total denied (429) requests",
}),
checkDurationMs: meter.createHistogram("rate_limit.check_duration_ms", {
description: "Rate-limit check duration in milliseconds",
unit: "ms",
}),
bypassedTotal: meter.createCounter("rate_limit.bypassed_total", {
description:
"Total bypassed checks (whitelisted IP or tenant bypass)",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getRateLimitInstruments} call binds against the global
* `MeterProvider` in effect at that point. Used by tests after
* installing a fresh provider.
*/
export function resetRateLimitInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,9 @@
export type {
CompiledPathPattern,
PathMatchResult,
} from "./path-matcher.js";
export {
compilePathPattern,
isPathMatch,
matchPath,
} from "./path-matcher.js";

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,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { compilePathPattern, isPathMatch, matchPath } from "./path-matcher.js";
describe("compilePathPattern + matchPath", () => {
it("matches an exact literal", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users");
expect(matchPath(p, "/api/users").matched).toBe(true);
expect(matchPath(p, "/api/users/").matched).toBe(false);
expect(matchPath(p, "/api/posts").matched).toBe(false);
});
it("matches trailing wildcard sub-paths", { timeout: 5000 }, () => {
expect(isPathMatch("/api/users/*", "/api/users/42/profile")).toBe(true);
expect(isPathMatch("/api/users/*", "/api/users/")).toBe(true);
expect(isPathMatch("/api/users/*", "/api/posts")).toBe(false);
});
it("captures :id named param", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users/:id");
const result = matchPath(p, "/api/users/42");
expect(result.matched).toBe(true);
expect(result.params.id).toBe("42");
});
it("captures multiple named params", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/orgs/:org/users/:user");
const r = matchPath(p, "/api/orgs/acme/users/u42");
expect(r.matched).toBe(true);
expect(r.params).toEqual({ org: "acme", user: "u42" });
});
it("inline segment wildcard binds exactly one segment", {
timeout: 5000,
}, () => {
expect(isPathMatch("/api/*/avatar", "/api/u1/avatar")).toBe(true);
expect(isPathMatch("/api/*/avatar", "/api/u1/sub/avatar")).toBe(false);
});
it("escapes regex metacharacters in literals", { timeout: 5000 }, () => {
expect(isPathMatch("/a.b", "/aXb")).toBe(false);
expect(isPathMatch("/a.b", "/a.b")).toBe(true);
});
it("throws INVALID_PATH_PATTERN when regex construction fails", {
timeout: 5000,
}, () => {
const originalRegExp = global.RegExp;
// Force RegExp to throw — defensive guard cover.
(global as { RegExp: unknown }).RegExp = function ThrowingRegExp() {
throw new SyntaxError("boom");
} as unknown as RegExpConstructor;
try {
let caught: RateLimitPluginError | undefined;
try {
compilePathPattern("/api/users");
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught).toBeInstanceOf(RateLimitPluginError);
expect(caught?.code).toBe(
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN
);
} finally {
(global as { RegExp: unknown }).RegExp = originalRegExp;
}
});
it("matchPath returns empty params on no match", { timeout: 5000 }, () => {
const p = compilePathPattern("/api/users/:id");
const r = matchPath(p, "/nope");
expect(r.matched).toBe(false);
expect(Object.keys(r.params)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,109 @@
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
/**
* A compiled path pattern ready for matching against inbound request
* paths. Created once per route rule at plugin init and reused per
* request.
*/
export interface CompiledPathPattern {
readonly source: string;
readonly regex: RegExp;
readonly paramNames: readonly string[];
}
/**
* Result of matching a compiled pattern against a path.
*/
export interface PathMatchResult {
readonly matched: boolean;
readonly params: Readonly<Record<string, string>>;
}
const PARAM_RE = /:[A-Za-z_][A-Za-z0-9_]*/g;
function escapeRegex(literal: string): string {
return literal.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
}
/**
* Compile a Hono-style path pattern into a `CompiledPathPattern`.
*
* Supported forms:
* - exact literal: `"/api/users"` matches only `/api/users`
* - trailing wildcard: `"/api/users/*"` matches anything under
* `/api/users/`
* - inline wildcard: `"/api/*"` matches one path segment
* - named param: `"/api/users/:id"` captures `id`
*
* Throws `RateLimitPluginError(INVALID_PATH_PATTERN)` when the
* compiled regex cannot be built — defensive guard so any future
* pattern-syntax mistake surfaces via the typed code rather than a
* bare `SyntaxError`.
*/
export function compilePathPattern(source: string): CompiledPathPattern {
const paramNames: string[] = [];
let cursor = 0;
let regexBody = "";
PARAM_RE.lastIndex = 0;
for (;;) {
const match = PARAM_RE.exec(source);
if (match === null) break;
const literal = source.slice(cursor, match.index);
regexBody += escapeRegex(literal).replace(/\\\*/g, "[^/]+");
const name = match[0].slice(1);
paramNames.push(name);
regexBody += "([^/]+)";
cursor = match.index + match[0].length;
}
const tail = source.slice(cursor);
if (tail.endsWith("/*")) {
regexBody += escapeRegex(tail.slice(0, -2)).replace(/\\\*/g, "[^/]+");
regexBody += "(?:/.*)?";
} else {
regexBody += escapeRegex(tail).replace(/\\\*/g, "[^/]+");
}
let regex: RegExp;
try {
regex = new RegExp(`^${regexBody}$`);
} catch (err) {
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN,
`Failed to compile path pattern '${source}'`,
{ cause: err, context: { source, statusCode: 500 } }
);
}
return { source, regex, paramNames };
}
/**
* Test a compiled pattern against a concrete path. Returns a
* `PathMatchResult` carrying any captured params keyed by the names
* declared in the source pattern.
*/
export function matchPath(
pattern: CompiledPathPattern,
path: string
): PathMatchResult {
const match = pattern.regex.exec(path);
if (match === null) return { matched: false, params: Object.freeze({}) };
const params: Record<string, string> = {};
for (let i = 0; i < pattern.paramNames.length; i++) {
const name = pattern.paramNames[i] as string;
// `([^/]+)` is non-optional so a successful regex match guarantees
// every capture group resolved to a non-empty string.
params[name] = match[i + 1] as string;
}
return { matched: true, params: Object.freeze(params) };
}
/**
* Convenience helper: compile and match in a single call. Suitable for
* one-off tests; in production the plugin caches `CompiledPathPattern`
* per route rule.
*/
export function isPathMatch(source: string, path: string): boolean {
return matchPath(compilePathPattern(source), path).matched;
}

View File

@@ -0,0 +1 @@
export { createRateLimitPlugin } from "./plugin.js";

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,158 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
RATE_LIMIT_PLUGIN_CODES,
type RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { resetRateLimitInstruments } from "../otel/instruments.js";
import type {
RateLimitPluginConfig,
RateLimitPluginOptions,
} from "../types/types.js";
import { createRateLimitPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeContext(services: Map<string, unknown>) {
return createPluginContext<RateLimitPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {
defaultLimit: 5,
defaultWindowMs: 60_000,
routes: [],
tenants: [],
ipWhitelist: [],
keyResolver: (i) => `${i.ip}|${i.tenantId ?? "_"}`,
headerStyle: "x-rate-limit",
emitRetryAfter: true,
},
services,
});
}
beforeEach(() => {
resetRateLimitInstruments();
});
describe("createRateLimitPlugin", () => {
it("returns a descriptor with name rate-limit and the resolved defaults", {
timeout: 5000,
}, () => {
const plugin = createRateLimitPlugin({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
expect(plugin.name).toBe("rate-limit");
expect(plugin.version).toBe("0.1.0");
expect(typeof plugin.onInit).toBe("function");
expect(typeof plugin.onClose).toBe("function");
const defaults = plugin.defaults as RateLimitPluginConfig;
expect(defaults.defaultLimit).toBe(100);
expect(defaults.headerStyle).toBe("x-rate-limit");
});
it("throws INVALID_INPUT when defaultLimit is negative", {
timeout: 5000,
}, () => {
let caught: RateLimitPluginError | undefined;
try {
createRateLimitPlugin({
defaultLimit: -1,
defaultWindowMs: 1_000,
} as unknown as RateLimitPluginOptions);
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught?.code).toBe(RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT);
expect(caught?.cause).toBeDefined();
});
it("throws SERVICE_NOT_AVAILABLE when the app slot is missing", {
timeout: 5000,
}, async () => {
const plugin = createRateLimitPlugin({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
const ctx = makeContext(new Map());
let caught: RateLimitPluginError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught?.code).toBe(
RATE_LIMIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE
);
});
it("registers rateLimitService and installs the global middleware", {
timeout: 5000,
}, async () => {
const plugin = createRateLimitPlugin({
defaultLimit: 2,
defaultWindowMs: 60_000,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = makeContext(services);
await plugin.onInit?.(ctx);
const service = ctx.getService("rateLimitService");
expect(service).toBeDefined();
// Ensure middleware is installed by hitting the app twice (within
// limit) and once over (denied).
const r1 = await app.request(
new Request("http://x/", {
headers: { "x-forwarded-for": "1.2.3.4" },
})
);
expect(r1.headers.get("X-RateLimit-Limit")).toBe("2");
});
it("onClose nulls internal state without error", {
timeout: 5000,
}, async () => {
const plugin = createRateLimitPlugin({
defaultLimit: 5,
defaultWindowMs: 60_000,
});
const ctx = makeContext(new Map());
await plugin.onClose?.(ctx);
});
it("uses caller-supplied logger and clock when present", {
timeout: 5000,
}, async () => {
let infoCalls = 0;
const logger = {
debug: () => undefined,
info: () => {
infoCalls += 1;
},
warn: () => undefined,
error: () => undefined,
};
const plugin = createRateLimitPlugin({
defaultLimit: 5,
defaultWindowMs: 60_000,
logger,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
services.set("clock", () => 42);
const ctx = makeContext(services);
await plugin.onInit?.(ctx);
expect(infoCalls).toBe(1);
});
});

View File

@@ -0,0 +1,125 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import type { Clock } from "@suaveplan/types";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { rateLimit } from "../middleware/rate-limit.js";
import { RateLimitPluginOptionsSchema } from "../schemas/options.js";
import {
createDefaultStorage,
createRateLimitService,
type RateLimitService,
} from "../services/rate-limit-service.js";
import "../services/registry-augmentation.js";
import type {
RateLimitPluginConfig,
RateLimitPluginOptions,
} from "../types/types.js";
/**
* Internal state owned by the plugin instance across the 4-phase
* lifecycle. Captured as a closure variable rather than on
* `PluginContext` because the registry's per-hook context is rebuilt
* each call.
*/
interface RateLimitPluginState {
service: RateLimitService | null;
}
/**
* Factory — produces a typed `PluginDescriptor` for the rate-limit
* plugin.
*
* Zod-validates the supplied options at the boundary so invalid input
* fails fast with `RateLimitPluginError(INVALID_INPUT)` carrying the
* `ZodError` as `cause`. The returned descriptor's `onInit` executes
* the canonical 6-step lifecycle:
*
* 1. Resolve `app` + `clock` + `logger` from the host
* `ServiceRegistry`.
* 2. Build the `RateLimitStorage` (caller-supplied or the bundled
* `MemoryStorage`).
* 3. Construct the `RateLimitService` bound to the resolved config.
* 4. Register `rateLimitService` in the `ServiceRegistry`.
* 5. Install the global `rateLimit` middleware on `ctx.app` so every
* request is rate-limited by default.
* 6. Emit a single info log so operators see the plugin came online.
*/
export function createRateLimitPlugin(
options: RateLimitPluginOptions
): PluginDescriptor<RateLimitPluginConfig> {
const parsed = RateLimitPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT,
"createRateLimitPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved = parsed.data;
const state: RateLimitPluginState = { service: null };
return definePlugin<RateLimitPluginConfig>({
name: "rate-limit",
version: "0.1.0",
description:
"Rate-limit plugin for @suaveplan/api. Wraps @suaveplan/rate-limiter with per-route + per-tenant configuration, IP whitelist, parameterised path matching, Hono middleware, and X-RateLimit-* / Retry-After headers.",
defaults: resolved,
tags: ["rate-limit", "security"],
onInit: async (ctx) => runOnInit(ctx, resolved, state),
onClose: async () => {
state.service = null;
},
});
}
async function runOnInit(
ctx: PluginContext<RateLimitPluginConfig>,
config: RateLimitPluginConfig,
state: RateLimitPluginState
): Promise<void> {
// (1) Resolve required services from the host ServiceRegistry.
const app = ctx.getService("app");
if (app === undefined) {
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"rate-limit plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const clock: Clock = ctx.getService("clock") ?? (() => Date.now());
const logger = config.logger ?? ctx.logger;
// (2) Build storage.
const storage = config.storage ?? createDefaultStorage({ clock });
// (3) Construct the service.
const service = createRateLimitService({
config,
storage,
clock,
logger,
});
state.service = service;
// (4) Register the service slot.
ctx.registerService("rateLimitService", service);
// (5) Install the global middleware.
app.use("*", rateLimit({ service }));
// (6) Log readiness.
logger.info("rate-limit plugin: ready", {
defaultLimit: config.defaultLimit,
defaultWindowMs: config.defaultWindowMs,
routes: config.routes.length,
tenants: config.tenants.length,
ipWhitelist: config.ipWhitelist.length,
headerStyle: config.headerStyle,
});
}

View File

@@ -0,0 +1,5 @@
export {
type RateLimitPluginOptionsInput,
type RateLimitPluginOptionsResolved,
RateLimitPluginOptionsSchema,
} from "./options.js";

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,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimitPluginOptionsSchema } from "./options.js";
describe("RateLimitPluginOptionsSchema", () => {
it("fills defaults for every optional field", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
});
expect(result.routes).toEqual([]);
expect(result.tenants).toEqual([]);
expect(result.ipWhitelist).toEqual([]);
expect(typeof result.keyResolver).toBe("function");
expect(result.headerStyle).toBe("x-rate-limit");
expect(result.emitRetryAfter).toBe(true);
});
it("default keyResolver renders ${ip}|${tenant}", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
});
expect(
result.keyResolver({
ip: "127.0.0.1",
tenantId: undefined,
path: "/",
method: "GET",
})
).toBe("127.0.0.1|_");
expect(
result.keyResolver({
ip: "1.2.3.4",
tenantId: "ent",
path: "/",
method: "GET",
})
).toBe("1.2.3.4|ent");
});
it("rejects negative defaultLimit", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: -1,
defaultWindowMs: 1_000,
});
expect(result.success).toBe(false);
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
const result = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
// @ts-expect-error — typos must fail loudly
defaultLimti: 999,
});
expect(result.success).toBe(false);
});
it("passes through caller-supplied keyResolver", { timeout: 5000 }, () => {
const custom = () => "k";
const result = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
keyResolver: custom,
});
expect(result.keyResolver).toBe(custom);
});
it("validates RouteRule shape and rejects empty path", {
timeout: 5000,
}, () => {
const bad = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
routes: [{ path: "", limit: 1, windowMs: 1 }],
});
expect(bad.success).toBe(false);
});
it("validates TenantRule shape", { timeout: 5000 }, () => {
const good = RateLimitPluginOptionsSchema.safeParse({
defaultLimit: 1,
defaultWindowMs: 1,
tenants: [
{ tenantId: "t1", multiplier: 2 },
{ tenantId: "t2", bypass: true },
],
});
expect(good.success).toBe(true);
});
it("accepts all three header styles", { timeout: 5000 }, () => {
for (const headerStyle of [
"x-rate-limit",
"rfc-9238",
"both",
] as const) {
const r = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 1,
headerStyle,
});
expect(r.headerStyle).toBe(headerStyle);
}
});
});

View File

@@ -0,0 +1,65 @@
import z from "zod";
import type {
RateLimitKeyResolver,
RateLimitPluginConfig,
RateLimitPluginOptions,
} from "../types/types.js";
/**
* Default key resolver — `${ip}|${tenantId ?? "_"}`. Uses pipe rather
* than colon to avoid colliding with `keyPrefix:storage-key` joins in
* the underlying storage adapter.
*/
const defaultKeyResolver: RateLimitKeyResolver = (input) =>
`${input.ip}|${input.tenantId ?? "_"}`;
const RouteRuleSchema = z.object({
path: z.string().min(1),
method: z.string().min(1).optional(),
limit: z.number().int().positive(),
windowMs: z.number().int().positive(),
id: z.string().min(1).optional(),
});
const TenantRuleSchema = z.object({
tenantId: z.string().min(1),
multiplier: z.number().positive().optional(),
bypass: z.boolean().optional(),
});
/**
* Zod schema for the plugin's caller-facing options. Applies defaults
* for every optional field so the resolved config is fully populated.
*
* `storage` and `logger` are skipped because their values are runtime
* objects, not serialisable — they are passed through the schema
* untouched via `z.custom`.
*/
export const RateLimitPluginOptionsSchema = z
.object({
defaultLimit: z.number().int().nonnegative(),
defaultWindowMs: z.number().int().positive(),
routes: z.array(RouteRuleSchema).default([]),
tenants: z.array(TenantRuleSchema).default([]),
ipWhitelist: z.array(z.string().min(1)).default([]),
keyResolver: z
.custom<RateLimitKeyResolver>((v) => typeof v === "function")
.default(() => defaultKeyResolver),
headerStyle: z
.enum(["x-rate-limit", "rfc-9238", "both"])
.default("x-rate-limit"),
emitRetryAfter: z.boolean().default(true),
storage: z.custom<RateLimitPluginConfig["storage"]>().optional(),
logger: z.custom<RateLimitPluginConfig["logger"]>().optional(),
})
.strict() satisfies z.ZodType<
RateLimitPluginConfig,
RateLimitPluginOptions
>;
export type RateLimitPluginOptionsInput = z.input<
typeof RateLimitPluginOptionsSchema
>;
export type RateLimitPluginOptionsResolved = z.output<
typeof RateLimitPluginOptionsSchema
>;

View File

@@ -0,0 +1,7 @@
export {
createDefaultStorage,
createRateLimitService,
type RateLimitCheckInput,
type RateLimitService,
} from "./rate-limit-service.js";
export { registerRateLimitServiceSlot } from "./registry-augmentation.js";

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,396 @@
import {
MemoryStorage,
type RateLimitState,
type RateLimitStorage,
} from "@suaveplan/rate-limiter";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
RATE_LIMIT_PLUGIN_CODES,
type RateLimitPluginError,
} from "../error-codes/error-codes.js";
import { resetRateLimitInstruments } from "../otel/instruments.js";
import { RateLimitPluginOptionsSchema } from "../schemas/options.js";
import type { RateLimitPluginConfig } from "../types/types.js";
import {
createDefaultStorage,
createRateLimitService,
} from "./rate-limit-service.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let clockNow = 0;
const clock = () => clockNow;
function makeService(extra: Partial<RateLimitPluginConfig> = {}) {
const baseInput = RateLimitPluginOptionsSchema.parse({
defaultLimit: 5,
defaultWindowMs: 60_000,
...extra,
});
const config: RateLimitPluginConfig = baseInput;
const storage = createDefaultStorage({ clock });
const service = createRateLimitService({
config,
storage,
clock,
logger: noopLogger,
});
return { service, config, storage };
}
beforeEach(() => {
clockNow = 1_000_000;
resetRateLimitInstruments();
});
afterEach(async () => {
await flushTestMetrics();
});
describe("createRateLimitService — happy path", () => {
it("allows up to defaultLimit then denies the next", {
timeout: 5000,
}, async () => {
const { service } = makeService({ defaultLimit: 3 });
for (let i = 0; i < 3; i++) {
const d = await service.check({
ip: "1.1.1.1",
path: "/",
method: "GET",
});
expect(d.allowed).toBe(true);
}
const denied = await service.check({
ip: "1.1.1.1",
path: "/",
method: "GET",
});
expect(denied.allowed).toBe(false);
expect(denied.limit).toBe(3);
expect(denied.retryAfter).toBeGreaterThan(0);
});
it("reset clears the counter", { timeout: 5000 }, async () => {
const { service } = makeService({ defaultLimit: 1 });
await service.check({ ip: "1.1.1.1", path: "/", method: "GET" });
const denied = await service.check({
ip: "1.1.1.1",
path: "/",
method: "GET",
});
expect(denied.allowed).toBe(false);
await service.reset("1.1.1.1|_");
const allowed = await service.check({
ip: "1.1.1.1",
path: "/",
method: "GET",
});
expect(allowed.allowed).toBe(true);
});
});
describe("createRateLimitService — bypass", () => {
it("short-circuits when the IP is whitelisted", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 0,
ipWhitelist: ["127.0.0.1"],
});
const d = await service.check({
ip: "127.0.0.1",
path: "/",
method: "GET",
});
expect(d.allowed).toBe(true);
expect(d.bypassed).toBe(true);
});
it("short-circuits when the tenant has bypass set", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 0,
tenants: [{ tenantId: "internal", bypass: true }],
});
const d = await service.check({
ip: "1.1.1.1",
tenantId: "internal",
path: "/",
method: "GET",
});
expect(d.allowed).toBe(true);
expect(d.bypassed).toBe(true);
});
});
describe("createRateLimitService — tenant multiplier", () => {
it("applies tenant multiplier to the numeric limit", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 5,
tenants: [{ tenantId: "ent", multiplier: 10 }],
});
for (let i = 0; i < 50; i++) {
const d = await service.check({
ip: "1.1.1.1",
tenantId: "ent",
path: "/",
method: "GET",
});
expect(d.allowed).toBe(true);
expect(d.limit).toBe(50);
}
const denied = await service.check({
ip: "1.1.1.1",
tenantId: "ent",
path: "/",
method: "GET",
});
expect(denied.allowed).toBe(false);
});
it("multiplier defaults to 1 when tenant has no override", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 2,
tenants: [{ tenantId: "other", multiplier: 5 }],
});
await service.check({
ip: "1.1.1.1",
tenantId: "no-override",
path: "/",
method: "GET",
});
const second = await service.check({
ip: "1.1.1.1",
tenantId: "no-override",
path: "/",
method: "GET",
});
expect(second.limit).toBe(2);
});
});
describe("createRateLimitService — route matching", () => {
it("matches a route rule with method constraint", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 100,
routes: [
{
path: "/login",
method: "POST",
limit: 1,
windowMs: 60_000,
id: "login",
},
],
});
const first = await service.check({
ip: "1.1.1.1",
path: "/login",
method: "POST",
});
expect(first.allowed).toBe(true);
expect(first.matchedRouteId).toBe("login");
const second = await service.check({
ip: "1.1.1.1",
path: "/login",
method: "POST",
});
expect(second.allowed).toBe(false);
});
it("skips a route rule when the method does not match", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 100,
routes: [
{
path: "/login",
method: "POST",
limit: 1,
windowMs: 60_000,
id: "login",
},
],
});
const d = await service.check({
ip: "1.1.1.1",
path: "/login",
method: "GET",
});
expect(d.matchedRouteId).toBeUndefined();
});
it("falls through to default route when no rule matches the path", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 5,
routes: [
{ path: "/login", limit: 1, windowMs: 60_000, id: "login" },
],
});
const d = await service.check({
ip: "1.1.1.1",
path: "/no-match",
method: "GET",
});
expect(d.matchedRouteId).toBeUndefined();
expect(d.limit).toBe(5);
});
it("auto-assigns route id when none is supplied", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 10,
routes: [{ path: "/api/users", limit: 1, windowMs: 60_000 }],
});
const d = await service.check({
ip: "1.1.1.1",
path: "/api/users",
method: "GET",
});
expect(d.matchedRouteId).toBe("route_0");
});
});
describe("createRateLimitService — storage failure (non-Error)", () => {
it("wraps a non-Error throwable from storage as STORAGE_FAILURE", {
timeout: 5000,
}, async () => {
const failing: RateLimitStorage = {
get: async () => {
const x: unknown = "boom-literal";
throw x;
},
set: async () => undefined,
delete: async () => undefined,
clear: async () => undefined,
};
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 60_000,
});
const service = createRateLimitService({
config,
storage: failing,
clock,
logger: noopLogger,
});
let caught: RateLimitPluginError | undefined;
try {
await service.check({ ip: "1.1.1.1", path: "/", method: "GET" });
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught?.code).toBe(RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE);
});
});
describe("createRateLimitService — storage failure", () => {
it("wraps storage failure as STORAGE_FAILURE", {
timeout: 5000,
}, async () => {
const failing: RateLimitStorage = {
get: async () => {
throw new Error("boom-get");
},
set: async () => undefined,
delete: async () => undefined,
clear: async () => undefined,
};
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 1,
defaultWindowMs: 60_000,
});
const service = createRateLimitService({
config,
storage: failing,
clock,
logger: noopLogger,
});
let caught: RateLimitPluginError | undefined;
try {
await service.check({ ip: "1.1.1.1", path: "/", method: "GET" });
} catch (err) {
caught = err as RateLimitPluginError;
}
expect(caught?.code).toBe(RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE);
expect(caught?.cause).toBeInstanceOf(Error);
});
});
describe("createRateLimitService — OTel", () => {
it("emits OTel counters and histogram", { timeout: 5000 }, async () => {
const { service } = makeService({ defaultLimit: 1 });
await service.check({ ip: "1.1.1.1", path: "/", method: "GET" });
await service.check({ ip: "1.1.1.1", path: "/", method: "GET" }); // denied
const data = await flushTestMetrics();
const names = data
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("rate_limit.checks_total");
expect(names).toContain("rate_limit.denials_total");
expect(names).toContain("rate_limit.check_duration_ms");
});
it("increments bypassed_total for whitelisted IPs", {
timeout: 5000,
}, async () => {
const { service } = makeService({
defaultLimit: 1,
ipWhitelist: ["127.0.0.1"],
});
await service.check({
ip: "127.0.0.1",
path: "/",
method: "GET",
});
const data = await flushTestMetrics();
const names = data
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("rate_limit.bypassed_total");
});
});
describe("createDefaultStorage", () => {
it("returns a MemoryStorage-backed adapter", {
timeout: 5000,
}, async () => {
const storage = createDefaultStorage({ clock });
const state: RateLimitState = {
key: "k",
algorithm: "sliding-window",
state: { requests: [clock()] },
config: { limit: 1, window: 1_000 },
updatedAt: clock(),
};
await storage.set("k", state, 1_000);
const got = await storage.get("k");
expect(got?.key).toBe("k");
expect(storage).toBeInstanceOf(MemoryStorage);
});
});

View File

@@ -0,0 +1,309 @@
import {
createRateLimiter,
MemoryStorage,
type RateLimiterInstance,
type RateLimitStorage,
} from "@suaveplan/rate-limiter";
import type { Clock, LoggerLike } from "@suaveplan/types";
import {
RATE_LIMIT_PLUGIN_CODES,
RateLimitPluginError,
} from "../error-codes/error-codes.js";
import {
getRateLimitInstruments,
type RateLimitInstruments,
} from "../otel/instruments.js";
import {
type CompiledPathPattern,
compilePathPattern,
matchPath,
} from "../path-matcher/path-matcher.js";
import type {
RateLimitDecision,
RateLimitKeyResolver,
RateLimitPluginConfig,
RouteRule,
TenantRule,
} from "../types/types.js";
/**
* Input to {@link RateLimitService.check}. The middleware fills these
* fields from the inbound `Request`; callers wiring `check()` by hand
* supply them directly.
*/
export interface RateLimitCheckInput {
readonly ip: string;
readonly tenantId?: string;
readonly path: string;
readonly method: string;
}
/**
* Service registered in the host `ServiceRegistry` under the
* `rateLimitService` slot. Wraps `@suaveplan/rate-limiter` with the
* plugin's per-route + per-tenant configuration, IP whitelist, and
* OTel emission.
*/
export interface RateLimitService {
/**
* Evaluate the inbound request and return a `RateLimitDecision`.
* The middleware uses this to choose between `next()` and a 429
* response; consumers may also wire it manually inside a route
* handler.
*/
check(input: RateLimitCheckInput): Promise<RateLimitDecision>;
/**
* Reset the counter for the given resolved key — call after a
* successful login / password reset / similar trust-restoring
* action so the user is not throttled mid-flow.
*/
reset(key: string): Promise<void>;
/**
* Inspect the live config the service was instantiated with.
* Useful for admin / debug routes and tests.
*/
readonly config: RateLimitPluginConfig;
}
interface CompiledRoute {
readonly rule: RouteRule;
readonly pattern: CompiledPathPattern;
readonly id: string;
readonly limiters: Map<string, RateLimiterInstance>;
}
interface BuildArgs {
readonly config: RateLimitPluginConfig;
readonly storage: RateLimitStorage;
readonly clock: Clock;
readonly logger: LoggerLike;
}
const DEFAULT_TENANT_KEY = "__default__";
/**
* Construct a {@link RateLimitService} bound to the resolved plugin
* config. The factory:
*
* 1. Compiles every `RouteRule.path` once via `compilePathPattern`.
* 2. Lazily builds one `createRateLimiter(...)` instance per
* (route, tenant-multiplier) pair so the effective numeric limit
* is wired into the algorithm itself rather than re-checked after.
* 3. Indexes tenant overrides by id for O(1) lookup.
* 4. Returns the service closure — every `.check()` call:
* - applies IP-whitelist bypass first
* - applies tenant-bypass second
* - matches the first route rule whose pattern hits
* - applies the tenant multiplier to the effective limit
* - delegates to the appropriate `RateLimiterInstance.check`
* - records the OTel counters + histogram
*/
export function createRateLimitService(args: BuildArgs): RateLimitService {
const { config, storage, clock, logger } = args;
const tenantMap = new Map<string, TenantRule>();
for (const tenant of config.tenants) tenantMap.set(tenant.tenantId, tenant);
const ipWhitelist = new Set(config.ipWhitelist);
const compiledRoutes: CompiledRoute[] = config.routes.map((rule, index) => {
const id = rule.id ?? `route_${index}`;
return {
rule,
pattern: compilePathPattern(rule.path),
id,
limiters: new Map(),
};
});
const defaultRoute: CompiledRoute = {
rule: {
path: "/*",
limit: config.defaultLimit,
windowMs: config.defaultWindowMs,
id: "default",
},
pattern: compilePathPattern("/*"),
id: "default",
limiters: new Map(),
};
function limiterFor(
route: CompiledRoute,
tenantKey: string,
effectiveLimit: number
): RateLimiterInstance {
let limiter = route.limiters.get(tenantKey);
if (limiter === undefined) {
limiter = createRateLimiter({
defaultConfig: {
limit: effectiveLimit,
window: route.rule.windowMs,
id: route.id,
},
storage,
keyPrefix: `rl:${route.id}:${tenantKey}`,
clock,
});
route.limiters.set(tenantKey, limiter);
}
return limiter;
}
const keyResolver: RateLimitKeyResolver = config.keyResolver;
const instruments: RateLimitInstruments = getRateLimitInstruments();
async function check(
input: RateLimitCheckInput
): Promise<RateLimitDecision> {
const started = clock();
const key = keyResolver({
ip: input.ip,
tenantId: input.tenantId,
path: input.path,
method: input.method,
});
if (ipWhitelist.has(input.ip)) {
instruments.bypassedTotal.add(1, { reason: "ip-whitelist" });
instruments.checksTotal.add(1, { outcome: "bypassed" });
return {
allowed: true,
limit: config.defaultLimit,
remaining: config.defaultLimit,
resetIn: 0,
retryAfter: undefined,
bypassed: true,
matchedRouteId: undefined,
tenantId: input.tenantId,
key,
};
}
const tenant =
input.tenantId !== undefined
? tenantMap.get(input.tenantId)
: undefined;
if (tenant?.bypass === true) {
instruments.bypassedTotal.add(1, { reason: "tenant-bypass" });
instruments.checksTotal.add(1, {
outcome: "bypassed",
tenantId: tenant["tenantId"],
});
return {
allowed: true,
limit: config.defaultLimit,
remaining: config.defaultLimit,
resetIn: 0,
retryAfter: undefined,
bypassed: true,
matchedRouteId: undefined,
tenantId: input.tenantId,
key,
};
}
const matched = findRouteMatch(compiledRoutes, input);
const route = matched ?? defaultRoute;
const baseLimit = route.rule.limit;
const multiplier =
tenant?.multiplier !== undefined ? tenant.multiplier : 1;
const effectiveLimit = Math.max(1, Math.round(baseLimit * multiplier));
const tenantKey =
tenant !== undefined ? tenant.tenantId : DEFAULT_TENANT_KEY;
const limiter = limiterFor(route, tenantKey, effectiveLimit);
let result: Awaited<ReturnType<RateLimiterInstance["check"]>>;
try {
const ctx: {
key: string;
tenantId?: string;
path: string;
method: string;
ip: string;
} = {
key: `${route.id}|${key}`,
path: input.path,
method: input.method,
ip: input.ip,
};
if (input.tenantId !== undefined) ctx.tenantId = input.tenantId;
result = await limiter.check(ctx);
} catch (err) {
// The underlying RateLimiter always wraps storage failures as
// RateLimiterError (which extends Error) so `err.message` is
// always defined.
logger.warn("rate-limit: storage failure", {
error: (err as Error).message,
});
throw new RateLimitPluginError(
RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE,
"rate-limit storage failure",
{ cause: err, context: { statusCode: 500 } }
);
}
const allowed = result.allowed;
const outcome = allowed ? "allowed" : "denied";
const attrs: Record<string, string> = {
outcome,
routeId: route.id,
};
if (input.tenantId !== undefined) attrs["tenantId"] = input.tenantId;
instruments.checksTotal.add(1, attrs);
if (!allowed) {
const denialAttrs: Record<string, string> = {
routeId: route.id,
};
if (input.tenantId !== undefined)
denialAttrs["tenantId"] = input.tenantId;
instruments.denialsTotal.add(1, denialAttrs);
}
instruments.checkDurationMs.record(clock() - started, { outcome });
return {
allowed,
limit: effectiveLimit,
remaining: result.remaining,
resetIn: result.resetIn,
retryAfter: result.retryAfter,
bypassed: false,
matchedRouteId: matched?.id,
tenantId: input.tenantId,
key,
};
}
async function reset(key: string): Promise<void> {
for (const route of [defaultRoute, ...compiledRoutes]) {
for (const limiter of route.limiters.values()) {
await limiter.reset(`${route.id}|${key}`);
}
}
}
return { check, reset, config };
}
function findRouteMatch(
routes: readonly CompiledRoute[],
input: RateLimitCheckInput
): CompiledRoute | undefined {
for (const route of routes) {
if (
route.rule.method !== undefined &&
route.rule.method.toUpperCase() !== input.method.toUpperCase()
) {
continue;
}
const result = matchPath(route.pattern, input.path);
if (result.matched) return route;
}
return undefined;
}
/**
* Construct an `@suaveplan/rate-limiter` `MemoryStorage` with the
* plugin's clock injection. Used when the caller does not supply a
* custom storage adapter.
*/
export function createDefaultStorage(args: { clock: Clock }): RateLimitStorage {
return new MemoryStorage({ clock: args.clock });
}

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,11 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { registerRateLimitServiceSlot } from "./registry-augmentation.js";
describe("registerRateLimitServiceSlot", () => {
it("returns undefined and is callable repeatedly", {
timeout: 5000,
}, () => {
expect(registerRateLimitServiceSlot()).toBeUndefined();
expect(registerRateLimitServiceSlot()).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
/**
* ServiceRegistry module augmentation — declares the
* `rateLimitService` slot on `@suaveplan/api-plugin/service-registry`'s
* `ServiceRegistry` interface so downstream plugins typed against the
* augmented registry see `ctx.getService("rateLimitService")` resolve
* to `RateLimitService | undefined` without any cast.
*
* Importing this module for its side effects (which the package root
* barrel does automatically) is sufficient — every consumer of
* `@suaveplan/api-plugin-rate-limit` gets typed `rateLimitService`
* access.
*
* The host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`,
* `clock`) are owned by `@suaveplan/api`'s own registry-augmentation
* module. We re-declare them here as a cumulative augmentation so the
* plugin's `onInit(ctx)` can resolve them with full typing even when
* the consumer has not explicitly imported `@suaveplan/api` (e.g.
* during isolated unit-tests of the plugin). TypeScript merges
* duplicate declarations, so this is safe alongside `@suaveplan/api`'s
* declarations.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { RateLimitService } from "./rate-limit-service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
rateLimitService: RateLimitService;
app: OpenAPIHono;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}
/**
* No-op runtime export so the module is preserved through dead-code
* elimination and consumers can rely on a side-effect import surfacing
* the augmentation.
*/
export function registerRateLimitServiceSlot(): void {
// intentional no-op — declaration above is what matters
}

View File

@@ -0,0 +1,12 @@
export type {
RateLimitConfig,
RateLimitDecision,
RateLimitHeaderStyle,
RateLimitKeyResolver,
RateLimitKeyResolverInput,
RateLimitPluginConfig,
RateLimitPluginOptions,
RateLimitStorage,
RouteRule,
TenantRule,
} from "./types.js";

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.

View File

@@ -0,0 +1,122 @@
import type {
RateLimitConfig,
RateLimitStorage,
} from "@suaveplan/rate-limiter";
import type { LoggerLike } from "@suaveplan/types";
/**
* Header-style emitted by the plugin alongside `429`/allowed responses.
*
* - `x-rate-limit` — legacy `X-RateLimit-Limit` / `X-RateLimit-Remaining`
* / `X-RateLimit-Reset` triple, widely deployed and understood by
* browser developer tooling.
* - `rfc-9238` — draft `RateLimit-Limit` / `RateLimit-Remaining` /
* `RateLimit-Reset` triple from IETF draft RFC 9238.
* - `both` — emit both header families.
*/
export type RateLimitHeaderStyle = "x-rate-limit" | "rfc-9238" | "both";
/**
* Per-route rate-limit override. The first rule whose `path` (and
* optionally `method`) matches the inbound request supplies the
* `limit` + `windowMs`. Rules are evaluated in declared order.
*
* `path` accepts three forms:
* - exact match: `"/api/users"`
* - prefix wildcard: `"/api/users/*"`
* - Hono-style param: `"/api/users/:id"` (params bind, value ignored)
*/
export interface RouteRule {
readonly path: string;
readonly method?: string | undefined;
readonly limit: number;
readonly windowMs: number;
readonly id?: string | undefined;
}
/**
* Per-tenant override. The numeric `multiplier` is APPLIED to the
* effective `limit` (rounded), so a tenant with multiplier `5` gets
* 5× the default cap. `bypass: true` skips rate-limit checks entirely.
*/
export interface TenantRule {
readonly tenantId: string;
readonly multiplier?: number | undefined;
readonly bypass?: boolean | undefined;
}
/**
* Caller-facing input to `createRateLimitPlugin`. All fields except
* `defaultLimit` and `defaultWindowMs` are optional; defaults are filled
* in via `RateLimitPluginOptionsSchema`.
*/
export interface RateLimitPluginOptions {
readonly defaultLimit: number;
readonly defaultWindowMs: number;
readonly routes?: readonly RouteRule[] | undefined;
readonly tenants?: readonly TenantRule[] | undefined;
readonly ipWhitelist?: readonly string[] | undefined;
readonly keyResolver?: RateLimitKeyResolver | undefined;
readonly storage?: RateLimitStorage | undefined;
readonly headerStyle?: RateLimitHeaderStyle | undefined;
readonly emitRetryAfter?: boolean | undefined;
readonly logger?: LoggerLike | undefined;
}
/**
* Resolved plugin config — every option is non-optional after
* `RateLimitPluginOptionsSchema` parses defaults in. Used as the
* `TConfig` parameter on the returned `PluginDescriptor`.
*/
export interface RateLimitPluginConfig {
readonly defaultLimit: number;
readonly defaultWindowMs: number;
readonly routes: readonly RouteRule[];
readonly tenants: readonly TenantRule[];
readonly ipWhitelist: readonly string[];
readonly keyResolver: RateLimitKeyResolver;
readonly headerStyle: RateLimitHeaderStyle;
readonly emitRetryAfter: boolean;
readonly storage?: RateLimitStorage | undefined;
readonly logger?: LoggerLike | undefined;
}
/**
* Caller-supplied function used to derive the rate-limit key from an
* incoming request. Defaults to `${ip}|${tenantId ?? "_"}`.
*/
export type RateLimitKeyResolver = (input: RateLimitKeyResolverInput) => string;
/**
* Input bundle handed to a `RateLimitKeyResolver`.
*/
export interface RateLimitKeyResolverInput {
readonly ip: string;
readonly tenantId: string | undefined;
readonly path: string;
readonly method: string;
}
/**
* Snapshot of a single rate-limit decision. Returned by
* `RateLimitService.check` and consumed by the Hono middleware to
* decide between `next()` and a 429 response.
*/
export interface RateLimitDecision {
readonly allowed: boolean;
readonly limit: number;
readonly remaining: number;
readonly resetIn: number;
readonly retryAfter: number | undefined;
readonly bypassed: boolean;
readonly matchedRouteId: string | undefined;
readonly tenantId: string | undefined;
readonly key: string;
}
/**
* Sibling-package re-export of `RateLimitConfig` so consumers do not
* need to depend on `@suaveplan/rate-limiter` directly when authoring
* custom storage adapters.
*/
export type { RateLimitConfig, RateLimitStorage };

View File

@@ -0,0 +1,65 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { registerRateLimitServiceSlot } from "../src/services/registry-augmentation.js";
const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = join(here, "..");
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
describe("dep-graph", () => {
it("ships ten subpath exports including the root", {
timeout: 5000,
}, () => {
const exportKeys = Object.keys(pkg.exports).filter(
(k: string) => k !== "./package.json"
);
expect(exportKeys.length).toBeGreaterThanOrEqual(10);
});
it("declares the canonical runtime deps", { timeout: 5000 }, () => {
const deps = pkg.dependencies as Record<string, string>;
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
expect(deps["@suaveplan/rate-limiter"]).toBe("workspace:^");
expect(deps["@suaveplan/error"]).toBe("workspace:^");
expect(deps["@suaveplan/types"]).toBe("workspace:^");
expect(deps.hono).toBe("catalog:");
expect(deps.zod).toBe("catalog:forms");
});
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) walk(p, out);
else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".types.ts") &&
entry !== "index.ts"
)
out.push(p);
}
return out;
}
it("registerRateLimitServiceSlot is callable without throwing", {
timeout: 5000,
}, () => {
expect(() => registerRateLimitServiceSlot()).not.toThrow();
});
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
const sources = walk(join(pkgRoot, "src"));
const missing: string[] = [];
for (const ts of sources) {
const md = ts.replace(/\.ts$/, ".md");
try {
statSync(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,164 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRateLimitPlugin } from "../../src/plugin/plugin.js";
import type { RateLimitPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
async function bootApp(extra: Partial<RateLimitPluginConfig> = {}) {
const plugin = createRateLimitPlugin({
defaultLimit: 3,
defaultWindowMs: 60_000,
routes: [
{
path: "/api/login",
method: "POST",
limit: 1,
windowMs: 60_000,
id: "login",
},
],
tenants: [
{ tenantId: "ent", multiplier: 5 },
{ tenantId: "internal", bypass: true },
],
...extra,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const ctx = createPluginContext<RateLimitPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as RateLimitPluginConfig,
services,
});
await plugin.onInit?.(ctx);
app.get("/api/users", (c) => c.text("ok"));
app.post("/api/login", (c) => c.text("ok"));
return { app, plugin };
}
describe("integration — full app", () => {
it("isolates rate-limit counters per tenant", {
timeout: 30_000,
}, async () => {
const { app } = await bootApp();
// Tenant A exhausts a defaultLimit of 3
for (let i = 0; i < 3; i++) {
const res = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "1.1.1.1",
"x-tenant-id": "A",
},
})
);
expect(res.status).toBe(200);
}
const denied = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "1.1.1.1",
"x-tenant-id": "A",
},
})
);
expect(denied.status).toBe(429);
// Tenant B still has its own counter
const bRes = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "1.1.1.1",
"x-tenant-id": "B",
},
})
);
expect(bRes.status).toBe(200);
expect(bRes.headers.get("X-RateLimit-Remaining")).toBe("2");
});
it("honours per-route limit (login = 1 per 60s)", {
timeout: 30_000,
}, async () => {
const { app } = await bootApp();
const first = await app.request(
new Request("http://x/api/login", {
method: "POST",
headers: { "x-forwarded-for": "2.2.2.2" },
})
);
expect(first.status).toBe(200);
const second = await app.request(
new Request("http://x/api/login", {
method: "POST",
headers: { "x-forwarded-for": "2.2.2.2" },
})
);
expect(second.status).toBe(429);
expect(second.headers.get("Retry-After")).toBeTruthy();
});
it("applies tenant multiplier to the limit", {
timeout: 30_000,
}, async () => {
const { app } = await bootApp();
// ent has multiplier 5 on a default of 3 -> 15 allowed
let lastStatus = 0;
for (let i = 0; i < 15; i++) {
const res = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "3.3.3.3",
"x-tenant-id": "ent",
},
})
);
lastStatus = res.status;
}
expect(lastStatus).toBe(200);
const denied = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "3.3.3.3",
"x-tenant-id": "ent",
},
})
);
expect(denied.status).toBe(429);
});
it("bypasses internal tenant entirely", { timeout: 30_000 }, async () => {
const { app } = await bootApp({ defaultLimit: 1 });
for (let i = 0; i < 25; i++) {
const res = await app.request(
new Request("http://x/api/users", {
headers: {
"x-forwarded-for": "4.4.4.4",
"x-tenant-id": "internal",
},
})
);
expect(res.status).toBe(200);
}
});
it("emits headers on every response", { timeout: 30_000 }, async () => {
const { app } = await bootApp({ headerStyle: "both" });
const res = await app.request(
new Request("http://x/api/users", {
headers: { "x-forwarded-for": "5.5.5.5" },
})
);
expect(res.headers.get("X-RateLimit-Limit")).toBeTruthy();
expect(res.headers.get("RateLimit-Limit")).toBeTruthy();
});
});

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-rate-limit.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-rate-limit",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 60000,
},
});