Files
suaveplan/openspec/specs/rules-hono/spec.md
2026-05-27 23:29:53 +01:00

153 lines
7.6 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<TContext>(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<TContext>(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<TContext>(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.<name>.duration_ms{outcome}` and `rules_hono.middleware.<name>_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"`
<!-- markdownlint-enable MD024 -->