# `middleware` — `createRbacMiddleware` _(no submodules)_ ## 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.