Initial Commit
This commit is contained in:
133
packages/api-stack/api/api-plugin-rate-limit/README.md
Normal file
133
packages/api-stack/api/api-plugin-rate-limit/README.md
Normal 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.
|
||||
|
||||
120
packages/api-stack/api/api-plugin-rate-limit/package.json
Normal file
120
packages/api-stack/api/api-plugin-rate-limit/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { RateLimitPluginErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
RATE_LIMIT_PLUGIN_CODES,
|
||||
RateLimitPluginError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
RateLimitHeaders,
|
||||
RateLimitProblemBody,
|
||||
} from "./headers.js";
|
||||
export {
|
||||
buildRateLimitHeaders,
|
||||
buildRateLimitProblem,
|
||||
} from "./headers.js";
|
||||
75
packages/api-stack/api/api-plugin-rate-limit/src/index.ts
Normal file
75
packages/api-stack/api/api-plugin-rate-limit/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RateLimitMiddlewareOptions,
|
||||
rateLimit,
|
||||
rateLimitExceeded,
|
||||
} from "./rate-limit.js";
|
||||
@@ -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")`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getRateLimitInstruments,
|
||||
type RateLimitInstruments,
|
||||
resetRateLimitInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
CompiledPathPattern,
|
||||
PathMatchResult,
|
||||
} from "./path-matcher.js";
|
||||
export {
|
||||
compilePathPattern,
|
||||
isPathMatch,
|
||||
matchPath,
|
||||
} from "./path-matcher.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createRateLimitPlugin } from "./plugin.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RateLimitPluginOptionsInput,
|
||||
type RateLimitPluginOptionsResolved,
|
||||
RateLimitPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createDefaultStorage,
|
||||
createRateLimitService,
|
||||
type RateLimitCheckInput,
|
||||
type RateLimitService,
|
||||
} from "./rate-limit-service.js";
|
||||
export { registerRateLimitServiceSlot } from "./registry-augmentation.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
RateLimitConfig,
|
||||
RateLimitDecision,
|
||||
RateLimitHeaderStyle,
|
||||
RateLimitKeyResolver,
|
||||
RateLimitKeyResolverInput,
|
||||
RateLimitPluginConfig,
|
||||
RateLimitPluginOptions,
|
||||
RateLimitStorage,
|
||||
RouteRule,
|
||||
TenantRule,
|
||||
} from "./types.js";
|
||||
108
packages/api-stack/api/api-plugin-rate-limit/src/types/types.md
Normal file
108
packages/api-stack/api/api-plugin-rate-limit/src/types/types.md
Normal 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.
|
||||
122
packages/api-stack/api/api-plugin-rate-limit/src/types/types.ts
Normal file
122
packages/api-stack/api/api-plugin-rate-limit/src/types/types.ts
Normal 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 };
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-rate-limit/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-rate-limit/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-rate-limit/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-rate-limit/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user