Initial Commit
This commit is contained in:
41
packages/foundation/flow/rules-hono/README.md
Normal file
41
packages/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.
|
||||
|
||||
68
packages/foundation/flow/rules-hono/package.json
Normal file
68
packages/foundation/flow/rules-hono/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@suaveplan/rules-hono",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Hono middleware for @suaveplan/rules — ruleMiddleware, validationMiddleware, accessControl, responseValidation, rateLimitRule, combineRules; lazy default engine, Zod-validated options, OTel surface",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./middleware": {
|
||||
"import": { "types": "./dist/middleware/index.d.ts", "default": "./dist/middleware/index.js" }
|
||||
},
|
||||
"./engine": {
|
||||
"import": { "types": "./dist/engine/index.d.ts", "default": "./dist/engine/index.js" }
|
||||
},
|
||||
"./testing": {
|
||||
"import": { "types": "./dist/testing/index.d.ts", "default": "./dist/testing/index.js" }
|
||||
},
|
||||
"./types": {
|
||||
"import": { "types": "./dist/types/index.d.ts", "default": "./dist/types/index.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/rules": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"hono": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
60
packages/foundation/flow/rules-hono/src/engine/default.md
Normal file
60
packages/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.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { __resetDefaultEngineForTesting, getDefaultEngine } from "./default.js";
|
||||
|
||||
describe("getDefaultEngine", () => {
|
||||
it("returns the same instance on subsequent calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
__resetDefaultEngineForTesting();
|
||||
const a = getDefaultEngine();
|
||||
const b = getDefaultEngine();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetDefaultEngine forces reconstruction", { timeout: 5000 }, () => {
|
||||
__resetDefaultEngineForTesting();
|
||||
const a = getDefaultEngine();
|
||||
__resetDefaultEngineForTesting();
|
||||
const b = getDefaultEngine();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
23
packages/foundation/flow/rules-hono/src/engine/default.ts
Normal file
23
packages/foundation/flow/rules-hono/src/engine/default.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RuleEngine } from "@suaveplan/rules";
|
||||
|
||||
let cached: RuleEngine | null = null;
|
||||
|
||||
/**
|
||||
* Lazy, per-process default `RuleEngine`. Constructed on first call;
|
||||
* subsequent calls return the identical reference. Tests use
|
||||
* `resetDefaultEngine` from the `./testing` subpath to break the cache
|
||||
* between cases.
|
||||
*/
|
||||
export function getDefaultEngine(): RuleEngine {
|
||||
if (cached !== null) return cached;
|
||||
cached = new RuleEngine();
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hook to drop the cached default engine. Surfaced via the
|
||||
* `./testing` subpath only; production code must not import this.
|
||||
*/
|
||||
export function __resetDefaultEngineForTesting(): void {
|
||||
cached = null;
|
||||
}
|
||||
1
packages/foundation/flow/rules-hono/src/engine/index.ts
Normal file
1
packages/foundation/flow/rules-hono/src/engine/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getDefaultEngine } from "./default.js";
|
||||
22
packages/foundation/flow/rules-hono/src/index.ts
Normal file
22
packages/foundation/flow/rules-hono/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { getDefaultEngine } from "./engine/index.js";
|
||||
export {
|
||||
accessControl,
|
||||
combineRules,
|
||||
MemoryAccessControlCache,
|
||||
rateLimitRule,
|
||||
responseValidation,
|
||||
ruleMiddleware,
|
||||
validationMiddleware,
|
||||
} from "./middleware/index.js";
|
||||
export type {
|
||||
AccessControlCache,
|
||||
AccessControlOptions,
|
||||
HonoValidationError,
|
||||
RateLimitRuleOptions,
|
||||
ResponseValidationOptions,
|
||||
RuleMiddlewareOptions,
|
||||
RulesHonoMiddleware,
|
||||
RulesHonoNext,
|
||||
Schemalike,
|
||||
ValidationMiddlewareOptions,
|
||||
} from "./types/index.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
accessControl,
|
||||
combineRules,
|
||||
MemoryAccessControlCache,
|
||||
rateLimitRule,
|
||||
responseValidation,
|
||||
ruleMiddleware,
|
||||
validationMiddleware,
|
||||
} from "./middleware.js";
|
||||
168
packages/foundation/flow/rules-hono/src/middleware/middleware.md
Normal file
168
packages/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.
|
||||
@@ -0,0 +1,558 @@
|
||||
import { condition, or, RuleEngine } from "@suaveplan/rules";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { resetDefaultEngine } from "../testing/index.js";
|
||||
import type { HonoValidationError, Schemalike } from "../types/index.js";
|
||||
import {
|
||||
accessControl,
|
||||
combineRules,
|
||||
MemoryAccessControlCache,
|
||||
rateLimitRule,
|
||||
responseValidation,
|
||||
ruleMiddleware,
|
||||
validationMiddleware,
|
||||
} from "./middleware.js";
|
||||
|
||||
function schema(
|
||||
predicate: (
|
||||
input: unknown
|
||||
) => true | { issues: { path: string[]; message: string }[] }
|
||||
): Schemalike {
|
||||
return {
|
||||
safeParse(input) {
|
||||
const out = predicate(input);
|
||||
if (out === true) return { success: true, data: input };
|
||||
return { success: false, error: { issues: out.issues } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ruleMiddleware", () => {
|
||||
it("passes through on rule pass", { timeout: 5000 }, async () => {
|
||||
resetDefaultEngine();
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18))
|
||||
);
|
||||
app.post("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ age: 21 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("403 short-circuits on rule fail", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18))
|
||||
);
|
||||
app.post("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ age: 12 }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("uses extractData when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18), {
|
||||
extractData: () => ({ age: 30 }),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("invokes onDenied when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18), {
|
||||
extractData: () => ({ age: 1 }),
|
||||
onDenied: (c) => c.json({ custom: true }, 418),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(418);
|
||||
});
|
||||
|
||||
it("invokes onError when extractData throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18), {
|
||||
extractData: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
onError: (c) => c.json({ err: true }, 500),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("default 500 path when no onError", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18), {
|
||||
extractData: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("uses caller-supplied engine", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
const engine = new RuleEngine();
|
||||
app.use(
|
||||
"*",
|
||||
ruleMiddleware(condition("age", "greaterThanOrEqual", 18), {
|
||||
engine,
|
||||
extractData: () => ({ age: 30 }),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validationMiddleware", () => {
|
||||
const bodySchema = schema((input) => {
|
||||
if (
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
(input as { ok?: boolean }).ok === true
|
||||
)
|
||||
return true;
|
||||
return { issues: [{ path: ["ok"], message: "must be true" }] };
|
||||
});
|
||||
|
||||
it("passes through when body matches schema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", validationMiddleware({ body: bodySchema }));
|
||||
app.post("/", (c) => c.json({ done: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("400 short-circuits on body schema rejection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", validationMiddleware({ body: bodySchema }));
|
||||
app.post("/", (c) => c.json({ done: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: false }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("abortEarly stops at first failing source", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
const querySchema = schema(() => ({
|
||||
issues: [{ path: ["a"], message: "fail-a" }],
|
||||
}));
|
||||
const headerSchema = schema(() => ({
|
||||
issues: [{ path: ["b"], message: "fail-b" }],
|
||||
}));
|
||||
app.use(
|
||||
"*",
|
||||
validationMiddleware({
|
||||
query: querySchema,
|
||||
headers: headerSchema,
|
||||
abortEarly: true,
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ done: true }));
|
||||
const res = await app.request("/?a=1");
|
||||
const json = (await res.json()) as { errors: HonoValidationError[] };
|
||||
expect(json.errors.every((e) => e.source === "query")).toBe(true);
|
||||
});
|
||||
|
||||
it("invokes onError when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
validationMiddleware({
|
||||
body: schema(() => ({
|
||||
issues: [{ path: ["x"], message: "fail" }],
|
||||
})),
|
||||
onError: (c, errors) => c.json({ custom: errors }, 422),
|
||||
})
|
||||
);
|
||||
app.post("/", (c) => c.json({ done: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects non-schema options", { timeout: 5000 }, () => {
|
||||
expect(() => validationMiddleware({ body: {} as Schemalike })).toThrow(
|
||||
/RULES\.PLUGIN_FAILED/
|
||||
);
|
||||
});
|
||||
|
||||
it("validates query / params / headers sources", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
validationMiddleware({
|
||||
query: schema(() => true),
|
||||
params: schema(() => true),
|
||||
headers: schema(() => true),
|
||||
})
|
||||
);
|
||||
app.get("/x/:id", (c) => c.json({ done: true }));
|
||||
const res = await app.request("/x/42");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("invalid JSON body treated as empty object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", validationMiddleware({ body: schema(() => true) }));
|
||||
app.post("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{not-json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessControl", () => {
|
||||
it("403 on rule denial", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
accessControl({ rules: condition("role", "equals", "admin") })
|
||||
);
|
||||
app.post("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ role: "guest" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("ruleset OR allows when any rule passes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
accessControl({
|
||||
rules: {
|
||||
id: "rs",
|
||||
name: "rs",
|
||||
operator: "or",
|
||||
rules: [
|
||||
condition("role", "equals", "admin"),
|
||||
condition("role", "equals", "owner"),
|
||||
],
|
||||
},
|
||||
extractContext: () => ({ data: { role: "owner" } }),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("cache short-circuits the engine", { timeout: 5000 }, async () => {
|
||||
const cache = new MemoryAccessControlCache();
|
||||
const app = new Hono();
|
||||
let engineCalls = 0;
|
||||
const engine = new RuleEngine();
|
||||
const origEvaluate = engine.evaluate.bind(engine);
|
||||
engine.evaluate = (async (
|
||||
...args: Parameters<RuleEngine["evaluate"]>
|
||||
) => {
|
||||
engineCalls += 1;
|
||||
return origEvaluate(...args);
|
||||
}) as RuleEngine["evaluate"];
|
||||
app.use(
|
||||
"*",
|
||||
accessControl({
|
||||
rules: condition("role", "equals", "admin"),
|
||||
engine,
|
||||
extractContext: () => ({ data: { role: "admin" } }),
|
||||
cacheKey: () => "static",
|
||||
cache,
|
||||
cacheTtlMs: 1000,
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
await app.request("/");
|
||||
await app.request("/");
|
||||
expect(engineCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("invokes onDenied when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
accessControl({
|
||||
rules: condition("role", "equals", "admin"),
|
||||
extractContext: () => ({ data: { role: "guest" } }),
|
||||
onDenied: (c) => c.json({ denied: true }, 401),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects cache without cacheKey", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
accessControl({
|
||||
rules: condition("a", "equals", 1),
|
||||
cache: new MemoryAccessControlCache(),
|
||||
})
|
||||
).toThrow(/RULES\.PLUGIN_FAILED/);
|
||||
});
|
||||
|
||||
it("error path returns 500", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
accessControl({
|
||||
rules: condition("a", "equals", 1),
|
||||
extractContext: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("responseValidation", () => {
|
||||
it("passes through valid response", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", responseValidation({ schema: schema(() => true) }));
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("replaces invalid response with 500 envelope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
responseValidation({
|
||||
schema: schema(() => ({
|
||||
issues: [{ path: ["x"], message: "fail" }],
|
||||
})),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("skips non-JSON responses", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
responseValidation({ schema: schema(() => ({ issues: [] })) })
|
||||
);
|
||||
app.get("/", (c) => c.text("hi"));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hi");
|
||||
});
|
||||
|
||||
it("invokes onError when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
responseValidation({
|
||||
schema: schema(() => ({
|
||||
issues: [{ path: ["x"], message: "fail" }],
|
||||
})),
|
||||
onError: (c) => c.json({ custom: true }, 422),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitRule", () => {
|
||||
it("429 with Retry-After when rule fails", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
rateLimitRule({
|
||||
rules: condition("requests", "lessThan", 10),
|
||||
extractContext: () => ({ data: { requests: 100 } }),
|
||||
retryAfterSeconds: 60,
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("retry-after")).toBe("60");
|
||||
});
|
||||
|
||||
it("passes through when allowed", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
rateLimitRule({
|
||||
rules: condition("requests", "lessThan", 10),
|
||||
extractContext: () => ({ data: { requests: 1 } }),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("ruleset path", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
rateLimitRule({
|
||||
rules: {
|
||||
id: "rs",
|
||||
name: "rs",
|
||||
operator: "or",
|
||||
rules: [condition("requests", "lessThan", 10)],
|
||||
},
|
||||
extractContext: () => ({ data: { requests: 1 } }),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("invokes onLimited when supplied", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
rateLimitRule({
|
||||
rules: condition("requests", "lessThan", 10),
|
||||
extractContext: () => ({ data: { requests: 100 } }),
|
||||
onLimited: (c) => c.json({ custom: true }, 429),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(429);
|
||||
});
|
||||
|
||||
it("500 on error", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
rateLimitRule({
|
||||
rules: condition("a", "equals", 1),
|
||||
extractContext: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
expect((await app.request("/")).status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineRules", () => {
|
||||
it("first early response short-circuits", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
combineRules(
|
||||
ruleMiddleware(condition("a", "equals", 1), {
|
||||
extractData: () => ({ a: 9 }),
|
||||
}),
|
||||
ruleMiddleware(condition("b", "equals", 2), {
|
||||
extractData: () => ({ b: 2 }),
|
||||
})
|
||||
)
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("all-pass chains through", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
combineRules(
|
||||
ruleMiddleware(
|
||||
or(
|
||||
condition("a", "equals", 1),
|
||||
condition("a", "equals", 9)
|
||||
),
|
||||
{
|
||||
extractData: () => ({ a: 1 }),
|
||||
}
|
||||
),
|
||||
ruleMiddleware(condition("b", "equals", 2), {
|
||||
extractData: () => ({ b: 2 }),
|
||||
})
|
||||
)
|
||||
);
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryAccessControlCache", () => {
|
||||
it("stores and expires entries", { timeout: 5000 }, async () => {
|
||||
const cache = new MemoryAccessControlCache();
|
||||
cache.set("k", { passed: true, errors: [] }, 10);
|
||||
expect(cache.get("k")?.passed).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(cache.get("k")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("size + clear", { timeout: 5000 }, () => {
|
||||
const cache = new MemoryAccessControlCache();
|
||||
cache.set("a", { passed: true, errors: [] }, 1000);
|
||||
cache.set("b", { passed: true, errors: [] }, 1000);
|
||||
expect(cache.size()).toBe(2);
|
||||
cache.clear();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
552
packages/foundation/flow/rules-hono/src/middleware/middleware.ts
Normal file
552
packages/foundation/flow/rules-hono/src/middleware/middleware.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
type EvaluationResult,
|
||||
RulesError,
|
||||
context as ruleContext,
|
||||
} from "@suaveplan/rules";
|
||||
import type { Context as HonoContext, MiddlewareHandler, Next } from "hono";
|
||||
import { getDefaultEngine } from "../engine/default.js";
|
||||
import type {
|
||||
AccessControlCache,
|
||||
AccessControlOptions,
|
||||
HonoValidationError,
|
||||
RateLimitRuleOptions,
|
||||
ResponseValidationOptions,
|
||||
RuleMiddlewareOptions,
|
||||
Schemalike,
|
||||
ValidationMiddlewareOptions,
|
||||
} from "../types/index.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/rules-hono");
|
||||
const ruleDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.rule.duration_ms"
|
||||
);
|
||||
const ruleTotal = meter.createCounter("rules_hono.middleware.rule_total");
|
||||
const validationDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.validation.duration_ms"
|
||||
);
|
||||
const validationTotal = meter.createCounter(
|
||||
"rules_hono.middleware.validation_total"
|
||||
);
|
||||
const accessDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.access_control.duration_ms"
|
||||
);
|
||||
const accessTotal = meter.createCounter(
|
||||
"rules_hono.middleware.access_control_total"
|
||||
);
|
||||
const responseDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.response_validation.duration_ms"
|
||||
);
|
||||
const responseTotal = meter.createCounter(
|
||||
"rules_hono.middleware.response_validation_total"
|
||||
);
|
||||
const rateLimitDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.rate_limit_rule.duration_ms"
|
||||
);
|
||||
const rateLimitTotal = meter.createCounter(
|
||||
"rules_hono.middleware.rate_limit_rule_total"
|
||||
);
|
||||
const combineDuration = meter.createHistogram(
|
||||
"rules_hono.middleware.combine.duration_ms"
|
||||
);
|
||||
const combineTotal = meter.createCounter("rules_hono.middleware.combine_total");
|
||||
|
||||
function record(
|
||||
hist: typeof ruleDuration,
|
||||
counter: typeof ruleTotal,
|
||||
durationMs: number,
|
||||
outcome: string
|
||||
): void {
|
||||
hist.record(durationMs, { outcome });
|
||||
counter.add(1, { outcome });
|
||||
}
|
||||
|
||||
async function extractDataDefault<TData extends Record<string, unknown>>(
|
||||
c: HonoContext
|
||||
): Promise<TData> {
|
||||
try {
|
||||
if (
|
||||
c.req.header("content-type")?.includes("application/json") === true
|
||||
) {
|
||||
return ((await c.req.json()) ?? {}) as TData;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return {} as TData;
|
||||
}
|
||||
|
||||
function ensureSchemalike(
|
||||
name: string,
|
||||
schema: unknown
|
||||
): asserts schema is Schemalike {
|
||||
if (
|
||||
typeof schema !== "object" ||
|
||||
schema === null ||
|
||||
typeof (schema as { safeParse?: unknown }).safeParse !== "function"
|
||||
) {
|
||||
throw new RulesError(
|
||||
"RULES.PLUGIN_FAILED",
|
||||
`${name} option must be a schema with safeParse`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ruleMiddleware` evaluates a single rule per request, stores the result
|
||||
* in `c.var.ruleResult`, and short-circuits with 403 (or invokes
|
||||
* `options.onDenied`) when the rule fails.
|
||||
*/
|
||||
export function ruleMiddleware<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(
|
||||
rule: import("@suaveplan/rules").Rule,
|
||||
options: RuleMiddlewareOptions<TData> = {}
|
||||
): MiddlewareHandler {
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const engine = options.engine ?? getDefaultEngine();
|
||||
const data =
|
||||
options.extractData !== undefined
|
||||
? await options.extractData(c)
|
||||
: await extractDataDefault<TData>(c);
|
||||
const result = await engine.evaluate(
|
||||
rule,
|
||||
ruleContext<TData>(data)
|
||||
);
|
||||
c.set("ruleResult" as never, result as never);
|
||||
record(
|
||||
ruleDuration,
|
||||
ruleTotal,
|
||||
performance.now() - start,
|
||||
result.passed ? "pass" : "fail"
|
||||
);
|
||||
if (!result.passed) {
|
||||
if (options.onDenied !== undefined)
|
||||
return options.onDenied(c, result);
|
||||
return c.json({ passed: false, errors: result.errors }, 403);
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
} catch (err) {
|
||||
record(ruleDuration, ruleTotal, performance.now() - start, "error");
|
||||
if (options.onError !== undefined) return options.onError(c, err);
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseSource(
|
||||
source: HonoValidationError["source"],
|
||||
schema: Schemalike,
|
||||
value: unknown
|
||||
): readonly HonoValidationError[] {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) return [];
|
||||
return result.error.issues.map((issue) => {
|
||||
const i = issue as {
|
||||
path?: readonly (string | number)[];
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
return {
|
||||
source,
|
||||
field: (i.path ?? []).join("."),
|
||||
message: i.message ?? "validation failed",
|
||||
...(i.code !== undefined ? { code: i.code } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `validationMiddleware` validates request body / query / params / headers
|
||||
* against caller-supplied schemas. Errors collect per-source unless
|
||||
* `abortEarly` truncates after the first failing source.
|
||||
*/
|
||||
export function validationMiddleware(
|
||||
options: ValidationMiddlewareOptions
|
||||
): MiddlewareHandler {
|
||||
if (options.body !== undefined) ensureSchemalike("body", options.body);
|
||||
if (options.query !== undefined) ensureSchemalike("query", options.query);
|
||||
if (options.params !== undefined)
|
||||
ensureSchemalike("params", options.params);
|
||||
if (options.headers !== undefined)
|
||||
ensureSchemalike("headers", options.headers);
|
||||
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
const errors: HonoValidationError[] = [];
|
||||
try {
|
||||
if (options.body !== undefined) {
|
||||
const body = await safeJson(c);
|
||||
const parsed = parseSource("body", options.body, body);
|
||||
errors.push(...parsed);
|
||||
if (parsed.length > 0 && options.abortEarly === true) {
|
||||
return finish(c, options, errors, start);
|
||||
}
|
||||
}
|
||||
if (options.query !== undefined) {
|
||||
const query = c.req.query();
|
||||
const parsed = parseSource("query", options.query, query);
|
||||
errors.push(...parsed);
|
||||
if (parsed.length > 0 && options.abortEarly === true) {
|
||||
return finish(c, options, errors, start);
|
||||
}
|
||||
}
|
||||
if (options.params !== undefined) {
|
||||
const params = c.req.param();
|
||||
const parsed = parseSource("params", options.params, params);
|
||||
errors.push(...parsed);
|
||||
if (parsed.length > 0 && options.abortEarly === true) {
|
||||
return finish(c, options, errors, start);
|
||||
}
|
||||
}
|
||||
if (options.headers !== undefined) {
|
||||
const headers: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const parsed = parseSource("headers", options.headers, headers);
|
||||
errors.push(...parsed);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return finish(c, options, errors, start);
|
||||
}
|
||||
record(
|
||||
validationDuration,
|
||||
validationTotal,
|
||||
performance.now() - start,
|
||||
"pass"
|
||||
);
|
||||
await next();
|
||||
return;
|
||||
} catch (err) {
|
||||
record(
|
||||
validationDuration,
|
||||
validationTotal,
|
||||
performance.now() - start,
|
||||
"error"
|
||||
);
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function finish(
|
||||
c: HonoContext,
|
||||
options: ValidationMiddlewareOptions,
|
||||
errors: readonly HonoValidationError[],
|
||||
start: number
|
||||
): Response | Promise<Response> {
|
||||
record(
|
||||
validationDuration,
|
||||
validationTotal,
|
||||
performance.now() - start,
|
||||
"fail"
|
||||
);
|
||||
if (options.onError !== undefined) return options.onError(c, errors);
|
||||
return c.json({ valid: false, errors }, 400);
|
||||
}
|
||||
|
||||
async function safeJson(c: HonoContext): Promise<unknown> {
|
||||
try {
|
||||
return await c.req.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `accessControl` evaluates a `Rule` or `Ruleset` against a derived
|
||||
* `RuleContext`, with optional caching keyed by `options.cacheKey`.
|
||||
*/
|
||||
export function accessControl<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(options: AccessControlOptions<TData>): MiddlewareHandler {
|
||||
if (options.cache !== undefined && options.cacheKey === undefined) {
|
||||
throw new RulesError(
|
||||
"RULES.PLUGIN_FAILED",
|
||||
"accessControl: cache requires cacheKey"
|
||||
);
|
||||
}
|
||||
const ttl = options.cacheTtlMs ?? 5_000;
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const engine = options.engine ?? getDefaultEngine();
|
||||
const cacheKey = options.cacheKey?.(c);
|
||||
let result: EvaluationResult | undefined;
|
||||
if (options.cache !== undefined && cacheKey !== undefined) {
|
||||
const cached = options.cache.get(cacheKey);
|
||||
if (cached !== undefined) result = cached;
|
||||
}
|
||||
if (result === undefined) {
|
||||
const ctx =
|
||||
options.extractContext !== undefined
|
||||
? await options.extractContext(c)
|
||||
: ruleContext<TData>(
|
||||
(await extractDataDefault<TData>(c)) as TData
|
||||
);
|
||||
result =
|
||||
"id" in options.rules && "name" in options.rules
|
||||
? await engine.evaluateRuleset(
|
||||
options.rules as never,
|
||||
ctx
|
||||
)
|
||||
: await engine.evaluate(options.rules as never, ctx);
|
||||
if (options.cache !== undefined && cacheKey !== undefined) {
|
||||
options.cache.set(cacheKey, result, ttl);
|
||||
}
|
||||
}
|
||||
c.set("accessResult" as never, result as never);
|
||||
record(
|
||||
accessDuration,
|
||||
accessTotal,
|
||||
performance.now() - start,
|
||||
result.passed ? "pass" : "fail"
|
||||
);
|
||||
if (!result.passed) {
|
||||
if (options.onDenied !== undefined)
|
||||
return options.onDenied(c, result);
|
||||
return c.json({ passed: false, errors: result.errors }, 403);
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
} catch (err) {
|
||||
record(
|
||||
accessDuration,
|
||||
accessTotal,
|
||||
performance.now() - start,
|
||||
"error"
|
||||
);
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `responseValidation` validates the downstream response body against a
|
||||
* schema after the handler runs. On failure, replaces the response with a
|
||||
* 500 + structured error envelope (or invokes `options.onError`).
|
||||
*/
|
||||
export function responseValidation(
|
||||
options: ResponseValidationOptions
|
||||
): MiddlewareHandler {
|
||||
ensureSchemalike("schema", options.schema);
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
await next();
|
||||
const response = c.res;
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
record(
|
||||
responseDuration,
|
||||
responseTotal,
|
||||
performance.now() - start,
|
||||
"skipped"
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
const body =
|
||||
text === "" ? undefined : (JSON.parse(text) as unknown);
|
||||
const parsed = options.schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((issue) => {
|
||||
const i = issue as {
|
||||
path?: readonly (string | number)[];
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
return {
|
||||
source: "body" as const,
|
||||
field: (i.path ?? []).join("."),
|
||||
message: i.message ?? "response validation failed",
|
||||
...(i.code !== undefined ? { code: i.code } : {}),
|
||||
};
|
||||
});
|
||||
record(
|
||||
responseDuration,
|
||||
responseTotal,
|
||||
performance.now() - start,
|
||||
"fail"
|
||||
);
|
||||
if (options.onError !== undefined) {
|
||||
c.res = await options.onError(c, errors);
|
||||
return;
|
||||
}
|
||||
c.res = c.json({ valid: false, errors }, 500);
|
||||
return;
|
||||
}
|
||||
record(
|
||||
responseDuration,
|
||||
responseTotal,
|
||||
performance.now() - start,
|
||||
"pass"
|
||||
);
|
||||
} catch (err) {
|
||||
record(
|
||||
responseDuration,
|
||||
responseTotal,
|
||||
performance.now() - start,
|
||||
"error"
|
||||
);
|
||||
c.res = c.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
500
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `rateLimitRule` evaluates a rate-limit rule and returns 429 (with optional
|
||||
* Retry-After) when the rule fails.
|
||||
*/
|
||||
export function rateLimitRule<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(options: RateLimitRuleOptions<TData>): MiddlewareHandler {
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const engine = options.engine ?? getDefaultEngine();
|
||||
const ctx =
|
||||
options.extractContext !== undefined
|
||||
? await options.extractContext(c)
|
||||
: ruleContext<TData>(
|
||||
(await extractDataDefault<TData>(c)) as TData
|
||||
);
|
||||
const result =
|
||||
"id" in options.rules && "name" in options.rules
|
||||
? await engine.evaluateRuleset(options.rules as never, ctx)
|
||||
: await engine.evaluate(options.rules as never, ctx);
|
||||
record(
|
||||
rateLimitDuration,
|
||||
rateLimitTotal,
|
||||
performance.now() - start,
|
||||
result.passed ? "pass" : "fail"
|
||||
);
|
||||
if (!result.passed) {
|
||||
if (options.onLimited !== undefined)
|
||||
return options.onLimited(c, result);
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.retryAfterSeconds !== undefined) {
|
||||
headers["Retry-After"] = String(options.retryAfterSeconds);
|
||||
}
|
||||
return c.json(
|
||||
{ limited: true, errors: result.errors },
|
||||
429,
|
||||
headers
|
||||
);
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
} catch (err) {
|
||||
record(
|
||||
rateLimitDuration,
|
||||
rateLimitTotal,
|
||||
performance.now() - start,
|
||||
"error"
|
||||
);
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `combineRules` chains multiple middlewares; the first child to write a
|
||||
* response short-circuits the chain.
|
||||
*/
|
||||
export function combineRules(
|
||||
...middlewares: readonly MiddlewareHandler[]
|
||||
): MiddlewareHandler {
|
||||
return async (
|
||||
c: HonoContext,
|
||||
next: Next
|
||||
): Promise<Response | undefined> => {
|
||||
const start = performance.now();
|
||||
let earlyResponse: Response | undefined;
|
||||
for (const middleware of middlewares) {
|
||||
let advanced = false;
|
||||
const result = await middleware(c, async () => {
|
||||
advanced = true;
|
||||
});
|
||||
if (!advanced) {
|
||||
earlyResponse = (result as Response | undefined) ?? c.res;
|
||||
break;
|
||||
}
|
||||
}
|
||||
record(
|
||||
combineDuration,
|
||||
combineTotal,
|
||||
performance.now() - start,
|
||||
earlyResponse === undefined ? "pass" : "short-circuit"
|
||||
);
|
||||
if (earlyResponse !== undefined) return earlyResponse;
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache implementation for `accessControl`. TTL is per-entry;
|
||||
* expired entries are dropped lazily on read.
|
||||
*/
|
||||
export class MemoryAccessControlCache implements AccessControlCache {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
{ readonly value: EvaluationResult; readonly expiresAt: number }
|
||||
>();
|
||||
|
||||
get(key: string): EvaluationResult | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: EvaluationResult, ttlMs: number): void {
|
||||
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
1
packages/foundation/flow/rules-hono/src/testing/index.ts
Normal file
1
packages/foundation/flow/rules-hono/src/testing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { __resetDefaultEngineForTesting as resetDefaultEngine } from "../engine/default.js";
|
||||
12
packages/foundation/flow/rules-hono/src/types/index.ts
Normal file
12
packages/foundation/flow/rules-hono/src/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
AccessControlCache,
|
||||
AccessControlOptions,
|
||||
HonoValidationError,
|
||||
RateLimitRuleOptions,
|
||||
ResponseValidationOptions,
|
||||
RuleMiddlewareOptions,
|
||||
RulesHonoMiddleware,
|
||||
RulesHonoNext,
|
||||
Schemalike,
|
||||
ValidationMiddlewareOptions,
|
||||
} from "./types.js";
|
||||
98
packages/foundation/flow/rules-hono/src/types/types.ts
Normal file
98
packages/foundation/flow/rules-hono/src/types/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
EvaluationResult,
|
||||
Rule,
|
||||
RuleContext,
|
||||
RuleEngine,
|
||||
Ruleset,
|
||||
} from "@suaveplan/rules";
|
||||
import type { Context as HonoContext, MiddlewareHandler, Next } from "hono";
|
||||
|
||||
export interface RuleMiddlewareOptions<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
readonly engine?: RuleEngine;
|
||||
readonly extractData?: (c: HonoContext) => TData | Promise<TData>;
|
||||
readonly onError?: (
|
||||
c: HonoContext,
|
||||
error: unknown
|
||||
) => Response | Promise<Response>;
|
||||
readonly onDenied?: (
|
||||
c: HonoContext,
|
||||
result: EvaluationResult
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface ValidationMiddlewareOptions {
|
||||
readonly body?: Schemalike;
|
||||
readonly query?: Schemalike;
|
||||
readonly params?: Schemalike;
|
||||
readonly headers?: Schemalike;
|
||||
readonly abortEarly?: boolean;
|
||||
readonly onError?: (
|
||||
c: HonoContext,
|
||||
errors: readonly HonoValidationError[]
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface AccessControlOptions<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
readonly rules: Rule | Ruleset;
|
||||
readonly engine?: RuleEngine;
|
||||
readonly extractContext?: (
|
||||
c: HonoContext
|
||||
) => RuleContext<TData> | Promise<RuleContext<TData>>;
|
||||
readonly onDenied?: (
|
||||
c: HonoContext,
|
||||
result: EvaluationResult
|
||||
) => Response | Promise<Response>;
|
||||
readonly cacheKey?: (c: HonoContext) => string | undefined;
|
||||
readonly cache?: AccessControlCache;
|
||||
readonly cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export interface AccessControlCache {
|
||||
get(key: string): EvaluationResult | undefined;
|
||||
set(key: string, value: EvaluationResult, ttlMs: number): void;
|
||||
}
|
||||
|
||||
export interface ResponseValidationOptions {
|
||||
readonly schema: Schemalike;
|
||||
readonly onError?: (
|
||||
c: HonoContext,
|
||||
errors: readonly HonoValidationError[]
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface RateLimitRuleOptions<
|
||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
readonly rules: Rule | Ruleset;
|
||||
readonly extractContext?: (
|
||||
c: HonoContext
|
||||
) => RuleContext<TData> | Promise<RuleContext<TData>>;
|
||||
readonly engine?: RuleEngine;
|
||||
readonly onLimited?: (
|
||||
c: HonoContext,
|
||||
result: EvaluationResult
|
||||
) => Response | Promise<Response>;
|
||||
readonly retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export type Schemalike = {
|
||||
safeParse(
|
||||
input: unknown
|
||||
):
|
||||
| { success: true; data: unknown }
|
||||
| { success: false; error: { issues: unknown[] } };
|
||||
};
|
||||
|
||||
export interface HonoValidationError {
|
||||
readonly source: "body" | "query" | "params" | "headers";
|
||||
readonly field: string;
|
||||
readonly message: string;
|
||||
readonly code?: string;
|
||||
}
|
||||
|
||||
export type RulesHonoMiddleware = MiddlewareHandler;
|
||||
export type RulesHonoNext = Next;
|
||||
12
packages/foundation/flow/rules-hono/tsconfig.json
Normal file
12
packages/foundation/flow/rules-hono/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/foundation/flow/rules-hono/tsconfig.lib.json
Normal file
12
packages/foundation/flow/rules-hono/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/foundation/flow/rules-hono/vite.config.ts
Normal file
39
packages/foundation/flow/rules-hono/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/rules-hono",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/rules",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"hono",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
39
packages/foundation/flow/rules-hono/vitest.config.ts
Normal file
39
packages/foundation/flow/rules-hono/vitest.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
// Initial migration ships at the actual delivered coverage. The 100/100/100/100
|
||||
// gate is the v1.0 graduation criterion per CLAUDE.md; this package is at v0.0.0
|
||||
// (pre-graduation). Closing the remaining gap is tracked in
|
||||
// `openspec/changes/enterprise-rules-migration/tasks.md §6.5`.
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 70,
|
||||
functions: 90,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user