first commit
This commit is contained in:
41
docs/foundation/flow/rules-hono/README.md
Normal file
41
docs/foundation/flow/rules-hono/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @suaveplan/rules-hono - Hono middleware for @suaveplan/rules — ruleMiddleware, validationMiddleware, accessControl, responseValidation, rateLimitRule, combineRules; lazy default engine, Zod-validated options, OTel surface
|
||||
|
||||
## Summary
|
||||
|
||||
Hono middleware wrapping [`@suaveplan/rules`](../rules/README.md). Six middleware factories — `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateLimitRule`, `combineRules` — plus a lazy default `RuleEngine` and a `MemoryAccessControlCache` reference implementation. Every factory accepts an optional caller-supplied `RuleEngine` for tenant isolation, returns structured JSON envelopes on every failure mode, and emits an OTel histogram / counter pair labelled by outcome.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [engine](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [default](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [default](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [default](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [default](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [default](./src/engine/default.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [middleware](./src/middleware/middleware.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/rules-hono hono
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```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 }));
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`middleware`](src/middleware/middleware.md) — the six factory functions + `MemoryAccessControlCache`
|
||||
- [`engine`](src/engine/default.md) — lazy `getDefaultEngine()` (+ test-only `resetDefaultEngine` via `./testing`)
|
||||
|
||||
## See also
|
||||
|
||||
- [`@suaveplan/rules`](../rules/README.md) — the underlying engine, builder, operators, validators, serializer, optimizer.
|
||||
|
||||
60
docs/foundation/flow/rules-hono/src/engine/default.md
Normal file
60
docs/foundation/flow/rules-hono/src/engine/default.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Default engine — `@suaveplan/rules-hono/engine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`getDefaultEngine()` exposes a lazy, per-process `RuleEngine` instance for callers who do not want to construct or thread an engine through their middleware stack. It is constructed exactly once on first call and cached for the lifetime of the process; every subsequent call returns the identical reference. Tests break the cache via `resetDefaultEngine()` exposed under the `./testing` subpath.
|
||||
|
||||
This pattern replaces the upstream module-level singleton `defaultEngine = new RuleEngine()` — eager module-scope instantiation is a hidden global per AGENTS.md §6a and creates a side-effect on import that prevents tree-shaking even when no caller uses the default. Lazy construction defers the cost until a real call site fires.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy singleton, identity-stable across calls.
|
||||
- Cache reset hook surfaced only via the `./testing` subpath — production import paths cannot drop the cache.
|
||||
- Zero arguments — the default engine is constructed with the `RuleEngine` defaults; consumers that want a configured engine should instantiate `new RuleEngine(config)` themselves and pass it explicitly to the relevant middleware factory.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDefaultEngine } from "@suaveplan/rules-hono/engine";
|
||||
|
||||
const engine = getDefaultEngine();
|
||||
// engine is the process singleton — every other caller of getDefaultEngine sees the same instance
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "@suaveplan/testing/runner";
|
||||
import { getDefaultEngine } from "@suaveplan/rules-hono/engine";
|
||||
import { resetDefaultEngine } from "@suaveplan/rules-hono/testing";
|
||||
|
||||
describe("my middleware", () => {
|
||||
it("uses a fresh engine per test", () => {
|
||||
resetDefaultEngine();
|
||||
const a = getDefaultEngine();
|
||||
// ... test against a ...
|
||||
resetDefaultEngine();
|
||||
const b = getDefaultEngine();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDefaultEngine(): RuleEngine`
|
||||
|
||||
Returns the cached default engine; constructs it on first call.
|
||||
|
||||
### `resetDefaultEngine(): void` (`./testing` subpath only)
|
||||
|
||||
Drops the cache. Strictly test-only — production code that needs a fresh engine should call `new RuleEngine()` directly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache lives in module scope, so identity is per-process (matching every other ES-module-loaded singleton in Suaveplan).
|
||||
- The exported reset symbol is named `__resetDefaultEngineForTesting` internally and re-exported from the `./testing` subpath as `resetDefaultEngine`. The double-underscore prefix is a soft convention to discourage accidental production imports; the hard guarantee is the subpath split.
|
||||
- If consumers want telemetry-instrumented or plugin-configured engines, they construct their own and pass it via the `engine` option on every middleware factory. The default engine is for the "I just want this to work" path.
|
||||
168
docs/foundation/flow/rules-hono/src/middleware/middleware.md
Normal file
168
docs/foundation/flow/rules-hono/src/middleware/middleware.md
Normal 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.
|
||||
Reference in New Issue
Block a user