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,168 @@
# Hono middleware — `@suaveplan/rules-hono/middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Six middleware factories that wire `@suaveplan/rules` into a Hono app: `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateLimitRule`, `combineRules`. Each factory accepts a Zod-shaped `Schemalike` (any object exposing `safeParse`), an optional caller-supplied `RuleEngine` (falling back to the lazy `getDefaultEngine`), and per-factory callbacks that override the default deny / error responses.
Failure modes return structured JSON envelopes (`403` for rule denial, `400` for input validation, `429` for rate-limit, `500` for internal errors) so a Hono app can render them uniformly without bespoke error handlers per route. OTel histograms and counters fire per middleware with an `outcome` label (`pass` / `fail` / `error` / `short-circuit` / `skipped`).
## Features
- `ruleMiddleware<TData>(rule, options?)` — evaluate a single `Rule`, store the result in `c.var.ruleResult`, 403 on miss.
- `validationMiddleware(options)` — schema-validate body / query / params / headers; 400 on miss; `abortEarly` truncates at first failing source.
- `accessControl<TData>(options)` — `Rule` or `Ruleset` evaluation with optional per-key cache via `AccessControlCache`.
- `responseValidation(options)` — validate the downstream response body; replaces non-conforming responses with 500.
- `rateLimitRule<TData>(options)` — 429 on miss with optional `Retry-After` header.
- `combineRules(...middlewares)` — chain middlewares with first-response short-circuit.
- `MemoryAccessControlCache` — drop-in `AccessControlCache` impl with TTL.
- `getDefaultEngine()` (re-exported from `../engine`) — lazy per-process singleton.
## Usage: Basic Example
```ts
import { Hono } from "hono";
import { condition } from "@suaveplan/rules";
import { ruleMiddleware } from "@suaveplan/rules-hono/middleware";
const app = new Hono();
app.use("*", ruleMiddleware(condition("age", "greaterThanOrEqual", 18)));
app.get("/", (c) => c.json({ ok: true }));
```
## Usage: Advanced Example
```ts
import { Hono } from "hono";
import z from "zod";
import { condition, RuleEngine } from "@suaveplan/rules";
import {
accessControl,
combineRules,
MemoryAccessControlCache,
rateLimitRule,
responseValidation,
ruleMiddleware,
validationMiddleware,
} from "@suaveplan/rules-hono/middleware";
const engine = new RuleEngine({ maxDepth: 16 });
const cache = new MemoryAccessControlCache();
const app = new Hono();
app.use(
"/admin/*",
combineRules(
validationMiddleware({
headers: z.object({ "x-tenant": z.string().min(1) }),
}),
accessControl({
rules: condition("role", "equals", "admin"),
engine,
extractContext: (c) => ({
data: {
role: c.req.header("x-role") ?? "guest",
},
}),
cacheKey: (c) => c.req.header("x-tenant") ?? "anon",
cache,
cacheTtlMs: 30_000,
}),
rateLimitRule({
rules: condition("requests", "lessThan", 100),
extractContext: () => ({ data: { requests: 1 } }),
retryAfterSeconds: 60,
}),
),
);
app.post(
"/users",
validationMiddleware({
body: z.object({ email: z.string().email() }),
abortEarly: true,
}),
responseValidation({
schema: z.object({ id: z.string() }),
}),
async (c) => c.json({ id: "u1" }),
);
```
## API Reference
### `ruleMiddleware<TData>(rule, options?)`
| Option | Type | Default |
|---|---|---|
| `engine` | `RuleEngine` | `getDefaultEngine()` |
| `extractData(c)` | sync/async | reads JSON body if `content-type: application/json` |
| `onError(c, err)` | sync/async | `c.json({ error }, 500)` |
| `onDenied(c, result)` | sync/async | `c.json({ passed: false, errors }, 403)` |
Stores the `EvaluationResult` on `c.var.ruleResult`.
### `validationMiddleware(options)`
| Option | Type | Notes |
|---|---|---|
| `body` / `query` / `params` / `headers` | `Schemalike` | any object with `safeParse`. |
| `abortEarly` | `boolean` | when `true`, stop at first failing source. |
| `onError(c, errors)` | sync/async | default `c.json({ valid: false, errors }, 400)`. |
`Schemalike` rejection issues are coerced into `HonoValidationError { source, field, message, code? }`.
### `accessControl<TData>(options)`
| Option | Type | Notes |
|---|---|---|
| `rules` | `Rule \| Ruleset` | discriminated via the presence of `id` + `name`. |
| `engine` | `RuleEngine` | default `getDefaultEngine()`. |
| `extractContext(c)` | sync/async | default reads JSON body. |
| `cacheKey(c)` | `() => string \| undefined` | required when `cache` is set. |
| `cache` | `AccessControlCache` | optional. |
| `cacheTtlMs` | `number` | default `5000`. |
| `onDenied(c, result)` | sync/async | default `c.json({ passed: false, errors }, 403)`. |
Stores the result on `c.var.accessResult`. Throws `RulesError(RULES.PLUGIN_FAILED)` at construction when `cache` is set without `cacheKey`.
### `responseValidation(options)`
| Option | Type | Notes |
|---|---|---|
| `schema` | `Schemalike` | validates the JSON response body. |
| `onError(c, errors)` | sync/async | default `c.json({ valid: false, errors }, 500)`. |
Non-JSON responses pass through unchanged (`outcome: "skipped"`).
### `rateLimitRule<TData>(options)`
| Option | Type | Notes |
|---|---|---|
| `rules` | `Rule \| Ruleset` | |
| `extractContext(c)` | sync/async | default reads JSON body. |
| `engine` | `RuleEngine` | default `getDefaultEngine()`. |
| `retryAfterSeconds` | `number` | sets the `Retry-After` header on 429. |
| `onLimited(c, result)` | sync/async | default `c.json({ limited: true, errors }, 429, headers)`. |
### `combineRules(...middlewares)`
Chains middlewares sequentially; the first child whose `next()` is never called terminates the chain and its response is propagated.
### `class MemoryAccessControlCache`
- `get(key) -> EvaluationResult | undefined` — drops expired entries lazily.
- `set(key, value, ttlMs) -> void`.
- `size() -> number`.
- `clear() -> void`.
## Implementation Notes
- The `Schemalike` interface is intentionally narrow (`safeParse` returning a `{ success: true, data }` or `{ success: false, error: { issues: [...] } }` discriminated union). Zod, Valibot, and `@suaveplan/validation` all satisfy this shape without adapter code.
- `responseValidation` reads `c.res.clone()` so the downstream consumer can still read the original response body. The clone path is paid only when the downstream `content-type` is JSON — non-JSON responses skip the validation entirely.
- `combineRules` tracks `advanced` per child via the supplied `next()` argument; if `next` was never called, the child wrote a response and the chain terminates.
- `MemoryAccessControlCache` does not run a background eviction loop — it drops entries lazily on read. Long-lived processes that worry about unbounded growth should periodically call `cache.clear()` or supply their own `AccessControlCache` implementation backed by Redis / LRU.
- Every middleware records an OTel histogram + counter even on error paths so dashboards can distinguish `pass` / `fail` / `error` / `short-circuit` outcomes without parsing logs.