first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# @suaveplan/api-rbac - Role/scope/rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Principal` plus a route's `RouteAuthSpec` (scope name, @suaveplan/rules Rule, or public bypass), emits 401 when a non-public route has no principal, 403 on missing-scope or rule-deny, and increments `api.rbac.denials_total{plugin, route, reason}`. Ships a conflict-aware `ScopeRegistry` plus `registerPluginScopes(registry, manifest)` so plugin-declared scopes auto-register at install and duplicates raise `RBAC.SCOPE_CONFLICT`. Universal, Zod-validated boundaries, real OTel surface.
Scope + rule-based authorisation middleware for the Suaveplan API plugin
system. Produces a framework-agnostic `Middleware` that consumes the
resolved `Principal` from `RequestContext` and the matched route's
`RouteAuthSpec`, performs the gate, and either calls `next()` or throws a
typed `RbacError`. Every denial increments
`api.rbac.denials_total{plugin, route, reason}` with `reason ∈
{ "missing-scope", "rule-deny", "no-principal" }`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `RBAC.*` error code surfaced by the `@suaveplan/api-rbac` authorisation middleware and…
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
- [instruments](./src/instruments/instruments.md) — Owns the single OpenTelemetry counter `api.rbac.denials_total` and the factory that constructs the `RbacInstruments` re…
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
- [middleware](./src/middleware/middleware.md) — Ships `createRbacMiddleware(options)` — the per-request authorisation gate. The factory produces a framework-agnostic `…
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
- [registry](./src/registry/registry.md) — Owns the shared, conflict-aware registry of plugin-declared scopes plus the `registerPluginScopes(registry, manifest)`…
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
- [spec](./src/spec/spec.md) — Defines the canonical discriminated `RouteAuthSpec` union, its Zod schema, the inbound shape adapters fold down to it,…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-rbac
```
## Quick start
```ts
import { createRbacMiddleware } from "@suaveplan/api-rbac";
const middleware = createRbacMiddleware({
routeFor: () => ({
plugin: "billing",
path: "/invoices",
auth: { kind: "scope", scope: "billing:read" },
}),
});
```
The middleware is the abstract `Middleware` from `@suaveplan/api-types` —
adapters (`@suaveplan/api-router-hono`, `api-router-in-memory`, etc.) plug
it into their pipeline directly.
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — `RBAC_CODES` registry
(`MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`, `SCOPE_CONFLICT`,
`INVALID_SPEC`) and the `RbacError` class.
- [`instruments`](./src/instruments/instruments.md) — the single counter
`api.rbac.denials_total` plus `createRbacInstruments` / `recordRbacDenial`.
- [`middleware`](./src/middleware/middleware.md) — `createRbacMiddleware`
and `statusForRbacError` mapping.
- [`registry`](./src/registry/registry.md) — `ScopeRegistry` plus
`registerPluginScopes(registry, manifest)` for install-time conflict
detection.
- [`spec`](./src/spec/spec.md) — the canonical `RouteAuthSpec` union, the
Zod `RouteAuthSpecSchema`, and the `normaliseRouteAuth` fold.
## OpenTelemetry
- counter `api.rbac.denials_total{plugin, route, reason}` — emitted on
every deny path.
Reason label values: `"missing-scope" | "rule-deny" | "no-principal"`.
## Error codes
| Code | HTTP | When |
| ---------------------- | ---- | -------------------------------------------------------- |
| `RBAC.NO_PRINCIPAL` | 401 | non-public route reached with `principal === null` |
| `RBAC.MISSING_SCOPE` | 403 | principal lacks the required scope |
| `RBAC.RULE_DENY` | 403 | `@suaveplan/rules` evaluation returned `passed === false`|
| `RBAC.SCOPE_CONFLICT` | 500 | two plugins claim the same scope name |
| `RBAC.INVALID_SPEC` | 500 | `RouteAuthSpec` failed Zod validation |
## License
MIT

View File

