Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

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

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.