first commit

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

View File

@@ -0,0 +1,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.