@@ -0,0 +1,94 @@
# `RBAC_CODES`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable, frozen registry of every `RBAC.*` error code surfaced by the
`@suaveplan/api-rbac` authorisation middleware and its companion
`ScopeRegistry`. Downstream consumers (the host's request pipeline,
operator dashboards, audit pipelines, OpenAPI emitters) read these
strings as load-bearing identifiers — they must never change between
releases without an OpenSpec migration plan. `RBAC_CODES` is the
single source of truth defining those strings, and `RbacError` is the
class wrapping them with full `SuaveplanError` semantics (Zod-validated
wire shape, cause-chain, redaction, OTel span-event integration).
The registry is built with `defineCodes("RBAC", [...])` from
`@suaveplan/error`, which yields a literal-typed, deeply-frozen
record. The literal types flow into every `RbacError` thrown by the
middleware so a caller can `switch` on `err.code` against
`RBAC_CODES.MISSING_SCOPE` with full compile-time exhaustiveness
checking, and the typed `RbacCode` union doubles as a discriminator
for telemetry attribute mapping.
## Features
- Exactly five codes — `MISSING_SCOPE`, `RULE_DENY`, `NO_PRINCIPAL`,
`SCOPE_CONFLICT`, `INVALID_SPEC`.
- Literal-typed values: `RBAC_CODES.MISSING_SCOPE` has the type
`"RBAC.MISSING_SCOPE"` (not the wider `string`).
- Deep-frozen at runtime (`Object.isFrozen` returns `true`).
- Re-exported `RbacCode` union for use as a discriminator type.
- `RbacError` extends `SuaveplanError<RbacCode>` so the cause-chain,
Zod wire shape, redaction config, and OTel span-event adapter all
inherit unchanged.
## Usage: Basic
```ts
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
throw new RbacError(
RBAC_CODES.MISSING_SCOPE,
"principal lacks required scope 'billing:write'"
);
```
## Usage: Advanced
Pattern-match on the union for exhaustive error handling at host call
sites — the compiler verifies every code is handled:
```ts
import { RBAC_CODES, type RbacCode } from "@suaveplan/api-rbac/error-codes";
function statusFor(code: RbacCode): number {
switch (code) {
case RBAC_CODES.NO_PRINCIPAL:
return 401;
case RBAC_CODES.MISSING_SCOPE:
case RBAC_CODES.RULE_DENY:
return 403;
case RBAC_CODES.INVALID_SPEC:
return 500;
case RBAC_CODES.SCOPE_CONFLICT:
// raised at install time, never per-request
return 500;
}
}
```
The `INVALID_SPEC` code is reserved for the Zod ingestion boundary —
host adapters that ingest a route's raw `auth` value run it through
`RouteAuthSpecSchema.parse(...)` and re-throw any failure as
`RbacError(INVALID_SPEC, ...)` so callers never see a raw `ZodError`.
## API Reference
- `RBAC_CODES` — frozen record mapping local name → fully-qualified
`RBAC.*` string. Branded as `defineCodes("RBAC", [...])`.
- `RbacCode` — union of every value in `RBAC_CODES`.
- `RbacError` — `SuaveplanError<RbacCode>` subclass; constructor
signature `(code, message, options?)` mirrors the base class.
## Implementation Notes
`defineCodes` deep-freezes the returned registry so accidental
mutation throws in strict mode. The class is a thin subclass of
`SuaveplanError` purely to override `name = "RbacError"` for clearer
stack traces; no field is added beyond the base shape, so the wire
encoding (`SuaveplanError.toJSON`) is byte-identical to other
`@suaveplan` errors and the i18n resolver hook applies unchanged.

View File

