Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,95 @@
{
"name": "@suaveplan/api-rbac",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Role/scope/rule-based authorisation middleware for the Suaveplan API plugin foundation. 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./middleware": {
"import": {
"types": "./dist/middleware/middleware.d.ts",
"default": "./dist/middleware/middleware.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
}
},
"./spec": {
"import": {
"types": "./dist/spec/spec.d.ts",
"default": "./dist/spec/spec.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/rules": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RBAC_CODES, RbacError } from "./error-codes.js";
describe("RBAC_CODES", () => {
it("contains every documented code", { timeout: 5000 }, () => {
expect(Object.keys(RBAC_CODES).sort()).toEqual(
[
"INVALID_SPEC",
"MISSING_SCOPE",
"NO_PRINCIPAL",
"RULE_DENY",
"SCOPE_CONFLICT",
].sort()
);
});
it("codes carry the RBAC namespace prefix", { timeout: 5000 }, () => {
for (const v of Object.values(RBAC_CODES)) {
expect(v.startsWith("RBAC.")).toBe(true);
}
});
it("registry is deep-frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(RBAC_CODES)).toBe(true);
});
});
describe("RbacError", () => {
it("preserves code, message, name", { timeout: 5000 }, () => {
const err = new RbacError(RBAC_CODES.MISSING_SCOPE, "no scope");
expect(err.code).toBe(RBAC_CODES.MISSING_SCOPE);
expect(err.message).toBe("no scope");
expect(err.name).toBe("RbacError");
});
it("captures context map", { timeout: 5000 }, () => {
const err = new RbacError(RBAC_CODES.NO_PRINCIPAL, "x", {
context: { foo: 1 },
});
expect(err.context?.["foo"]).toBe(1);
});
it("propagates cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new RbacError(RBAC_CODES.INVALID_SPEC, "x", { cause });
expect(err.cause).toBe(cause);
});
it("instanceof checks still narrow", { timeout: 5000 }, () => {
const err: unknown = new RbacError(RBAC_CODES.RULE_DENY, "x");
expect(err instanceof RbacError).toBe(true);
expect(err instanceof Error).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-rbac`.
*
* Every public failure path raises an {@link RbacError} carrying one of
* these namespaced `RBAC.*` codes. Consumers branch on the string value
* (e.g. `error.code === RBAC_CODES.MISSING_SCOPE` or the inline literal
* `"RBAC.MISSING_SCOPE"`).
*
* Codes:
* - `MISSING_SCOPE` — principal is authenticated but lacks the required scope (403).
* - `RULE_DENY` — `@suaveplan/rules` evaluation returned `passed === false` (403).
* - `NO_PRINCIPAL` — non-public route was reached with `principal === null` (401).
* - `SCOPE_CONFLICT` — two plugins declared the same scope name in their manifest.
* - `INVALID_SPEC` — the `RouteAuthSpec` Zod schema rejected the inbound shape.
*/
export const RBAC_CODES = defineCodes("RBAC", [
"MISSING_SCOPE",
"RULE_DENY",
"NO_PRINCIPAL",
"SCOPE_CONFLICT",
"INVALID_SPEC",
] as const);
/** Union of every fully-qualified `RBAC.*` code. */
export type RbacCode = (typeof RBAC_CODES)[keyof typeof RBAC_CODES];
/**
* Typed error for every `@suaveplan/api-rbac` failure path.
*
* Extends {@link SuaveplanError} so callers can discriminate either via
* `error instanceof RbacError` or via the stable `error.code` string. The
* `name` is overridden to `"RbacError"` so stack traces are self-describing.
*/
export class RbacError extends SuaveplanError<RbacCode> {
public constructor(
code: RbacCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "RbacError";
}
}

View File

@@ -0,0 +1 @@
export { RBAC_CODES, type RbacCode, RbacError } from "./error-codes.js";

View File

@@ -0,0 +1,44 @@
// Side-effect import — surfaces the error-codes registry to consumers
// of the package root barrel even when they only import a subpath.
import "./error-codes/error-codes.js";
// Error codes
export {
RBAC_CODES,
type RbacCode,
RbacError,
} from "./error-codes/error-codes.js";
// Instruments
export {
createRbacInstruments,
RBAC_DENIAL_LABEL_KEYS,
RBAC_DENIAL_REASONS,
RBAC_DENIALS_TOTAL_NAME,
RBAC_METER_NAME,
type RbacDenialReason,
type RbacInstruments,
type RbacInstrumentsOptions,
recordRbacDenial,
} from "./instruments/instruments.js";
// Middleware
export {
createRbacMiddleware,
type RbacMiddlewareOptions,
type RbacRequestContext,
type RbacRoute,
statusForRbacError,
} from "./middleware/middleware.js";
// Registry
export {
type RegisteredScope,
registerPluginScopes,
ScopeRegistry,
} from "./registry/registry.js";
// Spec
export {
isRouteAuthSpec,
normaliseRouteAuth,
type RouteAuthInput,
type RouteAuthSpec,
RouteAuthSpecSchema,
} from "./spec/spec.js";

View File

@@ -0,0 +1,11 @@
export {
createRbacInstruments,
RBAC_DENIAL_LABEL_KEYS,
RBAC_DENIAL_REASONS,
RBAC_DENIALS_TOTAL_NAME,
RBAC_METER_NAME,
type RbacDenialReason,
type RbacInstruments,
type RbacInstrumentsOptions,
recordRbacDenial,
} from "./instruments.js";

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,110 @@
import { metrics } from "@opentelemetry/api";
import type {
DataPoint,
MetricData,
ResourceMetrics,
} from "@opentelemetry/sdk-metrics";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createRbacInstruments,
RBAC_DENIAL_LABEL_KEYS,
RBAC_DENIAL_REASONS,
RBAC_DENIALS_TOTAL_NAME,
RBAC_METER_NAME,
recordRbacDenial,
} from "./instruments.js";
function build(): {
provider: MeterProvider;
exporter: InMemoryMetricExporter;
} {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const provider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
}),
],
});
return { provider, exporter };
}
describe("createRbacInstruments", () => {
it("creates the denials counter on the @suaveplan/api-rbac meter", {
timeout: 5000,
}, async () => {
const { provider, exporter } = build();
const inst = createRbacInstruments({ meterProvider: provider });
recordRbacDenial(inst, {
plugin: "p",
route: "/r",
reason: "missing-scope",
});
await provider.forceFlush();
const all: ResourceMetrics[] = exporter.getMetrics();
const meterNames = new Set(
all.flatMap((rm) => rm.scopeMetrics.map((sm) => sm.scope.name))
);
expect(meterNames.has(RBAC_METER_NAME)).toBe(true);
});
it("recordRbacDenial emits the canonical label keys", {
timeout: 5000,
}, async () => {
const { provider, exporter } = build();
const inst = createRbacInstruments({
meterProvider: provider,
baseAttributes: { host: "h-1", region: "r-1" },
});
recordRbacDenial(inst, {
plugin: "billing",
route: "/invoices",
reason: "rule-deny",
});
await provider.forceFlush();
const all: ResourceMetrics[] = exporter.getMetrics();
const flat: DataPoint<number>[] = [];
for (const rm of all) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics as MetricData[]) {
if (m.descriptor.name !== RBAC_DENIALS_TOTAL_NAME) continue;
for (const dp of m.dataPoints as DataPoint<number>[]) {
flat.push(dp);
}
}
}
}
const dp = flat[0] as DataPoint<number>;
for (const k of RBAC_DENIAL_LABEL_KEYS) {
expect(Object.keys(dp.attributes)).toContain(k);
}
expect(dp.attributes["host"]).toBe("h-1");
expect(dp.attributes["region"]).toBe("r-1");
});
it("falls back to the global MeterProvider when none supplied", {
timeout: 5000,
}, () => {
const prev = metrics.getMeterProvider();
const inst = createRbacInstruments();
expect(inst.denials).toBeDefined();
expect(metrics.getMeterProvider()).toBe(prev);
});
it("RBAC_DENIAL_REASONS contains the three documented reasons", {
timeout: 5000,
}, () => {
expect([...RBAC_DENIAL_REASONS].sort()).toEqual(
["missing-scope", "no-principal", "rule-deny"].sort()
);
});
});

View File

@@ -0,0 +1,102 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Reason label values emitted on `api.rbac.denials_total`.
*
* - `"missing-scope"` — principal lacked the required scope.
* - `"rule-deny"` — `@suaveplan/rules` evaluation returned `passed === false`.
* - `"no-principal"` — non-public route with `principal === null`.
*/
export type RbacDenialReason = "missing-scope" | "rule-deny" | "no-principal";
/** Frozen tuple of every legal reason label, for tests and dashboards. */
export const RBAC_DENIAL_REASONS = [
"missing-scope",
"rule-deny",
"no-principal",
] as const satisfies readonly RbacDenialReason[];
/** Frozen tuple of label keys recorded on the counter. */
export const RBAC_DENIAL_LABEL_KEYS = [
"plugin",
"route",
"reason",
] as const satisfies readonly string[];
/** Fully-qualified counter name. Load-bearing across dashboards. */
export const RBAC_DENIALS_TOTAL_NAME = "api.rbac.denials_total" as const;
/** Suaveplan-owned meter name for this package. */
export const RBAC_METER_NAME = "@suaveplan/api-rbac" as const;
/** Options accepted by {@link createRbacInstruments}. */
export interface RbacInstrumentsOptions {
/**
* OpenTelemetry MeterProvider — when omitted, the global
* `metrics.getMeterProvider()` is used. Tests inject a real
* `MeterProvider` from `@opentelemetry/sdk-metrics`.
*/
readonly meterProvider?: MeterProvider;
/**
* Default attributes mixed into every counter `add()` call (e.g.
* host id + version). Optional; the host injects them at boot.
*/
readonly baseAttributes?: Readonly<Record<string, string>>;
}
/**
* Registry returned by {@link createRbacInstruments}. The middleware
* receives a frozen reference and calls `denials.add(1, { ... })` on
* every deny path.
*/
export interface RbacInstruments {
/** The fully-qualified `api.rbac.denials_total` counter. */
readonly denials: Counter;
/** Base attributes mixed into every emission. Empty object by default. */
readonly baseAttributes: Readonly<Record<string, string>>;
}
/**
* Build the RBAC OTel instrument registry against a `MeterProvider`.
*
* The factory is pure — no global state is mutated. Callers that want
* a single shared registry hand the returned value to the middleware
* factory; the in-process default flows through the global
* `metrics.getMeterProvider()` resolution.
*/
export function createRbacInstruments(
options: RbacInstrumentsOptions = {}
): RbacInstruments {
const provider: MeterProvider =
options.meterProvider ?? metrics.getMeterProvider();
const meter: Meter = provider.getMeter(RBAC_METER_NAME);
const denials = meter.createCounter(RBAC_DENIALS_TOTAL_NAME, {
description:
"Number of requests denied by the RBAC middleware, labelled by plugin / route / reason.",
});
return Object.freeze({
denials,
baseAttributes: Object.freeze({ ...(options.baseAttributes ?? {}) }),
});
}
/**
* Record a single denial with the canonical label set. The function is
* defensively typed so the middleware cannot forget a label.
*/
export function recordRbacDenial(
instruments: RbacInstruments,
attrs: {
readonly plugin: string;
readonly route: string;
readonly reason: RbacDenialReason;
}
): void {
instruments.denials.add(1, {
plugin: attrs.plugin,
route: attrs.route,
reason: attrs.reason,
...instruments.baseAttributes,
});
}

View File

@@ -0,0 +1,7 @@
export {
createRbacMiddleware,
type RbacMiddlewareOptions,
type RbacRequestContext,
type RbacRoute,
statusForRbacError,
} from "./middleware.js";

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,159 @@
import type {
DataPoint,
MetricData,
ResourceMetrics,
} from "@opentelemetry/sdk-metrics";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import type {
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRbacInstruments } from "../instruments/instruments.js";
import type { RouteAuthSpec } from "../spec/spec.js";
import { createRbacMiddleware, type RbacRequestContext } from "./middleware.js";
const OK = new Response("ok", { status: 200 });
function makePrincipal(scopes: readonly string[]): Principal {
return {
id: "u_1" as PrincipalId,
scopes: scopes as readonly string[] as readonly Scope[],
claims: {},
};
}
function makeCtx(p: Principal | null): RbacRequestContext {
return {
request: new Request("http://x/test"),
principal: p,
tenantId: null,
};
}
interface DenialDataPoint {
readonly attributes: Readonly<Record<string, unknown>>;
readonly value: number;
}
async function collectDenials(
exporter: InMemoryMetricExporter,
provider: MeterProvider
): Promise<DenialDataPoint[]> {
await provider.forceFlush();
const all: ResourceMetrics[] = exporter.getMetrics();
const out: DenialDataPoint[] = [];
for (const rm of all) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics as MetricData[]) {
if (m.descriptor.name !== "api.rbac.denials_total") continue;
for (const dp of m.dataPoints as DataPoint<number>[]) {
out.push({
attributes: dp.attributes as Readonly<
Record<string, unknown>
>,
value: dp.value as number,
});
}
}
}
}
return out;
}
function buildProvider(): {
provider: MeterProvider;
exporter: InMemoryMetricExporter;
} {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
return { provider, exporter };
}
function makeMiddleware(provider: MeterProvider, auth: RouteAuthSpec) {
const instruments = createRbacInstruments({
meterProvider: provider,
baseAttributes: { host: "h-1" },
});
return createRbacMiddleware({
instruments,
routeFor: () => ({ plugin: "billing", path: "/invoices", auth }),
});
}
describe("api.rbac.denials_total — conformance", () => {
it("denial emits api.rbac.denials_total with missing-scope reason", {
timeout: 5000,
}, async () => {
const { provider, exporter } = buildProvider();
const mw = makeMiddleware(provider, {
kind: "scope",
scope: "billing:write",
});
await mw(
makeCtx(makePrincipal(["billing:read"])),
async () => OK
).catch(() => undefined);
const dps = await collectDenials(exporter, provider);
expect(dps.length).toBeGreaterThan(0);
const dp = dps[0] as DenialDataPoint;
expect(dp.value).toBe(1);
expect(dp.attributes["reason"]).toBe("missing-scope");
expect(dp.attributes["plugin"]).toBe("billing");
expect(dp.attributes["route"]).toBe("/invoices");
expect(dp.attributes["host"]).toBe("h-1");
});
it("no-principal denial labels reason='no-principal'", {
timeout: 5000,
}, async () => {
const { provider, exporter } = buildProvider();
const mw = makeMiddleware(provider, {
kind: "scope",
scope: "billing:read",
});
await mw(makeCtx(null), async () => OK).catch(() => undefined);
const dps = await collectDenials(exporter, provider);
const dp = dps[0] as DenialDataPoint;
expect(dp.attributes["reason"]).toBe("no-principal");
});
it("public route does not increment denial counter", {
timeout: 5000,
}, async () => {
const { provider, exporter } = buildProvider();
const mw = makeMiddleware(provider, { kind: "public" });
await mw(makeCtx(null), async () => OK);
const dps = await collectDenials(exporter, provider);
expect(dps).toEqual([]);
});
it("scope arm with required scope does not increment", {
timeout: 5000,
}, async () => {
const { provider, exporter } = buildProvider();
const mw = makeMiddleware(provider, {
kind: "scope",
scope: "billing:read",
});
const res = await mw(
makeCtx(makePrincipal(["billing:read"])),
async () => OK
);
expect(res.status).toBe(200);
const dps = await collectDenials(exporter, provider);
expect(dps).toEqual([]);
});
});

View File

@@ -0,0 +1,229 @@
import type {
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { condition, RuleEngine } from "@suaveplan/rules";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
import { createRbacInstruments } from "../instruments/instruments.js";
import type { RouteAuthSpec } from "../spec/spec.js";
import {
createRbacMiddleware,
type RbacRequestContext,
statusForRbacError,
} from "./middleware.js";
const OK = new Response("ok", { status: 200 });
function makePrincipal(scopes: readonly string[]): Principal {
return {
id: "u_1" as PrincipalId,
scopes: scopes as readonly string[] as readonly Scope[],
claims: { sub: "u_1" },
};
}
function makeCtx(
overrides: Partial<RbacRequestContext> = {}
): RbacRequestContext {
return {
request: new Request("http://localhost/test?a=1&b=2", {
method: "GET",
}),
principal: makePrincipal(["billing:read"]),
tenantId: "t_1" as unknown as TenantId,
...overrides,
};
}
function makeMiddleware(
auth: RouteAuthSpec,
opts: { engine?: RuleEngine } = {}
) {
const instruments = createRbacInstruments();
return createRbacMiddleware({
instruments,
rules: opts.engine,
routeFor: () => ({
plugin: "billing",
path: "/invoices",
auth,
}),
});
}
describe("createRbacMiddleware — scope arm", () => {
it("permits request with required scope", { timeout: 5000 }, async () => {
const mw = makeMiddleware({ kind: "scope", scope: "billing:read" });
const res = await mw(makeCtx(), async () => OK);
expect(res.status).toBe(200);
});
it("denies request missing required scope", { timeout: 5000 }, async () => {
const mw = makeMiddleware({ kind: "scope", scope: "billing:write" });
let captured: RbacError | undefined;
try {
await mw(makeCtx(), async () => OK);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.MISSING_SCOPE);
expect(statusForRbacError(captured as RbacError)).toBe(403);
});
});
describe("createRbacMiddleware — rule arm", () => {
it("permits when rule evaluates true", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const allowRule = condition("principal.id", "equals", "u_1");
const mw = makeMiddleware(
{ kind: "rule", rule: allowRule },
{ engine }
);
const res = await mw(makeCtx(), async () => OK);
expect(res.status).toBe(200);
});
it("denies when rule evaluates false", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const denyRule = condition("principal.id", "equals", "u_other");
const mw = makeMiddleware({ kind: "rule", rule: denyRule }, { engine });
let captured: RbacError | undefined;
try {
await mw(makeCtx(), async () => OK);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.RULE_DENY);
expect(statusForRbacError(captured as RbacError)).toBe(403);
});
it("rule arm with tenantId populates rule context", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r = condition("principal.id", "equals", "u_1");
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
const res = await mw(
makeCtx({ tenantId: "acme" as unknown as TenantId }),
async () => OK
);
expect(res.status).toBe(200);
});
it("rule arm with null tenantId omits the tenant slot", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r = condition("principal.id", "equals", "u_1");
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
const res = await mw(makeCtx({ tenantId: null }), async () => OK);
expect(res.status).toBe(200);
});
it("rule arm with undefined tenantId omits the tenant slot", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r = condition("principal.id", "equals", "u_1");
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
const ctx: RbacRequestContext = {
request: new Request("http://localhost/x"),
principal: makePrincipal(["s"]),
};
const res = await mw(ctx, async () => OK);
expect(res.status).toBe(200);
});
it("rule spec without an engine throws INVALID_SPEC", {
timeout: 5000,
}, async () => {
const denyRule = condition("x", "equals", "y");
const mw = makeMiddleware({ kind: "rule", rule: denyRule });
let captured: RbacError | undefined;
try {
await mw(makeCtx(), async () => OK);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
});
describe("createRbacMiddleware — null principal", () => {
it("returns 401 for null principal on non-public route", {
timeout: 5000,
}, async () => {
const mw = makeMiddleware({ kind: "scope", scope: "billing:read" });
let captured: RbacError | undefined;
try {
await mw(makeCtx({ principal: null }), async () => OK);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.NO_PRINCIPAL);
expect(statusForRbacError(captured as RbacError)).toBe(401);
});
it("null principal on rule-spec route also returns 401", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r = condition("x", "equals", "y");
const mw = makeMiddleware({ kind: "rule", rule: r }, { engine });
let captured: RbacError | undefined;
try {
await mw(makeCtx({ principal: null }), async () => OK);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.NO_PRINCIPAL);
});
});
describe("createRbacMiddleware — public arm", () => {
it("public route bypass", { timeout: 5000 }, async () => {
const mw = makeMiddleware({ kind: "public" });
const res = await mw(makeCtx({ principal: null }), async () => OK);
expect(res.status).toBe(200);
});
});
describe("createRbacMiddleware — defaults", () => {
it("uses createRbacInstruments() when no instruments passed", {
timeout: 5000,
}, async () => {
// Constructed via the default factory (global meter provider).
const mw = createRbacMiddleware({
routeFor: () => ({
plugin: "billing",
path: "/p",
auth: { kind: "public" },
}),
});
const res = await mw(makeCtx(), async () => OK);
expect(res.status).toBe(200);
});
});
describe("statusForRbacError", () => {
it("maps every code", { timeout: 5000 }, () => {
expect(
statusForRbacError(new RbacError(RBAC_CODES.NO_PRINCIPAL, "x"))
).toBe(401);
expect(
statusForRbacError(new RbacError(RBAC_CODES.MISSING_SCOPE, "x"))
).toBe(403);
expect(
statusForRbacError(new RbacError(RBAC_CODES.RULE_DENY, "x"))
).toBe(403);
expect(
statusForRbacError(new RbacError(RBAC_CODES.INVALID_SPEC, "x"))
).toBe(500);
expect(
statusForRbacError(new RbacError(RBAC_CODES.SCOPE_CONFLICT, "x"))
).toBe(500);
});
});

View File

@@ -0,0 +1,207 @@
import type { Middleware } from "@suaveplan/api-types/router";
import type { RuleEngine } from "@suaveplan/rules";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
import type { RbacInstruments } from "../instruments/instruments.js";
import {
createRbacInstruments,
recordRbacDenial,
} from "../instruments/instruments.js";
import type { RouteAuthSpec } from "../spec/spec.js";
/**
* Minimal shape the middleware needs from the request context. Defined
* structurally so the middleware works against both the abstract
* `RequestContext` and any adapter-specific superset.
*/
export interface RbacRequestContext {
readonly request: Request;
readonly principal:
| import("@suaveplan/api-types/principal").Principal
| null;
readonly tenantId?:
| import("@suaveplan/api-types/tenant").TenantId
| null
| undefined;
}
/**
* Per-request route descriptor consumed by the middleware. The router
* adapter supplies this — typically derived from the matched route — via
* the `routeFor` callback on {@link RbacMiddlewareOptions}.
*/
export interface RbacRoute {
/** Plugin owning the route — used as the `plugin` denial label. */
readonly plugin: string;
/** Route path / template — used as the `route` denial label. */
readonly path: string;
/** Canonical authorisation spec for the route. */
readonly auth: RouteAuthSpec;
}
/** Options accepted by {@link createRbacMiddleware}. */
export interface RbacMiddlewareOptions {
/**
* `@suaveplan/rules` engine used for `kind: "rule"` specs. Optional;
* when omitted, rule-spec routes throw `RBAC.INVALID_SPEC` at request
* time. Production hosts always supply one.
*/
readonly rules?: RuleEngine;
/**
* Resolver from a request context to the matched route descriptor.
* The router adapter knows the matched route; this callback bridges
* the gap so the middleware itself stays framework-agnostic.
*/
readonly routeFor: (ctx: RbacRequestContext) => RbacRoute;
/**
* OTel instruments registry. Defaults to a fresh registry built from
* the global `MeterProvider`; tests inject a real `MeterProvider`.
*/
readonly instruments?: RbacInstruments;
}
/** Status code attached to {@link RbacError} for adapter mapping. */
export function statusForRbacError(error: RbacError): 401 | 403 | 500 {
switch (error.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:
case RBAC_CODES.SCOPE_CONFLICT:
return 500;
}
}
/**
* Produce the RBAC `Middleware`. The middleware:
* 1. Resolves the matched route via `routeFor(ctx)`.
* 2. On `kind: "public"` — calls `next()` directly. No metric.
* 3. On `principal === null` (any non-public spec) — throws
* `RbacError(NO_PRINCIPAL, …)` AND increments
* `api.rbac.denials_total{reason="no-principal"}`.
* 4. On `kind: "scope"` — checks `principal.scopes`. On miss throws
* `RbacError(MISSING_SCOPE, …)` AND increments
* `api.rbac.denials_total{reason="missing-scope"}`.
* 5. On `kind: "rule"` — evaluates via the supplied `RuleEngine`. On
* `passed === false` throws `RbacError(RULE_DENY, …)` AND
* increments `api.rbac.denials_total{reason="rule-deny"}`.
*
* The middleware does not write a `Response` directly — it throws
* `RbacError` and lets the host's exception filter convert it to the
* canonical error envelope. Adapters that want a non-throwing path use
* {@link statusForRbacError} to map the code.
*/
export function createRbacMiddleware(
options: RbacMiddlewareOptions
): Middleware {
const instruments = options.instruments ?? createRbacInstruments();
const ruleEngine = options.rules;
return async function rbacMiddleware(ctx, next): Promise<Response> {
const route = options.routeFor(ctx);
if (route.auth.kind === "public") {
return next();
}
if (ctx.principal === null) {
recordRbacDenial(instruments, {
plugin: route.plugin,
route: route.path,
reason: "no-principal",
});
throw new RbacError(
RBAC_CODES.NO_PRINCIPAL,
"non-public route requires an authenticated principal",
{
context: { plugin: route.plugin, route: route.path },
}
);
}
if (route.auth.kind === "scope") {
const needed = route.auth.scope;
const has = ctx.principal.scopes.some(
(s) => (s as string) === needed
);
if (has) return next();
recordRbacDenial(instruments, {
plugin: route.plugin,
route: route.path,
reason: "missing-scope",
});
throw new RbacError(
RBAC_CODES.MISSING_SCOPE,
`principal lacks required scope '${needed}'`,
{
context: {
plugin: route.plugin,
route: route.path,
scope: needed,
},
}
);
}
// kind === "rule"
if (ruleEngine === undefined) {
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"route declares a rule-based auth spec but no RuleEngine was supplied to createRbacMiddleware",
{
context: { plugin: route.plugin, route: route.path },
}
);
}
const url = new URL(ctx.request.url);
const queryParams: Record<string, string> = {};
for (const [k, v] of url.searchParams) queryParams[k] = v;
const tenantStr =
ctx.tenantId === null || ctx.tenantId === undefined
? undefined
: (ctx.tenantId as string);
const result = await ruleEngine.evaluate(route.auth.rule, {
data: {
principal: {
id: ctx.principal.id as string,
scopes: ctx.principal.scopes.map((s) => s as string),
claims: ctx.principal.claims,
},
tenantId: ctx.tenantId ?? null,
route: { plugin: route.plugin, path: route.path },
},
user: {
id: ctx.principal.id as string,
roles: ctx.principal.scopes.map((s) => s as string),
},
...(tenantStr === undefined ? {} : { tenant: tenantStr }),
request: {
method: ctx.request.method,
path: url.pathname,
query: queryParams,
},
});
if (result.passed) return next();
recordRbacDenial(instruments, {
plugin: route.plugin,
route: route.path,
reason: "rule-deny",
});
throw new RbacError(
RBAC_CODES.RULE_DENY,
"rule-based authorisation denied the request",
{
context: {
plugin: route.plugin,
route: route.path,
errors: result.errors.map((e) => e.message),
},
}
);
};
}

View File

@@ -0,0 +1,5 @@
export {
type RegisteredScope,
registerPluginScopes,
ScopeRegistry,
} from "./registry.js";

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,136 @@
import type { PluginManifest } from "@suaveplan/api-types/manifest";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
import { registerPluginScopes, ScopeRegistry } from "./registry.js";
function manifest(
name: string,
scopes: ReadonlyArray<{ name: string; description?: string }>
): Pick<PluginManifest, "name" | "scopes"> {
return { name, scopes };
}
describe("ScopeRegistry — register + lookup", () => {
it("registers a scope and reports presence", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
r.register({ name: "billing:read", plugin: "billing" });
expect(r.has("billing:read")).toBe(true);
expect(r.get("billing:read")).toEqual({
name: "billing:read",
plugin: "billing",
});
});
it("list returns a snapshot array", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
r.register({ name: "a", plugin: "p" });
r.register({ name: "b", plugin: "p" });
const list = r.list();
expect(list.map((s) => s.name).sort()).toEqual(["a", "b"]);
});
it("empty scope name throws RBAC.INVALID_SPEC", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
let captured: RbacError | undefined;
try {
r.register({ name: "", plugin: "p" });
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
it("unregisterPlugin removes only that plugin's scopes", {
timeout: 5000,
}, () => {
const r = new ScopeRegistry();
r.register({ name: "a", plugin: "p1" });
r.register({ name: "b", plugin: "p2" });
r.unregisterPlugin("p1");
expect(r.has("a")).toBe(false);
expect(r.has("b")).toBe(true);
});
});
describe("ScopeRegistry — conflict handling", () => {
it("duplicate scope across plugins raises RBAC_SCOPE_CONFLICT", {
timeout: 5000,
}, () => {
const r = new ScopeRegistry();
registerPluginScopes(
r,
manifest("billing", [{ name: "billing:read" }])
);
let captured: RbacError | undefined;
try {
registerPluginScopes(
r,
manifest("invoices", [{ name: "billing:read" }])
);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.SCOPE_CONFLICT);
const ctx = captured?.context as Record<string, unknown> | undefined;
expect(ctx?.["conflictingPlugin"]).toBe("billing");
});
it("re-register for same plugin is idempotent", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
const m = manifest("billing", [
{ name: "billing:read", description: "Read" },
]);
const first = registerPluginScopes(r, m);
expect(first).toBe(1);
const second = registerPluginScopes(r, m);
expect(second).toBe(0);
expect(r.has("billing:read")).toBe(true);
});
it("same plugin changing description is treated as a conflict", {
timeout: 5000,
}, () => {
const r = new ScopeRegistry();
r.register({
name: "billing:read",
plugin: "billing",
description: "v1",
});
let captured: RbacError | undefined;
try {
r.register({
name: "billing:read",
plugin: "billing",
description: "v2",
});
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.SCOPE_CONFLICT);
});
it("missing manifest.scopes is a no-op", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
const n = registerPluginScopes(r, { name: "x" });
expect(n).toBe(0);
});
it("registerPluginScopes propagates description", { timeout: 5000 }, () => {
const r = new ScopeRegistry();
registerPluginScopes(
r,
manifest("billing", [
{ name: "billing:read", description: "Read access" },
])
);
expect(r.get("billing:read")?.description).toBe("Read access");
});
it("registerPluginScopes preserves omitted description as undefined", {
timeout: 5000,
}, () => {
const r = new ScopeRegistry();
registerPluginScopes(r, manifest("p", [{ name: "p:x" }]));
expect(r.get("p:x")?.description).toBeUndefined();
});
});

View File

@@ -0,0 +1,127 @@
import type { PluginManifest } from "@suaveplan/api-types/manifest";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
/**
* Internal descriptor of a registered scope. The owning plugin name is
* tracked so re-registration by the same plugin (e.g. on a hot reload)
* is a no-op while two different plugins claiming the same scope name
* raise `RBAC.SCOPE_CONFLICT`.
*/
export interface RegisteredScope {
readonly name: string;
readonly plugin: string;
readonly description?: string;
}
/**
* Conflict-aware registry of plugin-declared scopes.
*
* Plugins declare their scope vocabulary in `manifest.scopes[]`. At
* install time the host calls {@link registerPluginScopes} which
* iterates the manifest and registers each entry against the shared
* registry. Two plugins claiming the same scope name raise an
* `RBAC.SCOPE_CONFLICT`; the same plugin re-registering an
* identical descriptor is a no-op so hot reload is safe.
*
* The registry is intentionally not used as the source-of-truth for
* the gate decision — `principal.scopes` is. The registry exists to
* (a) detect typos and conflicts at install time, (b) drive admin UI
* listings of available scopes, and (c) gate the OpenAPI emitter so
* an undeclared scope on a route raises a startup error.
*/
export class ScopeRegistry {
private readonly byName = new Map<string, RegisteredScope>();
/**
* Register a single scope. Idempotent for `(plugin, name, description)`
* tuples that are byte-identical to the existing entry; otherwise
* raises `RBAC.SCOPE_CONFLICT` carrying the conflicting plugin id
* in `error.context.conflictingPlugin`.
*/
public register(scope: RegisteredScope): void {
if (scope.name.length === 0) {
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"scope name must be non-empty",
{ context: { plugin: scope.plugin } }
);
}
const existing = this.byName.get(scope.name);
if (existing === undefined) {
this.byName.set(scope.name, scope);
return;
}
if (
existing.plugin === scope.plugin &&
existing.description === scope.description
) {
// Idempotent re-register from the same plugin.
return;
}
throw new RbacError(
RBAC_CODES.SCOPE_CONFLICT,
`scope '${scope.name}' is already claimed by plugin '${existing.plugin}'`,
{
context: {
scope: scope.name,
incoming: scope.plugin,
conflictingPlugin: existing.plugin,
},
}
);
}
/** Return `true` iff the scope name is registered. */
public has(name: string): boolean {
return this.byName.has(name);
}
/** Look up a registered scope descriptor by name. */
public get(name: string): RegisteredScope | undefined {
return this.byName.get(name);
}
/** Snapshot iterator over every registered scope. */
public list(): readonly RegisteredScope[] {
return Array.from(this.byName.values());
}
/**
* Remove every scope owned by `plugin`. Called by the host when a
* plugin is uninstalled so the next install of a different plugin
* may legitimately claim the same scope name.
*/
public unregisterPlugin(plugin: string): void {
for (const [name, scope] of this.byName) {
if (scope.plugin === plugin) this.byName.delete(name);
}
}
}
/**
* Walk `manifest.scopes[]` and register each entry against `registry`.
* Returns the count of newly-registered (i.e. non-idempotent) entries.
*
* @throws `RbacError(SCOPE_CONFLICT)` on the first cross-plugin conflict.
*/
export function registerPluginScopes(
registry: ScopeRegistry,
manifest: Pick<PluginManifest, "name" | "scopes">
): number {
const declared = manifest.scopes ?? [];
let added = 0;
for (const entry of declared) {
const before = registry.has(entry.name);
const descriptor: RegisteredScope =
entry.description === undefined
? { name: entry.name, plugin: manifest.name }
: {
name: entry.name,
plugin: manifest.name,
description: entry.description,
};
registry.register(descriptor);
if (!before) added++;
}
return added;
}

View File

@@ -0,0 +1,7 @@
export {
isRouteAuthSpec,
normaliseRouteAuth,
type RouteAuthInput,
type RouteAuthSpec,
RouteAuthSpecSchema,
} from "./spec.js";

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.

View File

@@ -0,0 +1,116 @@
import { condition } from "@suaveplan/rules";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
import {
isRouteAuthSpec,
normaliseRouteAuth,
RouteAuthSpecSchema,
} from "./spec.js";
describe("normaliseRouteAuth", () => {
it("null → public", { timeout: 5000 }, () => {
expect(normaliseRouteAuth(null)).toEqual({ kind: "public" });
});
it("undefined → public", { timeout: 5000 }, () => {
expect(normaliseRouteAuth(undefined)).toEqual({ kind: "public" });
});
it("string → scope", { timeout: 5000 }, () => {
expect(normaliseRouteAuth("billing:read")).toEqual({
kind: "scope",
scope: "billing:read",
});
});
it("empty string throws INVALID_SPEC", { timeout: 5000 }, () => {
let captured: RbacError | undefined;
try {
normaliseRouteAuth("");
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
it("RouteAuthSpec passes through with validation", {
timeout: 5000,
}, () => {
expect(normaliseRouteAuth({ kind: "public" })).toEqual({
kind: "public",
});
expect(normaliseRouteAuth({ kind: "scope", scope: "x" })).toEqual({
kind: "scope",
scope: "x",
});
});
it("invalid spec object throws INVALID_SPEC", { timeout: 5000 }, () => {
let captured: RbacError | undefined;
try {
// kind="scope" without scope key
normaliseRouteAuth({ kind: "scope" } as unknown as {
kind: "scope";
scope: string;
});
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
it("{ rule } wrapper folds to rule spec", { timeout: 5000 }, () => {
const r = condition("x", "equals", "y");
const out = normaliseRouteAuth({ rule: r });
expect(out).toEqual({ kind: "rule", rule: r });
});
it("unknown shape throws INVALID_SPEC", { timeout: 5000 }, () => {
let captured: RbacError | undefined;
try {
normaliseRouteAuth({ foo: "bar" } as unknown as null);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
it("non-string/object/null input throws INVALID_SPEC", {
timeout: 5000,
}, () => {
let captured: RbacError | undefined;
try {
normaliseRouteAuth(42 as unknown as null);
} catch (err) {
if (err instanceof RbacError) captured = err;
}
expect(captured?.code).toBe(RBAC_CODES.INVALID_SPEC);
});
it("rule kind without rule field rejected by schema", {
timeout: 5000,
}, () => {
const out = RouteAuthSpecSchema.safeParse({ kind: "rule" });
expect(out.success).toBe(false);
});
it("public with excess keys rejected", { timeout: 5000 }, () => {
const out = RouteAuthSpecSchema.safeParse({
kind: "public",
extra: 1,
});
expect(out.success).toBe(false);
});
});
describe("isRouteAuthSpec", () => {
it("accepts a valid scope spec", { timeout: 5000 }, () => {
expect(isRouteAuthSpec({ kind: "scope", scope: "x" })).toBe(true);
});
it("rejects a garbage value", { timeout: 5000 }, () => {
expect(isRouteAuthSpec({})).toBe(false);
expect(isRouteAuthSpec(null)).toBe(false);
expect(isRouteAuthSpec("string")).toBe(false);
});
});

View File

@@ -0,0 +1,125 @@
import type { Rule } from "@suaveplan/rules";
import z from "zod";
import { RBAC_CODES, RbacError } from "../error-codes/error-codes.js";
/**
* Discriminated `RouteAuthSpec` union — the canonical shape every router
* adapter translates the user-facing route-option `auth` value into before
* invoking the RBAC middleware.
*
* Three kinds:
* - `"public"` — bypass; no principal check, no scope check, no metric.
* - `"scope"` — single string scope name; default contains-check against
* `principal.scopes`.
* - `"rule"` — `@suaveplan/rules` `Rule`; evaluated against a context
* derived from the request, principal, tenant, and route.
*/
export type RouteAuthSpec =
| { readonly kind: "public" }
| { readonly kind: "scope"; readonly scope: string }
| { readonly kind: "rule"; readonly rule: Rule };
/**
* Inbound `auth` shapes recognised by {@link normaliseRouteAuth}.
*
* The router adapter receives one of these on `route.options.auth` and
* the helper folds them all down to the canonical {@link RouteAuthSpec}.
*
* - `null` / `undefined` → `{ kind: "public" }`.
* - `string` → `{ kind: "scope", scope }`.
* - already-normalised `RouteAuthSpec` → identity.
* - `{ rule }` → `{ kind: "rule", rule }`.
*/
export type RouteAuthInput =
| null
| undefined
| string
| RouteAuthSpec
| { readonly rule: Rule };
/** Zod schema for a serialised scope-only auth spec. */
const ScopeSpecSchema = z
.object({
kind: z.literal("scope"),
scope: z.string().min(1, "scope must be non-empty"),
})
.strict();
/** Zod schema for a serialised public-route auth spec. */
const PublicSpecSchema = z
.object({
kind: z.literal("public"),
})
.strict();
/**
* Zod schema for the rule-bearing auth spec. The `Rule` shape itself is
* not re-validated here — `@suaveplan/rules` owns that contract — but the
* schema enforces presence and shape of the `rule` slot so callers cannot
* silently smuggle `undefined` past the gate.
*/
const RuleSpecSchema = z
.object({
kind: z.literal("rule"),
rule: z.object({ type: z.string() }).passthrough(),
})
.strict();
/**
* Boundary Zod schema for {@link RouteAuthSpec}. Adapters validating an
* inbound spec object (e.g. loaded from a manifest or admin API) parse
* with this schema so malformed shapes raise `RBAC.INVALID_SPEC` rather
* than producing undefined behaviour at gate time.
*/
export const RouteAuthSpecSchema = z.discriminatedUnion("kind", [
PublicSpecSchema,
ScopeSpecSchema,
RuleSpecSchema,
]);
/**
* Fold every recognised `auth` input shape down to the canonical
* {@link RouteAuthSpec}. The function is total over {@link RouteAuthInput}
* and throws `RbacError(INVALID_SPEC, ...)` for any other shape.
*/
export function normaliseRouteAuth(input: RouteAuthInput): RouteAuthSpec {
if (input === null || input === undefined) {
return { kind: "public" };
}
if (typeof input === "string") {
if (input.length === 0) {
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"auth string must be non-empty"
);
}
return { kind: "scope", scope: input };
}
if (typeof input === "object" && "kind" in input) {
const parsed = RouteAuthSpecSchema.safeParse(input);
if (!parsed.success) {
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"RouteAuthSpec failed Zod validation",
{ cause: parsed.error }
);
}
return parsed.data as RouteAuthSpec;
}
if (typeof input === "object" && "rule" in input) {
return { kind: "rule", rule: input.rule };
}
throw new RbacError(
RBAC_CODES.INVALID_SPEC,
"auth value is neither a scope string, a RouteAuthSpec, nor a { rule } wrapper"
);
}
/**
* Type-guard narrowing an arbitrary value to {@link RouteAuthSpec}. Useful
* at admin-API boundaries that accept JSON and need to feed the value into
* a strongly-typed registry.
*/
export function isRouteAuthSpec(value: unknown): value is RouteAuthSpec {
return RouteAuthSpecSchema.safeParse(value).success;
}

View File

@@ -0,0 +1,161 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_DIR = resolve(__dirname, "..");
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
exports?: Record<string, unknown>;
};
const ALLOWED_DEPS = new Set([
"@opentelemetry/api",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/logger",
"@suaveplan/rules",
"tslib",
"zod",
]);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) {
if (name === "node_modules" || name === "dist") continue;
walk(p, out);
} else {
out.push(p);
}
}
return out;
}
describe("dep-graph", () => {
it("dependencies match the allowlist", { timeout: 5000 }, () => {
const deps = Object.keys(pkg.dependencies ?? {});
for (const d of deps) {
expect(ALLOWED_DEPS.has(d)).toBe(true);
}
for (const expected of [
"@opentelemetry/api",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/logger",
"@suaveplan/rules",
"tslib",
"zod",
]) {
expect(deps).toContain(expected);
}
});
it("subpath exports cover every feature", { timeout: 5000 }, () => {
const exports = pkg.exports ?? {};
const expected = [
".",
"./middleware",
"./registry",
"./spec",
"./error-codes",
"./instruments",
];
for (const e of expected) {
expect(Object.keys(exports)).toContain(e);
}
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const srcDir = join(PKG_DIR, "src");
const all = walk(srcDir);
const missing: string[] = [];
for (const file of all) {
if (!file.endsWith(".ts")) continue;
if (file.endsWith(".test.ts")) continue;
if (file.endsWith(".spec.ts")) continue;
if (file.endsWith(".spec-conformance.test.ts")) continue;
if (file.endsWith(".types.ts")) continue;
const base = file.replace(/\.ts$/, "");
const fileName = base.split("/").pop();
if (fileName === "index") continue;
if (file === join(srcDir, "index.ts")) continue;
const md = `${base}.md`;
try {
statSync(md);
} catch {
missing.push(md.replace(`${PKG_DIR}/`, ""));
}
}
expect(missing).toEqual([]);
});
it("no biome suppressions in src/", { timeout: 5000 }, () => {
const all = walk(join(PKG_DIR, "src"));
const hits: string[] = [];
for (const f of all) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
const txt = readFileSync(f, "utf8");
if (txt.includes("biome-ignore")) hits.push(f);
if (txt.includes("eslint-disable")) hits.push(f);
}
expect(hits).toEqual([]);
});
it("no banned patterns in src/", { timeout: 5000 }, () => {
const banned = [
"TODO",
"FIXME",
"ts-ignore",
"ts-expect-error",
"@deprecated",
"console.log",
];
const all = walk(join(PKG_DIR, "src"));
const hits: { file: string; pattern: string }[] = [];
for (const f of all) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
const txt = readFileSync(f, "utf8");
for (const p of banned) {
if (txt.includes(p)) hits.push({ file: f, pattern: p });
}
}
expect(hits).toEqual([]);
});
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
const featureBarrels = readdirSync(join(PKG_DIR, "src"))
.filter((entry) => {
const p = join(PKG_DIR, "src", entry);
try {
return statSync(p).isDirectory();
} catch {
return false;
}
})
.map((d) => join(PKG_DIR, "src", d, "index.ts"));
const hits: string[] = [];
for (const b of featureBarrels) {
try {
const txt = readFileSync(b, "utf8");
if (/^export\s+\*/m.test(txt)) hits.push(b);
} catch {
/* directory may have no index.ts */
}
}
expect(hits).toEqual([]);
});
});
describe("layout invariants", () => {
it("error-codes lives at src/error-codes/error-codes.ts", {
timeout: 5000,
}, () => {
const inner = join(PKG_DIR, "src", "error-codes", "error-codes.ts");
statSync(inner);
expect(dirname(inner).endsWith("/error-codes")).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-rbac",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: false,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
external: [
"@opentelemetry/api",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/logger",
"@suaveplan/rules",
"tslib",
"zod",
],
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();