Files
skills/docs/foundation/flow/rules-hono/src/middleware/middleware.md
2026-05-29 08:20:56 +01:00

7.2 KiB

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<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

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

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.