@@ -0,0 +1,115 @@
# `instruments` — `api.rbac.denials_total`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the single OpenTelemetry counter `api.rbac.denials_total` and the
factory that constructs the `RbacInstruments` registry against a
caller-supplied `MeterProvider`. The middleware itself never touches
the global OTel API directly — every emission flows through
`recordRbacDenial(instruments, { plugin, route, reason })`, which
guarantees the three label keys are populated on every call and the
base attributes from the host (host-id, host-version, etc.) are
merged in.
Centralising emission here lets the spec-conformance test pin the
exact counter name, label set, and reason vocabulary against a real
`MeterProvider` from `@opentelemetry/sdk-metrics` — empirically the
class of regression that broke production dashboards in two of three
prior incidents was a label-key rename slipping past lint+typecheck.
## Features
- Single counter `api.rbac.denials_total` with description, created
on the `@suaveplan/api-rbac` meter.
- Closed `RbacDenialReason` union — `"missing-scope" | "rule-deny" |
"no-principal"` — so dashboard authors can rely on a finite label
set.
- Frozen `RBAC_DENIAL_REASONS` and `RBAC_DENIAL_LABEL_KEYS` tuples
for tests and metric-discovery tooling.
- Base attributes merged on every emission; host injects host-id /
host-version at boot.
- `MeterProvider` is injectable — tests use a real
`MeterProvider` from `@opentelemetry/sdk-metrics`, production
resolves through `metrics.getMeterProvider()`.
## Usage: Basic
```ts
import {
createRbacInstruments,
recordRbacDenial,
} from "@suaveplan/api-rbac/instruments";
const instruments = createRbacInstruments();
recordRbacDenial(instruments, {
plugin: "billing",
route: "/invoices",
reason: "missing-scope",
});
```
## Usage: Advanced
Inject a real `MeterProvider` and a `PeriodicExportingMetricReader`
with an `InMemoryMetricExporter` to assert the exact counter
emissions in a conformance test:
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
createRbacInstruments,
RBAC_DENIALS_TOTAL_NAME,
recordRbacDenial,
} from "@suaveplan/api-rbac/instruments";
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
const instruments = createRbacInstruments({
meterProvider: provider,
baseAttributes: { host: "h-1" },
});
recordRbacDenial(instruments, {
plugin: "billing",
route: "/x",
reason: "rule-deny",
});
await provider.forceFlush();
const dump = exporter.getMetrics();
// dump[0].scopeMetrics[0].metrics carries RBAC_DENIALS_TOTAL_NAME …
```
## API Reference
- `RBAC_DENIALS_TOTAL_NAME` — string literal `"api.rbac.denials_total"`.
- `RBAC_METER_NAME` — string literal `"@suaveplan/api-rbac"`.
- `RBAC_DENIAL_REASONS` — frozen tuple of legal reason labels.
- `RBAC_DENIAL_LABEL_KEYS` — frozen tuple `["plugin", "route", "reason"]`.
- `RbacDenialReason` — `"missing-scope" | "rule-deny" | "no-principal"`.
- `RbacInstruments` — `{ denials, baseAttributes }`.
- `RbacInstrumentsOptions` — `{ meterProvider?, baseAttributes? }`.
- `createRbacInstruments(options?)` — factory.
- `recordRbacDenial(instruments, attrs)` — emission helper.
## Implementation Notes
The factory falls back to `metrics.getMeterProvider()` only when no
provider is supplied; production hosts always pass their own
provider so SDK installation is opt-in. The counter description is
load-bearing for the OTel collector's `view` / `attribute_filter`
configuration — operators rely on it to scope the metric to
particular pipelines without re-discovering it. `Object.freeze` on
the registry plus `Object.freeze` on the base attribute bag makes
accidental in-place mutation throw in strict mode, which is the
class of bug `tslib` + `verbatimModuleSyntax` cannot catch.

View File

@@ -0,0 +1,120 @@
# `middleware` — `createRbacMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ships `createRbacMiddleware(options)` — the per-request authorisation
gate. The factory produces a framework-agnostic `Middleware` that
consumes the resolved `Principal` from `RequestContext` plus the
matched route's canonical `RouteAuthSpec`, and performs one of three
checks: bypass for `public`, contains-check against `principal.scopes`
for `scope`, or delegated evaluation via `@suaveplan/rules` for
`rule`. Denials are signalled by throwing a typed `RbacError` with
the corresponding `RBAC.*` code; the host's exception filter folds
the error into the standard `ErrorEnvelope` wire shape.
The middleware does not depend on Hono / Elysia / Bun.serve directly
— it returns the abstract `Middleware` from `@suaveplan/api-types`.
Adapters bridging to a specific router (`@suaveplan/api-router-hono`,
`api-router-in-memory`) accept the middleware factory and wire it
into their own pipeline; the matched-route descriptor is bridged via
the `routeFor(ctx)` callback supplied at construction time.
## Features
- `public` arm bypasses the gate entirely — no scope check, no
rule evaluation, no metric emission.
- Scope arm performs a strict `===` contains-check on
`principal.scopes`. Empty principal on a non-public route routes
to `RBAC.NO_PRINCIPAL` (401), not `MISSING_SCOPE` (403).
- Rule arm delegates to `RuleEngine.evaluate` with a context
populated from the request method/path/query and the principal /
tenant.
- Every denial increments `api.rbac.denials_total` with the three
canonical labels (`plugin`, `route`, `reason`).
- `statusForRbacError(error)` exposes the 401/403/500 mapping for
adapters that prefer return-value handling over throwing.
- Configurable rule engine + instruments — production hosts pass
their own.
## Usage: Basic
```ts
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
const middleware = createRbacMiddleware({
routeFor: () => ({
plugin: "billing",
path: "/invoices",
auth: { kind: "scope", scope: "billing:read" },
}),
});
```
## Usage: Advanced
Rule-based authorisation with the `@suaveplan/rules` engine and an
injected `MeterProvider` for assertable telemetry:
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { rule, RuleEngine } from "@suaveplan/rules";
import { createRbacMiddleware } from "@suaveplan/api-rbac/middleware";
import { createRbacInstruments } from "@suaveplan/api-rbac/instruments";
const provider = new MeterProvider();
const instruments = createRbacInstruments({ meterProvider: provider });
const engine = new RuleEngine();
const billingRule = rule().condition({
field: "principal.scopes",
operator: "contains",
value: "billing:write",
});
const middleware = createRbacMiddleware({
rules: engine,
instruments,
routeFor: (ctx) => ({
plugin: "billing",
path: new URL(ctx.request.url).pathname,
auth: { kind: "rule", rule: billingRule },
}),
});
```
The middleware throws `RbacError` rather than writing a Response so
the request pipeline's central exception filter retains a single
formatting site for the `ErrorEnvelope` wire shape.
## API Reference
- `RbacRequestContext` — structural shape consumed from the
request context (`request`, `principal`, `tenantId`).
- `RbacRoute` — `{ plugin, path, auth }` matched-route descriptor.
- `RbacMiddlewareOptions` — `{ rules?, routeFor, instruments? }`.
- `createRbacMiddleware(options)` — returns the abstract `Middleware`.
- `statusForRbacError(error)` — code → HTTP status mapping.
## Implementation Notes
The `rule` arm constructs a fresh `RuleContext` per request — the
engine receives `principal`, `tenantId`, and a `request` block with
the request method, pathname, and the searchParams folded into a
plain object. The engine returns `{ passed, errors, … }`; only
`passed === false` triggers a denial, matching the rules-package
contract. The `URL` parse happens once per request after the
fast-path arms (`public`, `scope`, `no-principal`) have returned, so
the typical scope-gated route does not pay the parse cost.
Strict-mode semantics for the 401 vs 403 split are tested at
`middleware.test.ts::"returns 401 for null principal on non-public
route"` — empirically the bug that breaks consumers most is a 403
on a missing principal masking an authentication outage as an
authorisation outage. The implementation explicitly checks
`principal === null` before any scope arithmetic so the two failure
modes never alias.

