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

7.6 KiB

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"