# Hono middleware — `@suaveplan/rules-hono/middleware` _(no submodules)_ ## 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(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(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(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(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(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(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.