View File

@@ -0,0 +1,114 @@
# `registry` — `ScopeRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the shared, conflict-aware registry of plugin-declared scopes
plus the `registerPluginScopes(registry, manifest)` helper the host
calls at install time. The registry's primary job is install-time
hygiene: two plugins claiming the same scope name (`billing:read`
declared by both `billing` and `invoices`) must fail loudly with
`RBAC.SCOPE_CONFLICT` instead of producing whichever-registered-last
behaviour at request time.
The registry is intentionally not the source of truth for the gate
decision. `principal.scopes` is. The registry exists to catch typos
and cross-plugin conflicts at boot, drive the admin UI's
list-of-available-scopes endpoint, and feed the OpenAPI emitter so
a route declaring an undeclared scope raises a startup error.
## Features
- Conflict detection — duplicate `name` across plugins raises
`RBAC.SCOPE_CONFLICT` with the conflicting plugin id in
`error.context.conflictingPlugin`.
- Idempotent re-register — same plugin, same name, same description
is a no-op so hot reload is safe.
- Empty-string scope name rejected with `RBAC.INVALID_SPEC`.
- `unregisterPlugin(plugin)` removes every scope owned by that
plugin — used at uninstall so a future install may legitimately
claim the same name.
- `registerPluginScopes(registry, manifest)` returns the count of
newly-registered (non-idempotent) entries so the host can log a
meaningful summary.
## Usage: Basic
```ts
import {
registerPluginScopes,
ScopeRegistry,
} from "@suaveplan/api-rbac/registry";
const registry = new ScopeRegistry();
registerPluginScopes(registry, {
name: "billing",
scopes: [
{ name: "billing:read", description: "Read invoices" },
{ name: "billing:write" },
],
});
registry.has("billing:read"); // true
registry.list(); // [ … two descriptors … ]
```
## Usage: Advanced
Driving a per-plugin uninstall + reinstall cycle while preserving
ordering invariants:
```ts
import {
registerPluginScopes,
ScopeRegistry,
} from "@suaveplan/api-rbac/registry";
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
const registry = new ScopeRegistry();
registerPluginScopes(registry, {
name: "billing",
scopes: [{ name: "billing:read" }],
});
// Cross-plugin claim → conflict.
try {
registerPluginScopes(registry, {
name: "invoices",
scopes: [{ name: "billing:read" }],
});
} catch (err) {
if (err instanceof RbacError) {
if (err.code === RBAC_CODES.SCOPE_CONFLICT) {
// Drop the offending plugin, surface to operator …
}
}
}
// Uninstall the first plugin, then the second may claim it.
registry.unregisterPlugin("billing");
registerPluginScopes(registry, {
name: "invoices",
scopes: [{ name: "billing:read" }],
});
```
## API Reference
- `RegisteredScope` — `{ name, plugin, description? }`.
- `ScopeRegistry` — class with `register / has / get / list /
unregisterPlugin` methods. Public no-arg constructor.
- `registerPluginScopes(registry, manifest)` — iterates
`manifest.scopes[]`, returns the count of newly-registered entries.
## Implementation Notes
The byte-identical idempotence comparison covers `(plugin, name,
description)`. A change in `description` from the same plugin is
treated as a conflict on purpose — it would otherwise be a silent
admin-visible mutation that bypasses the OpenSpec change discipline.
The internal `Map` is intentionally not exposed; callers walk
`list()` for snapshot semantics so a concurrent install cannot
trip an iterator-invalidation bug.

