# Capability Spec Delta: rules-hono (enterprise-rules-migration) **Package**: `@suaveplan/rules-hono` **Status**: PROPOSED > Every requirement carries a `**Verified by:**` line at authoring time. (AGENTS.md ยง13.) ## ADDED Requirements ### Requirement: ruleMiddleware evaluates a single rule per request The package SHALL expose `ruleMiddleware(rule, options)` returning a Hono `MiddlewareHandler`. The middleware SHALL invoke `options.extractData?` to build the evaluation context (defaulting to a context derived from `c.req`), call `options.engine ?? getDefaultEngine()` to evaluate, store the `EvaluationResult` in `c.set("ruleResult", result)`, and call `options.onDenied?` (or short-circuit with 403) when the rule fails. #### Scenario: passes through to next handler when rule passes - **WHEN** a Hono app uses `ruleMiddleware(passingRule)` and a request is dispatched - **THEN** the next handler is invoked and `c.var.ruleResult.passed === true` #### Scenario: 403 short-circuit when rule fails - **WHEN** a Hono app uses `ruleMiddleware(failingRule)` and no `onDenied` is supplied - **THEN** the response status is 403 and the next handler is not invoked **Verified by:** `src/middleware/rule.test.ts::"passes through on rule pass"` AND `src/middleware/rule.test.ts::"403 short-circuits on rule fail"` --- ### Requirement: validationMiddleware validates request body, query, params, and headers The package SHALL expose `validationMiddleware(options)` accepting per-source schemas under `body` / `query` / `params` / `headers`. The middleware SHALL collect errors per source as `HonoValidationError`, call `options.onError?` (or short-circuit with 400 returning a JSON body) when any errors exist, and honour `abortEarly` to stop at the first failing source. #### Scenario: body schema rejects malformed JSON - **WHEN** a request with `Content-Type: application/json` and a body that fails the body schema is dispatched - **THEN** the response status is 400 and the body lists every failing field with `source: "body"` #### Scenario: abortEarly stops at first failing source - **WHEN** body and query both fail and `abortEarly: true` is set - **THEN** the response body lists only `body` errors **Verified by:** `src/middleware/validation.test.ts::"body schema rejects malformed JSON"` AND `src/middleware/validation.test.ts::"abortEarly stops at first failing source"` --- ### Requirement: accessControl evaluates a ruleset with caching The package SHALL expose `accessControl(options)` taking a single rule or a ruleset, optionally an injectable `cache: { get(k): Result | undefined; set(k, v, ttl): void }` keyed by `options.cacheKey?(c)`. On pass, the result SHALL be stored in `c.set("accessResult", result)`; on fail, `options.onDenied?` runs or a 403 is returned. #### Scenario: cache hit short-circuits evaluation - **WHEN** the cache returns a passed `EvaluationResult` for the computed key - **THEN** the rule engine is not invoked and the next handler runs with `c.var.accessResult` populated from cache #### Scenario: 403 on ruleset denial includes errors - **WHEN** a ruleset evaluates to `passed: false` - **THEN** the response status is 403 and the JSON body includes the `errors` array **Verified by:** `src/middleware/access-control.test.ts::"cache hit short-circuits engine"` AND `src/middleware/access-control.test.ts::"403 includes ruleset errors"` --- ### Requirement: responseValidation validates outgoing JSON bodies The package SHALL expose `responseValidation(options)` that runs AFTER the downstream handler. The middleware SHALL parse the response body when `content-type` is `application/json`, validate it against `options.schema`, and call `options.onError?` (or replace the response with a 500 and a JSON envelope) when validation fails. #### Scenario: invalid response body replaced with 500 envelope - **WHEN** the downstream handler returns JSON that fails the schema - **THEN** the final response has status 500 and a JSON body with `errors` **Verified by:** `src/middleware/response-validation.test.ts::"invalid body replaced with 500"` --- ### Requirement: rateLimitRule returns 429 when its rule fails The package SHALL expose `rateLimitRule(options)` evaluating `options.rules` against a context built by `options.extractContext`. On failure, the response SHALL be 429 with a `Retry-After` header when `options.retryAfterSeconds?` is provided, optionally allowing `options.onLimited?` to customise the response. #### Scenario: 429 with Retry-After when over limit - **WHEN** the rule fails and `retryAfterSeconds: 60` is configured - **THEN** the response status is 429 and `Retry-After: 60` **Verified by:** `src/middleware/rate-limit-rule.test.ts::"429 with Retry-After"` --- ### Requirement: combineRules chains middlewares with early-response short-circuit The package SHALL expose `combineRules(...middlewares)` returning a single `MiddlewareHandler`. The combined middleware SHALL run each child sequentially, short-circuit on the first child that writes a response (no further children run), and pass control to the next handler if all children pass. #### Scenario: first early response short-circuits - **WHEN** the first child sends a 403 response - **THEN** the second child is not invoked #### Scenario: all-pass chains through - **WHEN** every child passes through - **THEN** the next handler runs and observes context variables set by each child **Verified by:** `src/middleware/combine.test.ts::"first response short-circuits"` AND `src/middleware/combine.test.ts::"all-pass chains through"` --- ### Requirement: Default engine is lazy and per-process The package SHALL expose `getDefaultEngine()` returning a process-singleton `RuleEngine` constructed on first call (no module-level instantiation). Tests SHALL be able to reset the singleton via `resetDefaultEngine()` (exported under the `./testing` subpath only). #### Scenario: first call constructs the singleton - **WHEN** `getDefaultEngine()` is called twice - **THEN** the second call returns the identical reference (`===`) #### Scenario: resetDefaultEngine forces reconstruction - **WHEN** `resetDefaultEngine()` is called, then `getDefaultEngine()` returns a fresh instance - **THEN** the new instance is not `===` to the previous one **Verified by:** `src/engine/default.test.ts::"singleton identity"` AND `src/engine/default.test.ts::"resetDefaultEngine forces reconstruction"` --- ### Requirement: All middleware options are validated via Zod at construction Every middleware factory SHALL Zod-validate its `options` argument at call time and raise `RulesError` with `code: "RULES.PLUGIN_FAILED"` (re-used from the rules core for option-validation failures) when validation rejects. #### Scenario: validationMiddleware rejects malformed options - **WHEN** `validationMiddleware({ body: "not-a-schema" } as any)` is called - **THEN** the call throws `RulesError` `code: "RULES.PLUGIN_FAILED"` **Verified by:** `src/middleware/options.spec-conformance.test.ts::"rejects malformed options"` --- ### Requirement: OTel surface emits from middleware paths The package SHALL emit `rules_hono.middleware..duration_ms{outcome}` and `rules_hono.middleware._total{outcome}` for each of the six middleware factories, asserted via an in-memory `MeterProvider`. #### Scenario: every middleware emits its duration histogram - **WHEN** the full test suite runs against an in-memory `MeterProvider` - **THEN** each of the six middlewares has at least one recorded `duration_ms` measurement **Verified by:** `src/middleware/otel.spec-conformance.test.ts::"every middleware emits duration_ms"`