View File

@@ -0,0 +1,99 @@
# `spec` — `RouteAuthSpec`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical discriminated `RouteAuthSpec` union, its Zod
schema, the inbound shape adapters fold down to it, and the helpers
that perform that folding. The middleware itself works exclusively
against `RouteAuthSpec` — every other shape has to pass through
`normaliseRouteAuth` first. Centralising the shape here means the
router adapter, the rule-policy loader, and the admin API all share
one validator and one error code (`RBAC.INVALID_SPEC`).
`RouteAuthSpec` is a closed three-arm union — `"public"`, `"scope"`,
and `"rule"`. The router adapter accepts the friendlier
{@link RouteAuthInput} (string scope, `null` → public, or a
`{ rule }` wrapper) and folds it down before invoking the middleware.
This keeps the in-handler ergonomics terse without exposing the
gate to ambiguous strings or undefineds.
## Features
- Discriminated `RouteAuthSpec` union — `public`, `scope`, `rule`.
- Zod `RouteAuthSpecSchema` discriminated-union schema covers all
three arms, rejects unknown discriminators and excess keys.
- `normaliseRouteAuth(input)` folds the five recognised input shapes
(`null`, `undefined`, string, `RouteAuthSpec`, `{ rule }`) down to
the canonical shape; throws `RbacError(INVALID_SPEC, ...)` on
anything else.
- `isRouteAuthSpec(value)` type-guard for JSON ingestion paths that
need a narrowing check without a thrown error.
- Empty-string scope rejected at the boundary so `auth: ""` cannot
silently become a permanent-deny gate.
## Usage: Basic
```ts
import { normaliseRouteAuth } from "@suaveplan/api-rbac/spec";
normaliseRouteAuth(null); // { kind: "public" }
normaliseRouteAuth("billing:read"); // { kind: "scope", scope: "billing:read" }
normaliseRouteAuth({ kind: "public" });// { kind: "public" }
```
## Usage: Advanced
Validate a manifest-loaded auth descriptor at the install-time
boundary and re-throw with a typed `RbacError` so the host's
exception filter routes it through the standard error envelope:
```ts
import { RouteAuthSpecSchema } from "@suaveplan/api-rbac/spec";
import { RBAC_CODES, RbacError } from "@suaveplan/api-rbac/error-codes";
function ingest(raw: unknown) {
const parsed = RouteAuthSpecSchema.safeParse(raw);
if (!parsed.success) {
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"manifest route auth failed validation",
{ cause: parsed.error }
);
}
return parsed.data;
}
```
Rule-based auth delegates evaluation to `@suaveplan/rules`. The
`rule` slot accepts any `Rule` from that package — `ConditionRule`,
`LogicalRule`, `GroupRule`, `CustomRule`, `ComputedRule`. The schema
only enforces that a `type: string` field is present so the engine
gets a valid shape; the engine's own Zod schemas validate the
internal structure at evaluation time.
## API Reference
- `RouteAuthSpec` — discriminated union of `public`, `scope`, `rule`.
- `RouteAuthInput` — union of recognised input shapes.
- `RouteAuthSpecSchema` — Zod discriminated-union schema; used at the
manifest / admin-API ingestion boundary.
- `normaliseRouteAuth(input)` — fold any `RouteAuthInput` to a
`RouteAuthSpec`; throws `RbacError(INVALID_SPEC, ...)`.
- `isRouteAuthSpec(value)` — type-guard; returns `true` on a
conforming value.
## Implementation Notes
The schema uses Zod's `discriminatedUnion` so error messages carry
the offending discriminator value. `.strict()` on each arm rejects
excess keys so a typo (`{ kind: "scope", scop: "x" }`) fails fast
at parse time rather than producing a silently-permanent-deny route.
The `Rule` shape is intentionally loose at this boundary —
re-validating the full rule tree here would duplicate
`@suaveplan/rules`' own validation and double the maintenance
surface; consumers that want strict rule validation invoke the
serializer's `validateSerializedRule` directly.