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,118 @@
# @suaveplan/api-host - Host orchestration core for the Suaveplan API plugin system. Provides the stable error-code registry (HOST/PLUGIN/RELOAD/CAPABILITY/ADMIN/LEDGER), the author-facing definePlugin DX, the runtime CapabilityRegistry, the LifecycleDriver + ReloadDriver + PluginLedger + admin Hono sub-router, the ErrorBoundary, the §6 11-step RequestPipeline, the top-level createHost(config) entry point composing every internal driver, and the HostInstruments registry covering the §12.3 metric surface. The ./testing subpath is reserved.
> host orchestration core for the Suaveplan API plugin system.
This package is the **keystone** of the API plugin system. Every downstream package either consumes `api-host` directly (worker proxies talk to it) or extends it (adapters register with its `CapabilityRegistry`).
<!-- begin:auto-docs -->
## Documentation tree
- [admin-api](./src/admin-api/admin-api.md) — `createAdminApi` builds the host-side Hono sub-router that exposes the design §8.4 admin surface — every operational en…
- [admin-api](./src/admin-api/admin-api.md) — `createAdminApi` builds the host-side Hono sub-router that exposes the design §8.4 admin surface — every operational en…
- [audit-sink](./src/admin-api/audit-sink.md) — `MemoryAdminAuditSink` is the in-memory default audit sink for the host's admin API. Every admin endpoint emits a struc…
- [idempotency-store](./src/admin-api/idempotency-store.md) — `InMemoryIdempotencyStore` is the process-local default for the admin API's `Idempotency-Key` enforcement. Mutating adm…
- [rate-limiter](./src/admin-api/rate-limiter.md) — `AdminRateLimiter` enforces the design §8.4 per-principal rate limits on every admin endpoint. Read endpoints share a 6…
- [capability-registry](./src/capability-registry/capability-registry.md) — The `CapabilityRegistry` is the live, single-instance store of every capability adapter wired into a host process. Stag…
- [capability-registry](./src/capability-registry/capability-registry.md) — The `CapabilityRegistry` is the live, single-instance store of every capability adapter wired into a host process. Stag…
- [define-plugin](./src/define-plugin/define-plugin.md) — `definePlugin` is the canonical entry point plugin authors call to declare their `name` / `version` / `requires` / `iso…
- [define-plugin](./src/define-plugin/define-plugin.md) — `definePlugin` is the canonical entry point plugin authors call to declare their `name` / `version` / `requires` / `iso…
- [empty-secrets](./src/empty-secrets/empty-secrets.md) — `EmptySecrets` is the no-op default `SecretsLike` implementation the host injects into every `PluginContext` / `Request…
- [empty-secrets](./src/empty-secrets/empty-secrets.md) — `EmptySecrets` is the no-op default `SecretsLike` implementation the host injects into every `PluginContext` / `Request…
- [error-boundary](./src/error-boundary/error-boundary.md) — The `ErrorBoundary` wraps every step of the per-request pipeline (design doc §6 steps 1–11) and projects every captured…
- [error-boundary](./src/error-boundary/error-boundary.md) — The `ErrorBoundary` wraps every step of the per-request pipeline (design doc §6 steps 1–11) and projects every captured…
- [error-codes](./src/error-codes/error-codes.md) — This module owns every stable error code raised by `@suaveplan/api-host` and its sub-drivers (lifecycle, hot reload, ca…
- [error-codes](./src/error-codes/error-codes.md) — This module owns every stable error code raised by `@suaveplan/api-host` and its sub-drivers (lifecycle, hot reload, ca…
- [host](./src/host/host.md) — `createHost(config)` composes every internal driver (`LifecycleDriver`, `ReloadDriver`, `PluginLedger`, `CapabilityRegi…
- [host](./src/host/host.md) — `createHost(config)` composes every internal driver (`LifecycleDriver`, `ReloadDriver`, `PluginLedger`, `CapabilityRegi…
- [hot-reload](./src/hot-reload/hot-reload.md) — `ReloadDriver` implements the §8.1 blue-green hot-reload primitive plus the §8.1a `RollbackJournal` integration. Each `…
- [file-watcher](./src/hot-reload/file-watcher.md) — `FileWatcher` is the dev-mode file-system watch primitive consumed by `ReloadDriver`'s §8.3 trigger path. Each `registe…
- [hot-reload](./src/hot-reload/hot-reload.md) — `ReloadDriver` implements the §8.1 blue-green hot-reload primitive plus the §8.1a `RollbackJournal` integration. Each `…
- [lifecycle](./src/lifecycle/lifecycle.md) — `LifecycleDriver` is the runtime executor for the per-plugin lifecycle FSM defined in `@suaveplan/api-lifecycle`. It ow…
- [lifecycle](./src/lifecycle/lifecycle.md) — `LifecycleDriver` is the runtime executor for the per-plugin lifecycle FSM defined in `@suaveplan/api-lifecycle`. It ow…
- [otel](./src/otel/otel.md) — `createHostInstruments` builds the frozen bundle of every OpenTelemetry counter and histogram the host emits on a produ…
- [otel](./src/otel/otel.md) — `createHostInstruments` builds the frozen bundle of every OpenTelemetry counter and histogram the host emits on a produ…
- [in-memory-bus](./src/pipeline-events-bus/in-memory-bus.md) — `InMemoryPipelineEventsBus` is the canonical fixture wired by `createTestHost` (test keystone) and consumed by every…
- [noop-bus](./src/pipeline-events-bus/noop-bus.md) — `NoopPipelineEventsBus` is the default value the host installs on `HostAdapters.pipelineEvents` when no operator-suppli…
- [pipeline-events-constants](./src/pipeline-events-bus/pipeline-events-constants.md) — `@suaveplan/api-types` is types-only — adding a runtime export there breaks the byte-size invariant CI enforces. The `P…
- [plugin-ledger](./src/plugin-ledger/plugin-ledger.md) — `PluginLedger` is the host-owned metadata store backing every plugin record the API host orchestrates. It persists the…
- [migrations](./src/plugin-ledger/migrations.md) — `HostMigrationRunner` is the forward-only idempotent migration engine the API host runs against its plugin ledger DB. I…
- [plugin-ledger](./src/plugin-ledger/plugin-ledger.md) — `PluginLedger` is the host-owned metadata store backing every plugin record the API host orchestrates. It persists the…
- [request-pipeline](./src/request-pipeline/request-pipeline.md) — `RequestPipeline` implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resol…
- [request-pipeline-driver](./src/request-pipeline/request-pipeline-driver.md) — This module exports the `runPipeline` function: the orchestrator that chains the §6 step gates (`request-pipeline-steps…
- [request-pipeline-emitters](./src/request-pipeline/request-pipeline-emitters.md) — This module owns the three side-effecting closers the §6 pipeline calls once a request has reached its outcome: `emitAu…
- [request-pipeline-helpers](./src/request-pipeline/request-pipeline-helpers.md) — This module concentrates the pure (no-`this`, no side-effecting) helpers the §6 request pipeline relies on: request-id…
- [request-pipeline-outcomes](./src/request-pipeline/request-pipeline-outcomes.md) — This module ties the bare emitters and validators together for the orchestrator. It exposes `DriverConfig` (the frozen…
- [request-pipeline-stage-emit](./src/request-pipeline/request-pipeline-stage-emit.md) — This module wraps the `PipelineEventsBus.emit` calls the §6 11-step driver makes around every stage. The helper central…
- [request-pipeline-stage-skip](./src/request-pipeline/request-pipeline-stage-skip.md) — The §6 11-step request pipeline has four stages (`csrf`, `capability`, `idempotency`, `audit`) that may be conditionall…
- [request-pipeline-steps](./src/request-pipeline/request-pipeline-steps.md) — This module hosts the six per-request gate functions the §6 pipeline runs after route match and before plugin handler i…
- [request-pipeline-validation](./src/request-pipeline/request-pipeline-validation.md) — This module owns the two Zod-validation functions the §6 pipeline runs: `validateRequest` (step 6) parses body / query…
- [request-pipeline](./src/request-pipeline/request-pipeline.md) — `RequestPipeline` implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resol…
- [reserved-prefix](./src/reserved-prefix/reserved-prefix.md) — The reserved-prefix contract publishes the canonical list of URL path prefixes that `@suaveplan/api-host` (and every fi…
- [reserved-prefix](./src/reserved-prefix/reserved-prefix.md) — The reserved-prefix contract publishes the canonical list of URL path prefixes that `@suaveplan/api-host` (and every fi…
- [testing](./src/testing/testing.md) — `@suaveplan/api-host/testing` is a **reserved subpath** in API plugin system. The intent is to popul…
- [create-test-host](./src/testing/create-test-host/create-test-host.md) — `createTestHost` is The keystone of the `@suaveplan/api-host/testing` subpath. It composes every in-memory adap…
- [create-test-host](./src/testing/create-test-host/create-test-host.md) — `createTestHost` is The keystone of the `@suaveplan/api-host/testing` subpath. It composes every in-memory adap…
- [pipeline-events-conformance](./src/testing/pipeline-events-conformance/pipeline-events-conformance.md) — This harness drives a host factory's `RequestPipeline` through a known-shape happy-path request and a known-shape 404 r…
- [pipeline-events-conformance](./src/testing/pipeline-events-conformance/pipeline-events-conformance.md) — This harness drives a host factory's `RequestPipeline` through a known-shape happy-path request and a known-shape 404 r…
- [testing](./src/testing/testing.md) — `@suaveplan/api-host/testing` is a **reserved subpath** in API plugin system. The intent is to popul…
<!-- end:auto-docs -->
## Chunk status
`@suaveplan/api-host` ships in **chunks** per the precedent set by the `@suaveplan/pdf` migration. Chunk A — this release — ships the pieces every later chunk depends on:
| Subpath | Status | Description |
|---|---|---|
| `./error-codes` | ✅ Chunk A | Full union of `HOST_*` / `PLUGIN_*` / `RELOAD_*` / `CAPABILITY_*` / `ADMIN_*` codes + default status map + `HostError` class. Every later chunk's codes are pre-declared so the union is stable. |
| `./define-plugin` | ✅ Chunk A | `definePlugin<const Caps>(spec)` author DX + `PluginModule<Caps>` + derived context types. |
| `./capability-registry` | ✅ Chunk A | Runtime `CapabilityRegistry` with semver-shape validation, duplicate detection, `assertSatisfies` compat check, reverse-order disposers. `defineCapability(...)` helper. |
| `./otel` | ✅ Chunk A | `createHostInstruments` + `HOST_COUNTER_NAMES` (12) + `HOST_HISTOGRAM_NAMES` (4) covering the §12.3 surface; per-plugin meter cache. |
| `./testing` | ✅ Test tier | Populated with `createTestHost(config?)` — composes every test in-memory adapter into a running `Host` in one line. The legacy `STAGE_2_RESERVED` sentinel flips to `false`. The `runPipelineRouterConformance` harness still ships from this subpath. |
| `./lifecycle` | ⏳ Chunk B | `LifecycleDriver` — per-plugin FSM mutex, every §4 transition, auto-rollback hand-off, worker-tier `crashed` auto-restart. |
| `./hot-reload` | ⏳ Chunk B | `ReloadDriver` — blue-green primitive, `RollbackJournal` ring buffer, drain timer, native-binding guard. |
| `./plugin-ledger` | ⏳ Chunk C | Host-owned metadata DB + forward-only idempotent migrations. |
| `./admin-api` | ⏳ Chunk C | Hono sub-router for §8.4 endpoints + RBAC + rate-limit + audit envelope. |
| `./error-boundary` | ⏳ Chunk D | Per-step boundary mapping `SuaveplanError → manifest statusMap`, `AbortError → 499`. |
| `./request-pipeline` | ⏳ Chunk D | Steps 1–11 from §6 in documented order + CSRF gating + streaming bypass. |
| `./host` | ⏳ Chunk D | Top-level `createHost(config)` composing every driver. |
Until the follow-up chunks ship, this package is **not** a runnable host — it is the contract surface every downstream package can compile and link against.
## Modules
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) — error-code registry.
- [`src/define-plugin/define-plugin.md`](src/define-plugin/define-plugin.md) — author-facing plugin entry point.
- [`src/capability-registry/capability-registry.md`](src/capability-registry/capability-registry.md) — runtime capability registry.
- [`src/lifecycle/lifecycle.md`](src/lifecycle/lifecycle.md) — `LifecycleDriver` driving every §4 transition.
- [`src/hot-reload/hot-reload.md`](src/hot-reload/hot-reload.md) — `ReloadDriver` blue-green primitive (§8.1).
- [`src/hot-reload/file-watcher.md`](src/hot-reload/file-watcher.md) — debounced filesystem watcher.
- [`src/plugin-ledger/plugin-ledger.md`](src/plugin-ledger/plugin-ledger.md) — SQLite-backed host metadata store.
- [`src/plugin-ledger/migrations.md`](src/plugin-ledger/migrations.md) — forward-only migration runner.
- [`src/admin-api/admin-api.md`](src/admin-api/admin-api.md) — Hono admin sub-router (§8.4).
- [`src/admin-api/audit-sink.md`](src/admin-api/audit-sink.md) — pluggable audit sink.
- [`src/admin-api/idempotency-store.md`](src/admin-api/idempotency-store.md) — Idempotency-Key store.
- [`src/admin-api/rate-limiter.md`](src/admin-api/rate-limiter.md) — admin token-bucket limiter.
- [`src/error-boundary/error-boundary.md`](src/error-boundary/error-boundary.md) — §12.7 pipeline-step throw containment.
- [`src/request-pipeline/request-pipeline.md`](src/request-pipeline/request-pipeline.md) — §6 11-step per-request driver.
- [`src/host/host.md`](src/host/host.md) — top-level `createHost(config)` composing every internal driver.
- [`src/otel/otel.md`](src/otel/otel.md) — host instrument registry.
- [`src/testing/testing.md`](src/testing/testing.md) — RESERVED subpath for test utilities.
## Conformance posture
`api-host` carries `suaveplan.conformanceSkip` for `api-lifecycle`, `api-loader`, `api-manifest`, and `api-secrets`. These four packages each export a `run*Conformance(factory)` harness expecting the consumer to PROVIDE a concrete implementation of the contract (`LifecycleDriverFactory`, `ManifestSchemaSet`, etc.). `api-host` is the orchestrator that DRIVES those contracts — it consumes the abstract types, composes them via `LifecycleDriver` / `ReloadDriver` / `RequestPipeline`, and threads `SecretsLike` through `ctx.secrets`. It does not implement the abstract types itself.
The `./testing` `createTestHost(...)` factory is the canonical proof that `api-host` composes the four contracts correctly. The `e2e/foundation-stack-test-host` Playwright suite drives every contract through `createTestHost` end-to-end and asserts on the observable behaviour (lifecycle install → start → handler invocation → secret resolution → ledger persistence). When the §16 `check-conformance-tests.ts` lint reports `api-host` as a consumer without conformance tests, the skip plus the e2e smoke is the documented answer.
If a future change extracts the conformance harnesses into adapter-agnostic forms (i.e., the harness signature shifts from `factory: () => Impl` to `factory: () => Driver`), the skip can be removed and the harness invoked through `createTestHost`'s adapter bag.
## Install
```bash
bun add @suaveplan/api-host
```
## License
MIT

View File

@@ -0,0 +1,127 @@
# `admin-api.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createAdminApi` builds the host-side Hono sub-router that exposes the
design §8.4 admin surface — every operational endpoint the host
publishes for plugin install / uninstall / start / stop / reload /
rollback / restart, plus the read views on plugin state, capabilities,
and health. The router is host-owned (not plugin-owned) so it short-
circuits the plugin pipeline entirely: requests resolve a principal,
enforce a `host:admin` scope, consume a per-principal rate-limit
bucket, dedupe via `Idempotency-Key`, dispatch the driver call, and
emit an audit envelope on every outcome.
The returned `Hono` instance is unmounted — the host bootstrap calls
`.route('/admin', adminApi)` onto its parent app when `adminPrefix` is
non-null, OR drops the router entirely when the operator configures
`adminPrefix: null` for sidecar-only deployments.
## Features
- Every design §8.4 endpoint (`GET /plugins`, `/plugins/:name`,
`/plugins/:name/journal`, `/capabilities`, `/capabilities/:id`,
`/health`; `POST /plugins/:name/{install,uninstall,start,stop,
reload,rollback,restart}`).
- Principal resolver port: deployments wire `@suaveplan/api-auth`'s
`SessionResolver` behind the narrow `AdminPrincipalResolver`
interface.
- `host:admin` scope enforced on every endpoint (configurable).
- Per-principal token-bucket rate limiter: 60 reads/min, 1
destructive/min by default.
- Mutating endpoints require `Idempotency-Key` — replays within the
TTL window raise `ADMIN.IDEMPOTENCY_KEY_REPLAY`.
- Audit envelope (`AdminAuditRecord`) emitted on every outcome
through the operator-supplied `AdminAuditSink`.
- OTel `host.admin_action_total{action, outcome[, plugin]}` counter +
`host.admin_call_duration_ms` histogram recorded per call.
- Zod-validated request bodies for `install`, `uninstall`, `reload`,
`rollback`.
## Usage: Basic
```ts
import { Hono } from "hono";
import {
createAdminApi,
MemoryAdminAuditSink,
} from "@suaveplan/api-host/admin-api";
const adminApi = createAdminApi({
lifecycle,
reload,
capabilities,
ledger,
instruments,
audit: new MemoryAdminAuditSink(),
resolver: {
resolve: (c) => ({ id: "alice", scopes: ["host:admin"] }),
},
});
const app = new Hono();
app.route("/admin", adminApi);
```
## Usage: Advanced
```ts
import { InMemoryIdempotencyStore } from "@suaveplan/api-host/admin-api";
const idempotency = new InMemoryIdempotencyStore();
const adminApi = createAdminApi({
/* ... */,
idempotencyStore: idempotency,
idempotencyTtlMs: 60_000,
readRatePerMinute: 120,
destructiveRatePerMinute: 5,
requiredScope: "host:admin",
health: async () => "ok",
});
// Drive a mutating endpoint with the required Idempotency-Key.
const res = await adminApi.request("/plugins/billing/start", {
method: "POST",
headers: { "Idempotency-Key": "op-001" },
});
```
## API Reference
- `createAdminApi(options): Hono` — constructs the router.
- `AdminApiOptions` — `{ lifecycle, reload, capabilities, ledger,
instruments, resolver, audit, requiredScope?, now?, health?,
idempotencyStore?, idempotencyTtlMs?, readRatePerMinute?,
destructiveRatePerMinute? }`.
- `AdminPrincipal` — narrow `{ id, scopes }` shape consumed by the
resolver.
- `AdminPrincipalResolver` — `{ resolve(ctx): Promise<AdminPrincipal |
null> | AdminPrincipal | null }`.
- `AdminAuditRecord` — `{ at, principal, action, plugin, params,
outcome, cause? }`.
- `AdminAuditSink` — `{ emit(record): Promise<void> | void }`.
## Implementation Notes
`withGuard` is the per-endpoint pre-handler. It runs in this order:
principal resolution → scope check → rate-limit consume → (mutating
only) idempotency-key check + record → driver dispatch → audit emit
→ metric record. The function is exhaustive in its error paths —
every failure shape produces an audit "failure" envelope so the audit
trail captures denied requests just like accepted ones.
The router never installs CSRF or body-size limits itself — those
are responsibilities of the parent app's middleware stack. The
router DOES enforce JSON content-type on mutating endpoints (any
mismatch yields `ADMIN.INVALID_REQUEST`), since the Zod parsers
expect JSON.
When the operator-supplied audit sink throws, the admin call is NOT
failed — the failure is recorded via the
`host.admin_action_total{action="host.audit.emit", outcome="fail"}`
counter so observability captures the audit-pipeline incident
without blocking the operational action.

View File

@@ -0,0 +1,73 @@
# `audit-sink.ts`
## Purpose
`MemoryAdminAuditSink` is the in-memory default audit sink for the
host's admin API. Every admin endpoint emits a structured
`AdminAuditRecord` after the request settles — the sink decides what
to do with it. The in-memory sink records into a local array suitable
for tests + dev; production deployments swap in a sink that forwards
to `@suaveplan/audit-logging`, syslog, or a managed audit pipeline.
The sink interface is intentionally narrow (`emit(record)`) so the
admin API never depends on a specific audit backend. The
`AdminAuditRecord` shape carries the principal id, action,
optional plugin name, request params, outcome (success / failure),
and the failure cause when applicable.
## Features
- Zero-dependency in-memory recorder.
- `snapshot()` reads without clearing — useful for asserting state
across multiple admin calls.
- `drain()` reads + clears in one atomic step.
- Implements the public `AdminAuditSink` interface so it composes with
the admin API's mounting helper.
## Usage: Basic
```ts
import { MemoryAdminAuditSink } from "@suaveplan/api-host/admin-api";
const sink = new MemoryAdminAuditSink();
sink.emit({
at: new Date().toISOString(),
principal: { id: "alice", scopes: ["host:admin"] },
action: "plugin.start",
plugin: "@acme/billing",
params: {},
outcome: "success",
});
console.log(sink.snapshot().length); // 1
```
## Usage: Advanced
```ts
// In an integration test — drain between scenarios.
const sink = new MemoryAdminAuditSink();
// ... hit admin endpoint ...
const first = sink.drain();
// ... hit admin endpoint again ...
const second = sink.drain();
```
## API Reference
- `new MemoryAdminAuditSink()` — zero-arg ctor.
- `emit(record)` — append to the in-memory log.
- `snapshot(): readonly AdminAuditRecord[]` — copy without clearing.
- `drain(): readonly AdminAuditRecord[]` — copy + clear.
## Implementation Notes
`emit` is synchronous for the in-memory case — the admin API treats
the return value as `Promise<void> | void` so an async sink (HTTP
POST, IPC) plugs in identically. A failed emit raises
`ADMIN.AUDIT_EMIT_FAILED` on the request boundary — the admin call
is still considered a success because the side effect already
occurred, but the audit failure is logged.
The sink never persists past process exit. Production sinks MUST
ship audit records to durable storage before acknowledging the emit;
otherwise tamper-evidence is impossible to assert.

View File

@@ -0,0 +1,66 @@
# `idempotency-store.ts`
## Purpose
`InMemoryIdempotencyStore` is the process-local default for the admin
API's `Idempotency-Key` enforcement. Mutating admin endpoints (install,
uninstall, start, stop, reload, rollback, restart) require the caller
to supply a unique key per logical operation; a replayed key within the
TTL window raises `ADMIN.IDEMPOTENCY_KEY_REPLAY`. The store is the
piece of state that records every accepted key so a replay can be
detected.
The store is deliberately thin: a `Map<string, number>` where the
value is the epoch-ms expiry. The TTL is enforced on every `has()` via
the `prune()` sweep — expired entries are evicted lazily so a long-idle
process doesn't carry stale keys forever. Production deployments
replace it with a Redis-backed implementation sharing the same
`IdempotencyStore` interface.
## Features
- Process-local Map-backed storage.
- Lazy TTL pruning on every `has()` call.
- Injectable clock for deterministic tests.
- Zero allocations on the cache hit path.
## Usage: Basic
```ts
import { InMemoryIdempotencyStore } from "@suaveplan/api-host/admin-api";
const store = new InMemoryIdempotencyStore();
store.set("op-1", Date.now() + 60_000);
store.has("op-1"); // true
```
## Usage: Advanced
```ts
// Inject a fake clock so tests can fast-forward past the TTL window.
let now = 1000;
const store = new InMemoryIdempotencyStore(() => now);
store.set("op-1", 1500);
store.has("op-1"); // true
now = 1600;
store.has("op-1"); // false — entry expired and was evicted
```
## API Reference
- `new InMemoryIdempotencyStore(now?)` — defaults to `Date.now`.
- `has(key): boolean` — runs the prune sweep then checks membership.
- `set(key, expiresAt): void` — record `key` with absolute epoch-ms
expiry.
## Implementation Notes
The prune sweep walks every entry on every `has()`. This is fine for
the admin API's expected scale (mutating ops are rate-limited to 1/min
per principal). A Redis-backed production store amortises the cost via
the server-side TTL — no client-side sweep is needed.
The store intentionally does NOT expose a `delete()` method: keys
are immutable once recorded and only evicted by TTL expiry. This
matches HTTP RFC 7231's idempotency semantics — a successfully-replayed
request returns the original response, never a fresh result.

View File

@@ -0,0 +1,64 @@
# `rate-limiter.ts`
## Purpose
`AdminRateLimiter` enforces the design §8.4 per-principal rate limits
on every admin endpoint. Read endpoints share a 60-calls-per-minute
bucket; destructive endpoints share a 1-per-minute bucket. Both buckets
are scoped per principal id so a misbehaving operator cannot starve
the host of admin throughput for everyone else.
The limiter is a thin wrapper around `@suaveplan/rate-limiter`'s
`TokenBucket` — one bucket per `(principalId, kind)` pair, allocated
lazily on first contact. The refill rate is set so the bucket starts
full and drains to zero after `capacity` consecutive calls in a
minute; refill is linear, so a steady stream is permitted at the
documented rate. Bursts up to `capacity` are allowed.
## Features
- Per-principal isolation — one bucket pair per id.
- Lazy allocation — principals that never hit admin are zero-cost.
- Token-bucket refill maps `capacity` calls to one minute.
- Injectable clock for deterministic tests.
## Usage: Basic
```ts
import { AdminRateLimiter } from "@suaveplan/api-host/admin-api";
const limiter = new AdminRateLimiter(60, 1);
limiter.consume("alice", "read"); // true
limiter.consume("alice", "destructive"); // true
limiter.consume("alice", "destructive"); // false (until refill)
```
## Usage: Advanced
```ts
let now = 0;
const limiter = new AdminRateLimiter(60, 1, () => now);
limiter.consume("alice", "destructive"); // true
limiter.consume("alice", "destructive"); // false
now = 60_001;
limiter.consume("alice", "destructive"); // true — refilled
```
## API Reference
- `new AdminRateLimiter(readPerMinute, destructivePerMinute, clock?)`
- `consume(principalId, kind): boolean` — `true` on success, `false`
when rate-limited.
## Implementation Notes
`TokenBucket`'s `consume(1)` updates the bucket's internal counter and
returns a `RateLimitResult`. The admin API only cares about the
`allowed` boolean — it surfaces `429` + `Retry-After` purely from
constants when `consume` returns `false`.
The principal-id key is the rate-limit cardinality bound. A
deployment with many distinct admin principals carries a small
memory cost (~80 bytes per bucket pair) — gateways should rate-limit
admin traffic at the edge before it reaches the host if extreme
multi-tenant cardinality is anticipated.

View File

@@ -0,0 +1,82 @@
# `capability-registry` — runtime registry of host-injected capabilities
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `CapabilityRegistry` is the live, single-instance store of every capability adapter wired into a host process. Stage-4 adapter packages (`api-router-hono`, `api-auth-jwt`, `api-db-adapter`, etc.) register against this registry at host boot. The host's lifecycle driver consults it on every plugin install to (a) verify every entry in `manifest.requiredCapabilities` resolves to a registered adapter and (b) verify the adapter's version satisfies the plugin's `apiHost` semver range.
The registry is also the disposal coordinator — `stop()` on the host invokes `dispose()` here, and disposers run in **reverse-registration** order so deeper consumers (e.g. `db`) shut down before their dependencies (e.g. `logger`).
## Features
- `register(...)` with semver-shape validation (`INVALID_VERSION`), scope-enum validation (`INVALID_SCOPE`), and duplicate detection (`DUPLICATE_REGISTRATION`).
- `lookup(id)` / `require(id)` (strict, throws `NOT_REGISTERED`).
- `assertSatisfies(ranges)` — per-plugin compat check used by the lifecycle driver; throws `NOT_REGISTERED` or `VERSION_MISMATCH` with the failing `(id, range, actual)` context.
- Reverse-registration-order `dispose()` — collects every disposer failure and surfaces them via one aggregated `DISPOSER_FAILED` error so a single bad disposer does not block teardown.
- `defineCapability(...)` — author-facing helper (identity at runtime) used by adapter packages.
## Usage: Basic
```ts
import {
CapabilityRegistry,
defineCapability,
} from "@suaveplan/api-host/capability-registry";
const registry = new CapabilityRegistry();
registry.register(
defineCapability({
id: "logger",
version: "1.0.0",
scope: "host",
provider: logger,
})
);
const entry = registry.require("logger"); // throws CAPABILITY.NOT_REGISTERED if missing.
```
## Usage: Advanced
Plug into a manifest's `requiredCapabilities` resolution:
```ts
import { CapabilityRegistry } from "@suaveplan/api-host/capability-registry";
const registry = new CapabilityRegistry();
// ... register every adapter ...
function installPlugin(manifest: PluginManifest): void {
// The manifest pins ranges via `requiredCapabilities` + per-id range table.
registry.assertSatisfies({
db: "^1.0.0",
"auth.session": "~1.4",
});
// ... proceed with lifecycle install ...
}
// Host teardown — disposers run in REVERSE-registration order.
await registry.dispose();
```
## API Reference
- `class CapabilityRegistry`
- `register(reg)`: void
- `lookup<Id>(id): CapabilityRegistration<Id> | undefined`
- `require<Id>(id): CapabilityRegistration<Id>`
- `has(id): boolean`
- `ids(): readonly CapabilityId[]`
- `size(): number`
- `assertSatisfies(ranges: Record<string, string>): void`
- `dispose(): Promise<void>`
- `defineCapability<const Id>(reg): CapabilityRegistration<Id>`
- `CapabilityScope`: `"host" | "plugin" | "request"`
- `CapabilityRegistration<Id>`: shape consumed by `register`.
## Implementation Notes
Version compatibility uses `@suaveplan/api-manifest/dep-graph`'s `satisfies(version, range)` — the same narrow semver dialect the manifest validator accepts (pinned, caret, tilde, major-wildcard, `*`). Unparseable ranges throw at `register` time inside `satisfies`; the registry catches and re-raises as `CAPABILITY.VERSION_MISMATCH` so the lifecycle driver only ever has to match on one code. Disposers are awaited sequentially in reverse-registration order — concurrent dispose would risk teardown races between adapters that share resources (e.g. `events` dispatching to `logger`). Idempotency: a second `dispose()` call is a no-op, so the host's stop path can call it freely even after a partial failure.

View File

@@ -0,0 +1,78 @@
# `define-plugin` — author-facing plugin DX
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`definePlugin` is the canonical entry point plugin authors call to declare their `name` / `version` / `requires` / `isolation` / `trustLevel` plus optional lifecycle hooks (`install`, `start`, `stop`, `uninstall`, `health`). It threads the literal-array `requires` through to the `PluginContext<Caps>` and `RequestContext<Caps>` so undeclared capability access becomes a compile-time TypeScript error — the type system, not a runtime guard, is what enforces the capability surface.
The runtime work is light: validate `name` / `version` are non-empty, reject duplicate ids inside `requires`, and return a frozen record. The heavy lifting (manifest cross-check, capability resolution, FSM hand-off) lives in the host's lifecycle driver; `definePlugin` is the type-safety + ergonomics layer.
## Features
- `const Caps extends ReadonlyArray<CapabilityId>` generic — captures literal-array `requires` so `ctx.<cap>` is narrowed exactly to the declared ids.
- Synchronous validation for the two failure modes the type system cannot catch: empty `name`, empty `version`, duplicate id.
- Frozen output record — safe to hash for the rollback journal.
- Lightweight `DefinePluginError` (plain `Error` subclass) — no `SuaveplanError` dependency on the hot author-side path.
- Type aliases `PluginContextOf<P>` / `RequestContextOf<P>` derive the per-hook context shape from the module record.
## Usage: Basic
```ts
import { definePlugin } from "@suaveplan/api-host/define-plugin";
export default definePlugin({
name: "billing",
version: "1.2.3",
requires: ["db", "auth.session", "logger"] as const,
isolation: "trusted",
trustLevel: "first-party",
async start(ctx) {
ctx.capabilities.logger.info("billing: started");
},
});
```
## Usage: Advanced
Derive request-context type for downstream router wiring:
```ts
import {
definePlugin,
type RequestContextOf,
} from "@suaveplan/api-host/define-plugin";
const plugin = definePlugin({
name: "billing",
version: "1.2.3",
requires: ["db", "auth.session"] as const,
isolation: "isolated",
trustLevel: "third-party",
async install(ctx) {
// ctx.capabilities.db is `AdminDatabaseLike` here.
await ctx.capabilities.db.execute("INSERT INTO ...");
},
async start(ctx) {
ctx.capabilities.db; // ok
// ctx.capabilities.events; // <- compile error: "events" not declared
},
});
type Req = RequestContextOf<typeof plugin>;
// Req.capabilities.db is `TenantScopedDatabaseLike` here.
```
## API Reference
- `definePlugin<const Caps>(spec): PluginModule<Caps>` — validates + freezes.
- `DefinePluginSpec<Caps>` — input shape with optional lifecycle hooks.
- `PluginModule<Caps>` — `Readonly<DefinePluginSpec<Caps>>`.
- `PluginContextOf<P>` / `RequestContextOf<P>` — per-module derived context types.
- `DefinePluginError` — `code` ∈ `DEFINE_PLUGIN.INVALID_NAME` | `DEFINE_PLUGIN.INVALID_VERSION` | `DEFINE_PLUGIN.DUPLICATE_CAPABILITY`.
## Implementation Notes
The `const Caps extends ReadonlyArray<CapabilityId>` constraint relies on TypeScript 5.0+ `const` generic inference — callers MUST write `requires: ["db"] as const` (or `satisfies CapabilityRequirements`) for the narrowing to work. Without `as const`, TypeScript widens the tuple to `string[]` and the capability map degrades to the full `CapabilityId` union. The validation is intentionally minimal: deeper manifest-vs-module cross-check is the host loader's job (Chunk B+) so author errors surface in two places — fast / type-only here, slow / runtime + manifest-aware there. The frozen output ensures the rollback journal can content-hash a stable representation.

View File

@@ -0,0 +1,99 @@
# `@suaveplan/api-host/empty-secrets`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmptySecrets` is the no-op default `SecretsLike` implementation the
host injects into every `PluginContext` / `RequestContext` when an
operator constructs a `HostAdapters` bag without a `secrets` slot. It
preserves the foundation-fix-gap-3 design intent that plugin authors
can always destructure `ctx.secrets` without a defensive null guard —
the failure mode is an explicit, debuggable `SECRETS.SECRET_NOT_FOUND`
throw rather than a `TypeError` from a missing field.
In production a real adapter (e.g. `createEnvSecrets` from
`@suaveplan/api-secrets-env`, a Vault-backed adapter, or an HSM bridge)
replaces this default. `EmptySecrets` is exclusively the
"nothing configured" fall-through.
## Features
- `get(name)` resolves to `undefined` for every key.
- `require(name)` throws `SECRETS.SECRET_NOT_FOUND` carrying the
requested key on `context.name` and a sentinel `context.source =
"host.default.empty-secrets"` for correlation.
- `list()` returns the frozen empty `readonly string[]`.
- Zero state — `EmptySecrets` instances are interchangeable; the host
constructs one per `createHost` call so OTel attribute attachment
remains traceable per host instance.
- Stable error identity — every throw is a `SecretsError` so callers
can `instanceof`-narrow without importing the abstract base.
## Usage: Basic
```ts
import { EmptySecrets } from "@suaveplan/api-host/empty-secrets";
const secrets = new EmptySecrets();
const maybe = await secrets.get("STRIPE_SECRET_KEY");
// maybe === undefined — `EmptySecrets` never resolves a value.
```
## Usage: Advanced
A custom host that injects `EmptySecrets` only in `development` mode so
production deploys fail fast when the real adapter wiring is missing:
```ts
import { createHost } from "@suaveplan/api-host/host";
import type { HostAdapters } from "@suaveplan/api-host/host";
import { EmptySecrets } from "@suaveplan/api-host/empty-secrets";
import { createEnvSecrets } from "@suaveplan/api-secrets-env";
function buildSecrets(mode: "production" | "development") {
if (mode === "production") {
return createEnvSecrets({ allowedKeys: ["STRIPE_SECRET_KEY"] });
}
return new EmptySecrets();
}
const adapters: HostAdapters = {
/* ...other slots... */
secrets: buildSecrets(process.env["NODE_ENV"] as never),
} as never;
const host = await createHost({ adapters, plugins: [], mode: "development" });
```
## API Reference
`class EmptySecrets implements SecretsLike`
- `get(name: string): Promise<string | undefined>` — always
`undefined`.
- `require(name: string): Promise<string>` — always throws
`SecretsError` with code `SECRETS.SECRET_NOT_FOUND` and `context: {
name, source: "host.default.empty-secrets" }`.
- `list(): readonly string[]` — frozen empty array, stable across
calls.
## Implementation Notes
`EmptySecrets` is deliberately the simplest possible `SecretsLike`
implementation — every method is constant-time and allocation-free
(the frozen empty array is module-scoped and shared). The class lives
inside `@suaveplan/api-host` rather than `@suaveplan/api-secrets`
because the policy decision "what does the host inject when no adapter
is provided" is a host-orchestration concern, not a secrets-domain
concern. Sibling adapters (`api-secrets-env`, hypothetical
`api-secrets-vault`) stay focused on concrete sources.
Error codes are sourced from `@suaveplan/api-secrets`'s
`SECRETS_CODES` registry so the throw uses the canonical
namespace string (`SECRETS.SECRET_NOT_FOUND`) every other secrets
source uses. This keeps audit / OTel log dashboards consistent
regardless of which source surfaced the failure.

View File

@@ -0,0 +1,114 @@
# `ErrorBoundary` — pipeline-step throw containment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ErrorBoundary` wraps every step of the per-request pipeline (design doc §6 steps 1–11) and projects every captured throw onto a stable, observable shape. It is the single chokepoint where a `SuaveplanError`, an OTel `AbortError`, or an unknown value escaping a step gets turned into a `BoundaryFailure` carrying a deterministic HTTP status, error code, message, and OTel span termination.
Per the per-request pipeline, every step is wrapped — the boundary's per-failure side effects (counter emission, span close, structured log line) are uniform across pre-handler, handler, and post-handler steps. The boundary never throws — callers branch on the `BoundaryOutcome.ok` discriminator and never have to catch.
## Features
- Status projection consults the per-plugin `manifest.errors.statusMap` first, then the package-wide `HOST_DEFAULT_STATUS_MAP`, finally falling back to HTTP 500 with the original code preserved.
- `AbortError` (or any error with `code === "ABORT_ERR"`) projects to `HOST.CLIENT_ABORTED` (499).
- `SuaveplanError` subclasses (`HostError`, plugin-side errors carrying `PLUGIN.<NAME>.<CODE>`) pass their `code` through unchanged.
- Plain `Error` / strings / anything else surface as `HOST.INTERNAL_INVARIANT` by default; the dedicated `runHandler` wrapper substitutes `HOST.HANDLER_THREW` so OTel slices handler bugs from host invariants.
- Every failure emits `api.errors_total{plugin, step, code, status}` on the host instruments.
- The OTel span passed to `runStep`/`runHandler` is closed with `recordException` + `setStatus({code: 2, message: <projected-code>})`.
- Structured log line carries the cause chain (up to 5 levels) so a single log search resolves a request's failure path.
## Usage: Basic
```ts
import { ErrorBoundary } from "@suaveplan/api-host/error-boundary";
import { ConsoleLogger } from "@suaveplan/logger";
import { createHostInstruments } from "@suaveplan/api-host/otel";
const boundary = new ErrorBoundary({
instruments: createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" }),
logger: new ConsoleLogger({ name: "host" }),
});
const outcome = await boundary.runStep("auth.resolve", null, {}, null, async () => {
return await sessionResolverChain.resolve(request);
});
if (!outcome.ok) {
return new Response(JSON.stringify({ error: outcome.failure }), {
status: outcome.failure.status,
});
}
const principal = outcome.value;
```
## Usage: Advanced
Plugin-supplied `statusMap` overrides the host-default per code; per-plugin
manifest fields land here directly:
```ts
const statusMap = manifest.errors?.statusMap ?? {};
const handlerOutcome = await boundary.runHandler(
manifest.name,
statusMap,
span,
async () => await route.handler(ctx)
);
if (!handlerOutcome.ok) {
// `HOST.HANDLER_THREW` is the default code on unknown throws inside a
// handler — manifest may map it to a friendlier 502 for retryable
// upstream failures, for instance.
return assembleEnvelope(handlerOutcome.failure);
}
```
For streaming routes, the boundary still wraps the handler — once the
handler returns a `Response` carrying a `ReadableStream`, downstream
stream errors flow through the engine's native abort signalling and
appear here as `HOST.CLIENT_ABORTED`.
## API Reference
```ts
class ErrorBoundary {
constructor(options: ErrorBoundaryOptions);
runStep<T>(
step: string,
plugin: string | null,
statusMap: StatusMap,
span: BoundarySpan | null,
fn: () => Promise<T> | T
): Promise<BoundaryOutcome<T>>;
runHandler<T>(
plugin: string,
statusMap: StatusMap,
span: BoundarySpan | null,
fn: () => Promise<T> | T
): Promise<BoundaryOutcome<T>>;
}
type BoundaryOutcome<T> =
| { ok: true; value: T }
| { ok: false; failure: BoundaryFailure };
interface BoundaryFailure {
code: string;
status: number;
message: string;
step: string;
cause?: { code: string; message: string };
}
```
## Implementation Notes
The boundary intentionally does not own envelope assembly — that is the request pipeline's job. The boundary's failure shape carries everything the pipeline needs (`code`, `status`, `message`, `step`) and the pipeline adds the `requestId` / `traceId` / per-route `details` on top before serialising. Keeping that responsibility on the pipeline side lets unit tests around the boundary remain envelope-agnostic and makes future surface additions (e.g. `Retry-After` on `RATE_LIMIT_EXCEEDED`) a single-location edit.
`recordException` + `setStatus({code: 2, ...})` is the OTel convention for ERROR status. The boundary mutates the span but never `.end()`s it — span termination remains the pipeline's prerogative so per-step child spans can close before the parent.

View File

@@ -0,0 +1,69 @@
# `error-codes` — host orchestration error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every stable error code raised by `@suaveplan/api-host` and its sub-drivers (lifecycle, hot reload, capability registry, admin API). Codes are partitioned into five namespaces — `HOST.*`, `PLUGIN.*`, `RELOAD.*`, `CAPABILITY.*`, `ADMIN.*` — so consumers can match on either the exact code string or the namespace prefix without coupling to the package's internal module layout.
The registry is the contract every operator-facing surface (admin endpoints, audit log entries, OTel span events) reads from. Codes are versioned along with the package: renaming or removing a code is a breaking change requiring an OpenSpec proposal.
## Features
- Five disjoint namespaces (`HOST`, `PLUGIN`, `RELOAD`, `CAPABILITY`, `ADMIN`) declared via `defineCodes` from `@suaveplan/error`.
- Compile-time-checked `HostCode` union for exhaustive `switch` discrimination.
- Default `HOST_DEFAULT_STATUS_MAP` that maps every published code to an HTTP status — codes absent from a plugin's `manifest.errors.statusMap` fall back to this table.
- `statusFor(code)` accessor used by the error boundary to compute the final status.
- `HostError` class extending `SuaveplanError`, preserving the standard `code` / `context` / `cause` / `traceId` fields.
## Usage: Basic
```ts
import { HOST_CODES, HostError, statusFor } from "@suaveplan/api-host/error-codes";
if (alreadyUp) {
throw new HostError(HOST_CODES.ALREADY_STARTED, "host already started");
}
const status = statusFor("ADMIN.RATE_LIMIT_EXCEEDED"); // 429
```
## Usage: Advanced
Branch the error boundary on namespace prefix for telemetry:
```ts
import {
HostError,
HOST_DEFAULT_STATUS_MAP,
type HostCode,
} from "@suaveplan/api-host/error-codes";
function envelopeFor(err: unknown): { code: string; status: number } {
if (err instanceof HostError) {
const code = err.code as HostCode;
const namespace = code.split(".")[0];
return {
code,
status: HOST_DEFAULT_STATUS_MAP[code],
};
}
return { code: "HOST.INTERNAL_INVARIANT", status: 500 };
}
```
Plugins that ship their own manifest `errors.statusMap` override the default per-code; codes not in the override fall back to `HOST_DEFAULT_STATUS_MAP`.
## API Reference
- `HOST_CODES`, `PLUGIN_CODES`, `RELOAD_CODES`, `CAPABILITY_CODES`, `ADMIN_CODES`: frozen string-literal records, one per namespace.
- `HostCode`: union of every published code.
- `HOST_DEFAULT_STATUS_MAP: Readonly<Record<HostCode, number>>`: every code → HTTP status.
- `statusFor(code: HostCode): number`: returns the documented status.
- `HostError`: subclass of `SuaveplanError<HostCode>` with `.name === "HostError"`.
## Implementation Notes
The five-namespace split is deliberate: stack traces and audit envelopes both surface the namespace prefix, so collapsing into a single `HOST.*` union would lose the operational signal. The default status table is conservative — 503 for operational unavailability, 422 for validation, 409 for conflict — so plugins that opt-out of overrides still get sensible status codes. Codes published here that have no production path yet (e.g. lifecycle codes that ship in Chunk B of `api-host`) are pre-declared so the union is stable from day one and follow-up chunks do not need to widen `HostCode` and break consumers.

View File

@@ -0,0 +1,157 @@
# `createHost` — top-level host orchestration entry point
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createHost(config)` composes every internal driver (`LifecycleDriver`, `ReloadDriver`, `PluginLedger`, `CapabilityRegistry`, `RequestPipeline`, admin API) into a runnable host. Production deployments hand their HTTP engine's `fetch(request)` handler straight to `host.fetch(...)`; integration tests drive the same surface programmatically.
The resulting `Host` instance carries an authoritative install order (topo-sorted by manifest deps), exposes start/stop hooks, and serves three kinds of traffic: admin endpoints under `adminPrefix` (default `/admin`), internal probes (`/internal/livez`, `/internal/readyz`), and per-request plugin traffic through the §6 11-step pipeline.
## Features
- Zod-validated config with stable `HOST.CONFIG_INVALID` failure code.
- Topological dependency-graph install via `resolveInstallOrder`.
- Lifecycle + reload + plugin-ledger + capability-registry shared across the admin API and the per-request pipeline.
- Per-mode response-validation strictness — `production` defaults to `enforce`, `development` to `warn`.
- Admin API mountable at any prefix or disable-able via `adminPrefix: null`.
- `/internal/livez` returns `200 ok`; `/internal/readyz` returns `{state, plugins}` with the registered install order.
- Graceful shutdown with `graceMs` budget; sequential reverse-topo stop sequence.
## Usage: Basic
```ts
import { createHost } from "@suaveplan/api-host/host";
import { Database } from "bun:sqlite";
const host = await createHost({
adapters: {
router,
auth,
tenant,
capabilities,
rateLimit,
idempotency,
csrf,
audit,
span,
db: new Database(":memory:"),
},
plugins: [
{ module: billingModule, manifest: billingManifest, manifestHash: "sha256:...", specifier: "file:///billing.js" },
],
});
await host.start();
Bun.serve({ port: 3000, fetch: (req) => host.fetch(req) });
```
## Usage: Advanced
Disable the admin surface for non-admin pods + supply a request-id
generator for log correlation:
```ts
const host = await createHost({
adapters,
plugins,
adminPrefix: null,
mode: "development",
requestId: () => `req_${process.pid}_${counter++}`,
});
```
Install a plugin at runtime (post-start):
```ts
await host.installPlugin({
module: notificationsModule,
manifest: notificationsManifest,
manifestHash: "sha256:...",
specifier: "file:///notifications.js",
});
expect(host.plugins()).toContain("@acme/notifications");
```
## API Reference
```ts
function createHost(config: HostConfig): Promise<Host>;
interface Host {
start(): Promise<void>;
stop(graceMs?: number): Promise<void>;
fetch(request: Request): Promise<Response>;
installPlugin(install: HostPluginInstall): Promise<void>;
uninstallPlugin(name: string): Promise<void>;
plugins(): readonly string[];
readonly instruments: HostInstruments;
}
interface HostConfig {
adapters: HostAdapters;
plugins: ReadonlyArray<HostPluginInstall>;
adminPrefix?: string | null;
mode?: "production" | "development";
instruments?: HostInstruments;
logger?: BoundaryLogger & PipelineRequestLogger;
hostId?: string;
hostVersion?: string;
/**
* Fallback tenant — see "Default tenant" below. `undefined` /
* `null` propagate `ctx.tenant === null` on resolver miss for
* auth-optional routes; a `TenantContext` value preserves the
* legacy `{id: "public", slug: "public"}` injection.
*/
defaultTenant?: TenantContext | null;
}
```
### Default tenant (foundation gap 5)
Prior to `0.1.0` the request pipeline injected a hard-coded
`{id: "public", slug: "public"}` `TenantContext` whenever the configured
`TenantResolverChain` returned `null` on a public (auth-optional) route.
That made the `"public"` literal load-bearing across every handler that
introspected `ctx.tenantId`, blocking deployments that wanted to
distinguish "no tenant" from "the public tenant".
`HostConfig.defaultTenant` now controls the fallback explicitly:
| Value | Behaviour on resolver miss + auth-optional route |
| --------------- | --------------------------------------------------------- |
| `undefined` | `ctx.tenant === null` (the new default) |
| `null` | Same as `undefined` — documents the intent explicitly |
| `TenantContext` | Inject verbatim — preserves legacy `"public"` semantics |
Auth-required routes (`auth: <scope>`) still raise
`HOST.TENANT_NOT_RESOLVED` (400) on a resolver miss regardless of
`defaultTenant` — the fallback is a public-route convenience, not a
silent bypass of tenant-required policy.
## Implementation Notes
`createHost` is the only entry point that touches every internal
driver; everything else in `@suaveplan/api-host` is composable in
isolation. Tests SHOULD prefer the focused driver imports
(`@suaveplan/api-host/lifecycle`, `@suaveplan/api-host/request-pipeline`,
etc.) and reach for `createHost` only when the integration path matters.
The admin sub-router is mounted via Hono's `app.fetch(...)` directly
against a re-shaped `Request` whose pathname strips the configured
`adminPrefix`. This avoids a Hono `app.route(...)` import-cycle with
the admin-api package and keeps the prefix configurable without
forcing the admin router to know its own mount point.
`installPlugin` post-start short-circuits the topo-sort by appending
to the existing install order — adding a plugin at runtime never
re-orders previously-started plugins. A plugin with unresolved deps
will fail lifecycle install just as it would at start; the host
surfaces that via `LifecycleDriver`'s state-machine codes.
The reload resolver bag is intentionally minimal — Stage 3 will wire
`@suaveplan/api-loader`'s `ModuleResolver` here. Until then, calling
`reload(...)` throws `HOST.INTERNAL_INVARIANT` to make the missing
adapter loud rather than silent.

View File

@@ -0,0 +1,101 @@
# `FileWatcher`
## Purpose
`FileWatcher` is the dev-mode file-system watch primitive consumed by
`ReloadDriver`'s §8.3 trigger path. Each `register(plugin, path, trigger)`
call wires a watch against the supplied `FsWatchAdapter`; emitted fs events
are coalesced on a 500ms debounce so a burst of saves (e.g. an editor
performing atomic write-via-rename) collapses to a single reload trigger.
Production deployments disable the watcher entirely by simply not
constructing a `FileWatcher` instance — the constructor is a no-op unless
something is registered.
The watcher is intentionally decoupled from `node:fs/promises` — it
consumes an abstract `FsWatchAdapter` so Chunk-B's tests can drive the
debounce + dispose logic deterministically without ever touching a real
file system. Chunk D wires a Node-backed adapter that calls `fs.watch()`
and forwards events to the registered listener.
## Features
- Debounce coalescing — a burst of fs events collapses to one trigger.
- Configurable debounce window (`debounceMs`, default 500).
- Injectable timer factory (`setTimer` / `clearTimer`) so tests can drive
the time axis deterministically.
- Per-plugin registration with FIFO-style disposer semantics.
- `disposeAll()` tears down every registered watch + cancels pending
triggers — required at host shutdown.
- Throws on duplicate registration so misconfigured plugins fail loudly.
## Usage: Basic
```ts
import { FileWatcher } from "@suaveplan/api-host/hot-reload";
const watcher = new FileWatcher({
fs: {
watch(path, listener) {
const w = fs.watch(path, listener);
return () => w.close();
},
},
});
const dispose = watcher.register(
"@acme/billing",
"/opt/plugins/billing/dist",
async (plugin) => {
await reload.reload(plugin, await readNext(plugin));
}
);
```
## Usage: Advanced
Drive the watcher with a fake clock + an in-memory fs adapter for
deterministic tests:
```ts
let now = 0;
const timers = new Map<number, { cb: () => void; due: number }>();
const watcher = new FileWatcher({
fs: inMemoryFs,
debounceMs: 100,
setTimer: (cb, ms) => {
const handle = Date.now();
timers.set(handle, { cb, due: now + ms });
return handle;
},
clearTimer: (h) => timers.delete(h),
});
watcher.register("p", "/p", trigger);
inMemoryFs.emit("/p");
inMemoryFs.emit("/p");
inMemoryFs.emit("/p");
now += 200;
for (const t of timers.values()) if (t.due <= now) t.cb();
expect(trigger).toHaveBeenCalledTimes(1);
```
## API Reference
- `class FileWatcher`
- `constructor(options: FileWatcherOptions)`
- `register(plugin, path, trigger): () => void` — wire a watch, returns disposer.
- `disposeAll(): void`
- `count(): number`
- `interface FileWatcherOptions` — `fs`, `debounceMs?`, `setTimer?`,
`clearTimer?`.
- `interface FsWatchAdapter` — `watch(path, listener): () => void`.
## Implementation Notes
The watcher refuses duplicate registration for the same plugin name so an
operator misconfiguration surfaces as a synchronous throw rather than a
silently-doubled trigger rate. Disposal is idempotent through the
`handles` map deletion — calling the returned disposer twice is safe.
Pending debounce timers are cancelled on disposal so host shutdown does
not leak a stray trigger after the reload driver has been torn down.

View File

@@ -0,0 +1,154 @@
# `ReloadDriver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReloadDriver` implements the §8.1 blue-green hot-reload primitive plus the
§8.1a `RollbackJournal` integration. Each `reload(plugin, opts)` call
captures the live module + manifest into a per-plugin ring buffer, imports
the new module under a cache-busted ESM specifier via the
`@suaveplan/api-loader` `ModuleResolver`, refuses to proceed if native
bindings (`*.node`) are present in the dep graph, stages the new module in
a shadow router slot, drives the lifecycle FSM through `stop → start` against
the new module, runs the health probe, atomically flips the router's
per-plugin version pointer to the new slot, and drains the old slot. On any
failure the driver discards the shadow + auto-transitions the plugin to
`rolled_back` by re-activating the most-recent journal entry.
The driver is paired with `LifecycleDriver` (see `../lifecycle/`): the
lifecycle driver owns the FSM transitions; the reload driver orchestrates
the blue-green plumbing around them. Both share the same `EventBus` so
the `plugin.rolled_back` event is the public boundary between them.
## Features
- Blue-green primitive with atomic shadow → live flip + draining slot.
- 503-window opt-in via `blueGreen: false` for environments that cannot host
two module instances concurrently.
- `RollbackJournal` ring buffer (default depth 3) layered on the in-memory
`createRollbackJournal` from `@suaveplan/api-loader`; persisted subset
written to the plugin ledger via the `RollbackPersistence` adapter.
- Auto-rollback on `start_failed` during reload — the lifecycle driver
is fed `rolled_back` without operator intervention.
- Manual rollback via `rollback(plugin, toIndex)` — Stage 4's admin route
`POST /admin/plugins/{name}/rollback?to=N` calls into this.
- Native-binding guard via `walkNativeBindings()` + `assertNoNativeBindings()`.
- Hydration from the persisted journal store after host restart —
`hydrateFromPersistence(plugin)` re-populates the in-memory ring buffer
from the ledger (the `moduleRef` is intentionally left `null` until a
rollback re-imports).
- `FileWatcher` (sibling feature) supplies dev-mode `--watch` triggers with
a 500ms debounce, gated off in production.
- OTel: `plugin.reload_total{plugin, trigger, outcome}` counter +
`plugin.reload_duration_ms{plugin, trigger}` histogram on every reload.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import {
createHostInstruments,
LifecycleDriver,
ReloadDriver,
} from "@suaveplan/api-host";
const reload = new ReloadDriver({
adapters: {
blueGreen,
persistence,
resolver,
health: { async probe() {} },
},
lifecycle,
events,
instruments,
});
reload.recordInitialActivation(plugin, registered, initialHandle);
await reload.reload(plugin, {
manifest: nextManifest,
module: nextModule,
manifestHash: nextHash,
trigger: "admin",
});
```
## Usage: Advanced
Wire the dev-mode file watcher and a custom drain timeout:
```ts
import { FileWatcher, ReloadDriver } from "@suaveplan/api-host/hot-reload";
const reload = new ReloadDriver({
adapters,
lifecycle,
events,
instruments,
drainTimeoutMs: 5_000,
forceTimeoutMs: 10_000,
blueGreen: false, // 503-window mode for native-locks
journalDepth: 5,
});
const watcher = new FileWatcher({ fs: nodeFsWatch });
watcher.register("@acme/billing", "/opt/plugins/billing/dist", async (pluginName) => {
await reload.reload(pluginName, await loadPluginFromDisk(pluginName, "filewatch"));
});
```
Operator-initiated rollback:
```ts
const snap = await reload.rollback("@acme/billing", 0);
// snap.journal[0] is now the active version; emits plugin.rolled_back.
```
## API Reference
- `class ReloadDriver`
- `constructor(options: ReloadDriverOptions)`
- `recordInitialActivation(plugin, registered, handle): void`
- `reload(plugin, opts: ReloadOptions): Promise<ReloadSnapshot>`
- `rollback(plugin, toIndex: number): Promise<ReloadSnapshot>`
- `hydrateFromPersistence(plugin): Promise<void>`
- `snapshot(plugin): ReloadSnapshot`
- `interface ReloadDriverOptions` — `adapters`, `lifecycle`, `events`,
`instruments`, `journalDepth?`, `drainTimeoutMs?`, `forceTimeoutMs?`,
`blueGreen?`, `now?`.
- `interface ReloadAdapters` — `blueGreen`, `persistence`, `resolver`, `health`.
- `interface ReloadOptions` — `manifest`, `module`, `manifestHash`, `trigger`.
- `interface ReloadSnapshot` — `plugin`, `journal`, `inFlight`, `lastReloadAt`,
`lastReloadTrigger`.
- `class FileWatcher` — dev-mode watch primitive with debounced fire.
## Implementation Notes
The `BlueGreenAdapter` interface is intentionally narrow — `stageShadow`,
`flip`, `discardShadow`, `releaseDraining`. The production impl (Stage 4's
`api-router`) backs these with `Atomics.store` on a SharedArrayBuffer; the
Chunk-B reference impl used in tests is a plain map of `{ live, draining }`
records. The flip is "atomic" in the design because at the router level a
single integer write decides which handler runs — but `ReloadDriver` does
not care which mechanism the adapter chooses.
Reinstall criteria (`requiresReinstall(prev, next)`) raises
`LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` on a downgrade — the
caller propagates that as the reload's failure code. Forward-compatible
upgrades that cross schemaVersion or add new scopes/events/jobs/secrets
intentionally fall through to the lifecycle driver's normal start path so
the host's audit log shows one reload event, not two.
Persistence holds only the serialisable subset; `moduleRef` is a `WeakRef`
that cannot be serialised. On restart the host re-imports the persisted
specifier on demand — the first rollback after a restart pays one extra
`import()` call, every subsequent one is free.
The drain loop currently consults its own `AbortController` but Chunk D
wires the lifecycle driver's `InflightTracker` into the resolver-dispose
path so requests actually settle against the right module — Chunk B
captures the contract + emits the OTel metric so downstream wiring is
mechanical.

View File

@@ -0,0 +1,188 @@
# `LifecycleDriver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LifecycleDriver` is the runtime executor for the per-plugin lifecycle FSM
defined in `@suaveplan/api-lifecycle`. It owns one record per registered
plugin (manifest hash, plugin module from `definePlugin`, schemaVersion,
current FSM state, retry trackers, hook log) and translates the
author-facing verbs `install` / `start` / `stop` / `uninstall` / `crash` /
`retryInstall` / `retryStart` / `clearRollback` / `forceUninstall` /
`repair` into the trigger sequences that the `createPluginFsm` interpreter
applies against the legal-transition table.
Each high-level operation dispatches the entry trigger, executes the
side-effect descriptors captured on the FSM (the discriminated-union
`SideEffectOp` from `@suaveplan/api-lifecycle/side-effects`), then
dispatches the completion trigger. Failures along the way auto-dispatch
the matching `*Failed` trigger and the FSM ends in the documented parked
state (`install_failed`, `start_failed`, `uninstall_failed`). Auto-retry of
`start_failed` follows the `@suaveplan/retry` exponential-backoff policy
defined in `RETRY_POLICIES.startFailed`; crashed plugins auto-restart up
to `RETRY_POLICIES.crashed.crashRestartMax` (default 3) with cooldown
reset through `CrashBudgetTracker`.
## Features
- Full coverage of every `(state, trigger)` edge in
`@suaveplan/api-lifecycle`'s `PLUGIN_TRANSITIONS` table.
- Per-plugin mutex enforcement inherited from `createPluginFsm` — concurrent
triggers queue FIFO; tests assert this dedup.
- `SideEffectOp` execution against pluggable adapter surfaces
(`RouterAdapter`, `JobSchedulerAdapter`, `TopicSubscriberAdapter`,
`HealthMarkerAdapter`, `LedgerAdapter`, `MigrationRunner`, `RbacAdapter`,
`CapabilityInjector`, `InflightTracker`).
- Emits `plugin.lifecycle_transitions_total{plugin, from, to, trigger,
outcome}` on every transition via Chunk-A's `HostInstruments`.
- Emits typed events onto the host `EventBus<LifecycleEventMap>` —
`plugin.installed`, `plugin.started`, `plugin.stopped`, `plugin.uninstalled`,
`plugin.rolled_back`, `plugin.crashed`.
- `snapshot(plugin)` renders an admin-friendly view with the FSM state, the
most recent hook outcomes, and any captured `lastError`.
- `rollback(plugin, cause)` is invoked by the `ReloadDriver` (see
`../hot-reload/`) on a failed reload — drives `starting → rolled_back`
without operator intervention.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import {
createHostInstruments,
LifecycleDriver,
} from "@suaveplan/api-host";
const events = new EventBus();
const instruments = createHostInstruments({
hostId: "h1",
hostVersion: "0.1.0",
});
const driver = new LifecycleDriver({
adapters: {
router: { mount() {}, unmount() {} },
jobs: { register() {}, pause() {}, unpause() {} },
topics: { subscribe() {}, unsubscribe() {} },
health: { mark() {} },
ledger: {
async write() {},
async drop() {},
async read() { return null; },
},
migrations: { async runUpTo() {} },
rbac: { registerScopes() {}, dropScopes() {} },
capabilities: {
inject: () => ({}) as never,
dispose: async () => {},
},
inflight: {
async drain() { return 0; },
forceCancel: () => 0,
},
},
events,
instruments,
});
driver.register({
module: pluginModule,
manifest,
manifestHash: "sha256:...",
specifier: "file:///opt/plugin/dist/index.js?v=1",
schemaVersion: 1,
});
await driver.install(pluginModule.name);
await driver.start(pluginModule.name);
```
## Usage: Advanced
Customise the per-plugin mutex (e.g. backed by a distributed lock) and the
crash-restart cap:
```ts
import { LifecycleDriver } from "@suaveplan/api-host/lifecycle";
import type { PluginMutex } from "@suaveplan/api-lifecycle";
class RedisPluginMutex implements PluginMutex {
async acquire(plugin: string) { /* … */ }
tryAcquire(plugin: string) { /* … */ }
}
const driver = new LifecycleDriver({
adapters,
events,
instruments,
mutex: new RedisPluginMutex(),
crashRestartMax: 5,
sleep: () => Promise.resolve(),
});
driver.events.on("plugin.rolled_back", ({ plugin, from, to }) => {
operator.notify(`${plugin} rolled back ${from} → ${to}`);
});
await driver.crash(plugin.name, new Error("worker exit code 137"));
const snap = driver.snapshot(plugin.name);
if (snap.lastError !== null) operator.escalate(snap);
```
## API Reference
- `class LifecycleDriver`
- `constructor(options: LifecycleDriverOptions)`
- `register(record: RegisteredPlugin): void`
- `update(record: RegisteredPlugin): void`
- `install(plugin: string): Promise<PluginState>`
- `start(plugin: string): Promise<PluginState>`
- `stop(plugin: string): Promise<PluginState>`
- `uninstall(plugin: string): Promise<PluginState>`
- `crash(plugin: string, cause: unknown): Promise<PluginState>`
- `retryInstall(plugin: string): Promise<PluginState>`
- `retryStart(plugin: string): Promise<PluginState>`
- `clearRollback(plugin: string): Promise<PluginState>`
- `forceUninstall(plugin: string): Promise<PluginState>`
- `repair(plugin: string): Promise<PluginState>`
- `rollback(plugin: string, cause: unknown): Promise<PluginState>`
- `snapshot(plugin: string): LifecycleSnapshot`
- `names(): readonly string[]`
- `interface LifecycleDriverOptions` — `adapters`, `events`, `instruments`,
`mutex?`, `crashRestartMax?`, `sleep?`
- `interface LifecycleEventMap` — `plugin.installed`, `plugin.started`,
`plugin.stopped`, `plugin.uninstalled`, `plugin.rolled_back`,
`plugin.crashed`.
- Adapter surfaces from `lifecycle.types.ts` — `RouterAdapter`,
`JobSchedulerAdapter`, `TopicSubscriberAdapter`, `HealthMarkerAdapter`,
`LedgerAdapter`, `MigrationRunner`, `RbacAdapter`, `CapabilityInjector`,
`InflightTracker`, `LifecycleAdapters`.
## Implementation Notes
The driver intentionally delegates illegal-transition detection to the
underlying `PluginFsm` — every operator verb maps to one entry trigger;
attempting `start()` from `crashed` therefore raises a
`LifecycleError(ILLEGAL_TRANSITION)` from the FSM rather than a duplicate
guard in the driver. This keeps the source-of-truth for legal transitions
in The contract package.
Side-effect ops `validate-manifest`, `resolve-deps`, `acquire-db-connection`,
`complete-otel-span`, `apply-data-policy`, and `rollback-to-prior-module`
are documented but execute as no-ops inside the driver: manifest validation
ran at registration time, dep-graph resolution is computed by
`api-manifest/dep-graph` before registration, the ledger adapter holds
the DB connection, OTel span completion is captured via the lifecycle
counter, the data policy is applied by the next `drop-ledger` op, and the
rollback module switch is performed by the `ReloadDriver` before the
`rolled_back` transition fires. Keeping the no-op branches lets Stage-3
wire concrete behaviour without revising the FSM definition.
Auto-retry of `start_failed` uses `backoffDelay(base, factor, attempt, max)`
locally rather than `@suaveplan/retry`'s `retry()` engine because the
driver needs to inter-leave the FSM transitions between attempts —
`@suaveplan/retry`'s engine is single-call-with-retries semantics. The
underlying delay strategy is identical to `RETRY_POLICIES.startFailed.delay`.

View File

@@ -0,0 +1,86 @@
# `otel` — host instrument registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createHostInstruments` builds the frozen bundle of every OpenTelemetry counter and histogram the host emits on a production path. The instrument list mirrors design doc §12.3 exactly — every name is a published surface and removing one is a breaking change. The package never installs an OTel SDK; the meter-provider source is, in priority order, the explicit `meterProvider` override and then the OTel global provider (which falls back to a no-op when no SDK is wired). This keeps SDK initialisation an operator concern, not a library concern.
The registry also vends per-plugin meters via `forPlugin(name)` — each call returns a cached meter namespaced under `@suaveplan/api-host/plugin/<name>` with the plugin label pre-attached. Stage-4 packages plug their plugin-declared `manifest.telemetry` instruments onto these meters.
## Features
- Twelve counters (`HOST_COUNTER_NAMES`) and four histograms (`HOST_HISTOGRAM_NAMES`) covering the full §12.3 metric surface.
- Pre-tagged host meter with `host.id` + `host.version` attributes.
- Histogram units (`ms`, `By`) set at construction time.
- Cached per-plugin meters with `plugin.name` pre-attached.
- `tenantLabelOptIn` flag — defaults to `false` to bound cardinality.
- `dispose()` clears the per-plugin meter cache for graceful teardown.
## Usage: Basic
```ts
import { createHostInstruments } from "@suaveplan/api-host/otel";
const instruments = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
});
instruments.counters["api.requests_total"].add(1, { route: "/billing/invoices" });
instruments.histograms["api.request_duration_ms"].record(42, {
route: "/billing/invoices",
});
```
## Usage: Advanced
Plug an explicit meter-provider (test harness or operator-supplied SDK):
```ts
import {
HOST_COUNTER_NAMES,
createHostInstruments,
} from "@suaveplan/api-host/otel";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({
/* readers / views configured by the operator */
});
const instruments = createHostInstruments({
hostId: "host-1",
hostVersion: "0.1.0",
tenantLabelOptIn: true,
meterProvider: provider,
});
// Per-plugin meter — caches per `pluginName`.
const billingMeter = instruments.forPlugin("billing").meter;
const errorsByReason = billingMeter.createCounter("billing.errors_total");
// Verify the surface is complete.
for (const name of HOST_COUNTER_NAMES) {
instruments.counters[name]; // present.
}
```
## API Reference
- `createHostInstruments(config: HostInstrumentsConfig): HostInstruments`
- `HOST_COUNTER_NAMES` — `readonly` tuple of every counter name.
- `HOST_HISTOGRAM_NAMES` — `readonly` tuple of every histogram name.
- `HostInstruments`:
- `meter: Meter`
- `tenantLabelOptIn: boolean`
- `counters: Readonly<Record<HostCounterName, Counter>>`
- `histograms: Readonly<Record<HostHistogramName, Histogram>>`
- `forPlugin(name): PluginInstruments`
- `dispose(): void`
- `PluginInstruments`: `{ meter: Meter; pluginName: string }`.
## Implementation Notes
The counter / histogram lists are exported as `readonly` tuples so consumers can iterate and assert against them in tests — a missing instrument shows up as a test failure, not a runtime no-op. Per-plugin meters are cached by name to avoid leaking meter objects on every reload (one meter per plugin is the design invariant — disposal happens at uninstall, mirrored by `dispose()` here clearing the whole cache). Tenant-label opt-in is gated at the call site rather than the registry level: the registry exposes the flag and the request pipeline (Chunk B) decides whether to attach `tenant` to a given measurement. This keeps the cardinality story explicit at every emit site rather than hidden behind a registry policy.

View File

@@ -0,0 +1,76 @@
# `in-memory-bus` — capture-all `PipelineEventsBus` for tests
## Purpose
`InMemoryPipelineEventsBus` is the canonical fixture wired by `createTestHost` (Stage-5 keystone) and consumed by every smoke / integration test that needs to assert what happened inside the §6 11-step request pipeline. Every emitted event is appended to an internal FIFO and broadcast synchronously to every registered subscriber.
The bus is intentionally simple — capture-all (no sampling), drainable (so multi-request tests don't leak), with `byRequest` and `byStage` slicers for the common assertion patterns. It is NOT suitable for production observability (unbounded memory growth, single-process); production bridges to OTel via a separate adapter in `@suaveplan/api-host-otel-pipeline` (follow-up change).
## Features
- FIFO capture of every emitted event, accessible via `events`, `length`, `byRequest(id)`, `byStage(name)`.
- Synchronous in-order fan-out to subscribers (registration order).
- Subscriber exception isolation — a throwing subscriber does not halt fan-out or prevent FIFO append.
- Idempotent `subscribe` return — calling the returned unsubscribe twice is a no-op.
- `drain()` returns and clears the captured FIFO in one call for between-request reset.
## Usage: Basic
```ts
import { InMemoryPipelineEventsBus } from "@suaveplan/api-host";
const bus = new InMemoryPipelineEventsBus();
bus.emit({ requestId: "r1", stage: "auth", outcome: "entered", at: 1 });
bus.emit({ requestId: "r1", stage: "auth", outcome: "completed", at: 2, durationMs: 1 });
expect(bus.byStage("auth")).toHaveLength(2);
```
## Usage: Advanced
Drain between requests so each request's assertions stay isolated:
```ts
const bus = new InMemoryPipelineEventsBus();
host.adapters.pipelineEvents = bus; // wired automatically by createTestHost
await host.fetch(new Request("http://h/probe"));
const first = bus.drain();
expect(first.filter((e) => e.outcome === "entered")).toHaveLength(11);
await host.fetch(new Request("http://h/probe"));
const second = bus.drain();
expect(second).not.toEqual(first); // fresh requestId, fresh timestamps
```
Subscribe a live bridge in addition to the FIFO capture:
```ts
const bus = new InMemoryPipelineEventsBus();
const off = bus.subscribe((event) => console.log(event.stage, event.outcome));
// ...
off();
expect(bus.subscriberCount).toBe(0);
```
## API Reference
```ts
export class InMemoryPipelineEventsBus implements PipelineEventsBus {
emit(event: PipelineStageEvent): void;
subscribe(listener: PipelineEventListener): PipelineEventUnsubscribe;
readonly events: readonly PipelineStageEvent[];
readonly length: number;
drain(): PipelineStageEvent[];
byRequest(requestId: string): PipelineStageEvent[];
byStage(stage: PipelineStage): PipelineStageEvent[];
readonly subscriberCount: number;
}
```
## Implementation Notes
`events` returns a copy on every call so mutating the snapshot does not corrupt the internal FIFO. Test authors who hot-loop on the assertion path should cache the result in a local variable to avoid the O(n) copy on every read.
Subscriber exceptions are swallowed silently in the test fixture. Production bridges (e.g. the OTel bridge) MUST log via the configured boundary logger — but the in-memory fixture deliberately stays silent so tests aren't drowning in `console.error` noise from intentionally-throwing subscribers used to verify the isolation invariant.
The bus does NOT enforce ordering across `requestId`s — interleaved requests produce interleaved events in the FIFO. Use `byRequest(id)` to slice; the conformance harness does this for the per-request order invariant.

View File

@@ -0,0 +1,59 @@
# `noop-bus` — zero-cost default `PipelineEventsBus`
## Purpose
`NoopPipelineEventsBus` is the default value the host installs on `HostAdapters.pipelineEvents` when no operator-supplied bus is configured. Production deployments that don't care about per-stage observability incur zero overhead — `emit` is a no-op that allocates nothing, and `subscribe` returns a cached unsubscribe so callers do not allocate per registration.
The pipeline emits 22 events per successful request (entered + completed for each of the 11 §6 stages); on a noop bus that is 22 method calls that immediately return. With listeners present it becomes 22 synchronous listener invocations. The split avoids forcing every host to pay observability cost when nobody is listening.
## Features
- Implements `PipelineEventsBus` from `@suaveplan/api-types/pipeline-events`.
- Zero per-call allocation — `emit` neither reads the event nor allocates.
- Module-scoped `NOOP_UNSUBSCRIBE` shared across every `subscribe` call.
- Stateless — the exported `noopPipelineEventsBus` singleton is safe to share across hosts.
## Usage: Basic
```ts
import { NoopPipelineEventsBus } from "@suaveplan/api-host";
const bus = new NoopPipelineEventsBus();
bus.emit({ requestId: "x", stage: "auth", outcome: "entered", at: Date.now() });
// — no-op, no listeners, nothing observable.
```
## Usage: Advanced
Use the shared singleton when constructing host configs to avoid per-host allocation. This is what `createHost` does internally when `adapters.pipelineEvents` is omitted:
```ts
import { noopPipelineEventsBus } from "@suaveplan/api-host";
const host = await createHost({
adapters: {
// ...
pipelineEvents: noopPipelineEventsBus,
},
plugins: [],
});
```
Production builds that wire an OTel exporter (`@suaveplan/api-host-otel-pipeline`, follow-up change) replace this with a bridging bus; the host code path is identical.
## API Reference
```ts
export class NoopPipelineEventsBus implements PipelineEventsBus {
emit(event: PipelineStageEvent): void; // no-op
subscribe(listener: PipelineEventListener): PipelineEventUnsubscribe; // cached no-op unsubscribe
}
export const noopPipelineEventsBus: NoopPipelineEventsBus;
```
## Implementation Notes
The unused parameter is bound with a `void` reference so static analyzers do not flag it as dead code AND so the structural type compatibility check passes verbatim against `PipelineEventsBus.emit`. The class form (rather than a frozen object literal) is required so consumers can `instanceof NoopPipelineEventsBus` when they need to bail out of observability bridging — the foundation closure smoke does this to confirm the default is in place when no explicit bus is wired.
`NOOP_UNSUBSCRIBE` is module-scoped (not a method-local closure) because returning the same function from every `subscribe` call lets V8 keep a single shared hidden class and avoids per-call closure allocation. The benchmark in `noop-bus.test.ts` measures both paths and asserts the zero-allocation invariant.

View File

@@ -0,0 +1,55 @@
# `pipeline-events-constants` — runtime constants for `PipelineEventsBus`
## Purpose
`@suaveplan/api-types` is types-only — adding a runtime export there breaks the byte-size invariant CI enforces. The `PipelineEventsBus` contract still needs runtime constants for the canonical stage order tuple (so consumers can iterate / assert without re-listing the names) and for the per-stage event-name strings (so OTel bridges can name events without re-deriving them). Those constants live here, in `@suaveplan/api-host`, where runtime exports are expected.
## Features
- `PIPELINE_STAGE_ORDER` frozen tuple — canonical §6 execution order matching the `PipelineStage` union.
- `PIPELINE_EVENT_NAME` literal — log-grep friendly namespace prefix (`api-host.pipeline.stage`).
- `PIPELINE_STAGE_EVENT_NAMES` per-stage map — concatenated `${PIPELINE_EVENT_NAME}.<stage>` strings for OTel span-event naming.
## Usage: Basic
```ts
import { PIPELINE_STAGE_ORDER } from "@suaveplan/api-host";
for (const stage of PIPELINE_STAGE_ORDER) {
console.log(stage); // request-id, route-match, tenant, auth, ...
}
```
## Usage: Advanced
Build a per-stage histogram dashboard by indexing the per-stage event-name map:
```ts
import { PIPELINE_STAGE_EVENT_NAMES } from "@suaveplan/api-host";
import type { PipelineStage } from "@suaveplan/api-types/pipeline-events";
const histograms = new Map<PipelineStage, number[]>();
for (const stage of Object.keys(PIPELINE_STAGE_EVENT_NAMES) as PipelineStage[]) {
histograms.set(stage, []);
}
bus.subscribe((event) => {
if (event.outcome === "completed" || event.outcome === "rejected") {
histograms.get(event.stage)?.push(event.durationMs ?? 0);
}
});
```
## API Reference
```ts
export const PIPELINE_STAGE_ORDER: readonly PipelineStage[];
export const PIPELINE_EVENT_NAME: "api-host.pipeline.stage";
export const PIPELINE_STAGE_EVENT_NAMES: Readonly<Record<PipelineStage, string>>;
```
## Implementation Notes
The constants are typed as `readonly PipelineStage[]` (tuple) and `Readonly<Record<PipelineStage, string>>` (map). The map's value type is `string` rather than the specific literal because TypeScript widens nested string literals through `Readonly<Record<...>>`. Consumers that need the literal narrowing import the `PIPELINE_STAGE_EVENT_NAMES` constant directly — TypeScript preserves the literal types on the const expression.
The tuple MUST stay in lock-step with the `PipelineStage` union in `@suaveplan/api-types/pipeline-events`. A `satisfies` check at the type-test level catches any drift between the two; the conformance harness's order invariant also catches drift at runtime by comparing the actual emit order against this tuple.

View File

@@ -0,0 +1,83 @@
# `migrations.ts`
## Purpose
`HostMigrationRunner` is the forward-only idempotent migration engine
the API host runs against its plugin ledger DB. It owns the closed set
of host-side schema migrations — every table the host uses to track
plugins, journal rollback entries, and reserve future saga + 2PC
storage. The runner records every applied migration in a sentinel
`__host_migrations` table so re-instantiating it against an
already-migrated DB is a true no-op.
The runner is "forward only" by design — there is no downgrade path.
Out-of-order or backwards-only declarations raise
`LEDGER.MIGRATION_OUT_OF_ORDER` so config drift between deployments
fails loudly before any rows are touched. Migration callback failures
raise `LEDGER.MIGRATION_FAILED` and the runner deliberately does NOT
record the failed id, so the next attempt re-runs the migration.
## Features
- Forward-only ledger with a `__host_migrations` sentinel table.
- Two migrations shipped: `core-tables` (the active plugin + journal
schema) and `saga-2pc-reserved` (Stage-6 reserved schemas).
- Idempotent re-apply: the runner only executes a migration whose id
has no row in `__host_migrations`.
- Out-of-order detection: applied ids that aren't declared by the
current runner, or pending ids ≤ the max applied id, are both
rejected.
- Injectable clock for deterministic `applied_at` timestamps.
## Usage: Basic
```ts
import { Database } from "bun:sqlite";
import { HostMigrationRunner } from "@suaveplan/api-host/plugin-ledger";
const db = new Database(":memory:");
const runner = new HostMigrationRunner(db);
const applied = runner.migrate(); // [1, 2]
runner.migrate(); // []
```
## Usage: Advanced
```ts
// Inject a fake clock + a slimmer migration list for tests.
import { HOST_LEDGER_MIGRATIONS } from "@suaveplan/api-host/plugin-ledger";
const onlyCore = HOST_LEDGER_MIGRATIONS.filter((m) => m.id === 1);
const runner = new HostMigrationRunner(
db,
onlyCore,
() => new Date("2026-05-25T00:00:00Z")
);
runner.migrate();
const applied = runner.listApplied(); // [1]
```
## API Reference
- `new HostMigrationRunner(db, migrations?, now?)` — defaults to
`HOST_LEDGER_MIGRATIONS` and `() => new Date()`.
- `migrate(): readonly number[]` — returns the ids run by this call.
- `listApplied(): readonly number[]` — every previously-applied id
ascending.
- `HOST_LEDGER_MIGRATIONS` — closed forward-only migration set.
- `LedgerMigration { id, name, up(db) }` — declaration shape.
## Implementation Notes
Each migration's `up` callback runs DDL through `db.run(sql)` (the
single-statement DDL path on a SQLite-shaped handle). Multi-statement
migrations declare each statement separately rather than relying on
the driver to split — this keeps the runner driver-agnostic.
The sentinel insert (`INSERT INTO __host_migrations`) runs AFTER the
`up` callback returns. If the callback throws, the runner does not
record the id, so the next `migrate()` retries the migration. A
single migration is not transactional with respect to its own DDL —
SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent within a single
migration, so a partial first-pass apply still succeeds on retry.

View File

@@ -0,0 +1,121 @@
# `plugin-ledger.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluginLedger` is the host-owned metadata store backing every plugin
record the API host orchestrates. It persists the §4 plugin row
(state, version, manifest hash, isolation tier, crash + install-failure
counters, capability set) and the §8.1a rollback-journal subset
(ring buffer of `(specifier, manifestHash, migrationCursor, capturedAt,
version)` tuples). The store is the source of truth that survives host
restart — the in-memory `WeakRef<Module>` from `@suaveplan/api-loader`
stays in memory, but the persisted subset round-trips through this
ledger so a post-restart rollback only costs one extra `import()`.
The ledger is intentionally driver-agnostic: it consumes a structural
`LedgerDb` (a SQLite-shaped `prepare(sql).run(...) / .all(...) / .get(...)`)
seam so `bun:sqlite`, `better-sqlite3`, or any thin wrapper can wire in
without recompilation. Forward-only migrations install the host-owned
tables idempotently — re-instantiating a `PluginLedger` against an
already-migrated DB is a no-op.
## Features
- Forward-only idempotent migration runner (`HostMigrationRunner`).
- Schema covers the five §4 host-owned tables — the active plugin
ledger + rollback journal, plus reserved schemas for Stage-6 saga +
2PC.
- Ring-buffer trim with `(plugin_name, position)` composite PK so the
oldest entry is overwritten when depth (default 3) is exceeded.
- Adapter views (`asLifecycleLedger`, `asRollbackPersistence`) hand the
lifecycle + reload drivers their narrow ports.
- Zod-validated journal rows — corrupt entries raise
`LEDGER.INVALID_ROW`.
## Usage: Basic
```ts
import { Database } from "bun:sqlite";
import { PluginLedger } from "@suaveplan/api-host/plugin-ledger";
const db = new Database(":memory:");
const ledger = new PluginLedger({ db });
ledger.upsertPlugin({
name: "@acme/billing",
version: "1.4.2",
manifestHash: "sha256:abc",
isolation: "trusted",
state: "started",
lastTransitionAt: null,
installFailures: 0,
crashCount: 0,
crashBudgetResetsAt: null,
capabilities: [{ id: "audit-logger", version: "1.0.0" }],
});
const all = ledger.listPlugins();
```
## Usage: Advanced
```ts
// Hand the rollback driver its persisted journal seam.
import { ReloadDriver } from "@suaveplan/api-host/hot-reload";
const driver = new ReloadDriver({
// ...,
adapters: {
// ...,
persistence: ledger.asRollbackPersistence(),
},
});
// Push a journal entry directly (e.g. on initial activation).
ledger.pushJournalEntry("@acme/billing", {
specifier: "file:///opt/plugins/billing/dist/index.js?v=1",
manifestHash: "sha256:abc",
migrationCursor: 7,
capturedAt: new Date().toISOString(),
version: "1.4.2",
});
const latest = ledger.latestJournal("@acme/billing");
```
## API Reference
- `new PluginLedger({ db, journalDepth?, now?, autoMigrate? })` — opens
the ledger and runs migrations unless `autoMigrate === false`.
- `upsertPlugin(record): PluginLedgerRow` — insert-or-replace.
- `getPlugin(name): PluginLedgerRow | null`
- `listPlugins(): readonly PluginLedgerRow[]`
- `removePlugin(name): boolean`
- `pushJournalEntry(plugin, entry)` — appends with ring-buffer trim.
- `peekJournal(plugin, n): readonly PersistedRollbackEntry[]`
- `latestJournal(plugin): PersistedRollbackEntry | null`
- `journalLength(plugin): number`
- `asLifecycleLedger(): LedgerAdapter`
- `asRollbackPersistence(): RollbackPersistence`
## Implementation Notes
The ring-buffer trim chooses the oldest entry by `(captured_at,
position)` so ties (same timestamp on two pushes) deterministically
pick the lower position. The composite primary key `(plugin_name,
position)` lets the upsert `ON CONFLICT` clause overwrite the chosen
slot atomically.
`asLifecycleLedger().write` preserves the existing isolation tier and
counter values when the lifecycle driver writes a partial row. The
caller is expected to call `upsertPlugin` with the full record once
the manifest is parsed.
`materialiseRow` re-parses the JSON-serialised capability set on every
read so the in-memory shape is always validated. A malformed
`capabilities_json` raises `LEDGER.INVALID_ROW` rather than passing
through a half-typed array — operators see the corrupt row early.

View File

@@ -0,0 +1,75 @@
# `request-pipeline-driver` — §6 11-step orchestrator
## Purpose
This module exports the `runPipeline` function: the orchestrator that chains the §6 step gates (`request-pipeline-steps`), the Zod validators (`request-pipeline-validation`), the validation pre-step (`request-pipeline-outcomes.runRequestValidation`), the handler invocation, and the success / failure result builders (`request-pipeline-outcomes`). It also emits the per-stage observability events the `PipelineEventsBus` contract promises — every §6 stage is wrapped in a `beginStage` pair so observers see the full entered/(completed|rejected|skipped) lifecycle.
The driver function never throws — every failure path is converted into a `PipelineResult` with a fully-shaped `ErrorEnvelope` by the time it returns. The `RequestPipeline` class is now a thin shell that builds a `DriverConfig`, starts the root OTel span, emits the wrapping `request-id` stage events, and delegates to `runPipeline`.
## Features
- Single-pass orchestration: route-match → tenant → auth → csrf → capability → rate-limit → idempotency → handler → response-validate → audit.
- Per-stage `PipelineEventsBus` events at every boundary — `entered` on entry, one of `completed` / `rejected` / `skipped` on exit, with `durationMs` and (for rejections) `cause` populated.
- Skip detection re-derives the short-circuit branches inside `runCsrfStep` / `runRbacStep` / `runIdempotencyStep` via the predicates in `request-pipeline-stage-skip.ts`, so the stage event stream can distinguish "ran + passed" from "not applicable".
- Re-exports `DriverConfig` so external consumers do not need to know whether it lives in `outcomes` or `driver` — the type appears on the public surface here.
- Decoupled from the `RequestPipeline` class: tests or admin routes can call `runPipeline(config, request, requestId, rootSpan, startedAt)` directly with their own config.
## Usage: Basic
```ts
import { runPipeline } from "@suaveplan/api-host/request-pipeline-driver";
const rootSpan = config.options.span.start("api.request", null);
const result = await runPipeline(
config,
request,
requestId,
rootSpan,
config.now()
);
rootSpan.end(config.now());
return result.response;
```
## Usage: Advanced
Subscribe to the per-stage event stream via the configured bus to drive operator-grade dashboards or assert in tests:
```ts
import { InMemoryPipelineEventsBus } from "@suaveplan/api-host";
const bus = new InMemoryPipelineEventsBus();
const config: DriverConfig = {
...realConfig,
pipelineEvents: bus,
};
const result = await runPipeline(config, req, "test-id", span, 0);
const events = bus.byRequest("test-id");
expect(events.filter((e) => e.outcome === "entered")).toHaveLength(10); // 10 stages from driver; the wrapping `request-id` is emitted by `RequestPipeline.handle`.
```
The dispatch order is fixed; reordering steps requires editing this module and updating both the unit tests, the conformance harness's order invariant, and the §6 design doc, since later steps assume earlier-step invariants (e.g. `runRbacStep` assumes session resolution has run).
## API Reference
```ts
export { type DriverConfig } from "./request-pipeline-outcomes.js";
function runPipeline(
config: DriverConfig,
request: Request,
requestId: string,
rootSpan: OtelSpanLike & BoundarySpan,
startedAt: number
): Promise<PipelineResult>;
```
## Implementation Notes
The driver is split into three async helper functions (`runPipeline`, `runHandlerAndAfter`, `runResponseValidateAndAudit`/`emitAuditAndSucceed`) to keep the file under the CLAUDE.md soft LOC budget — the linear flow is preserved by passing a `carry: OutcomeArgs` value between them. Each helper handles a coherent stage range, so the stage-event wiring stays local to the code that drives the step.
`emitReject` is a free function (not a closure capture) so it can be shared between the route-match stage, the gate `onFail` closure, the handler short-circuit, and the response-validate short-circuit without re-defining the emission shape in four places. The function does NOT touch `failResult` — it only emits the event and returns; the caller still owns the `PipelineResult` construction.
`responseValidation === "warn"` still emits `completed` on the response-validate stage (the handler's response is propagated even though Zod flagged a mismatch). The audit stage runs after a warn-mode response-validate, matching the §6 spec — the metric counter is incremented exactly once whether the host runs in `enforce` or `warn` mode.
The `request-id` stage is NOT emitted by the driver — it is emitted by the enclosing `RequestPipeline.handle` so the entered/completed pair wraps the entire driver invocation. This keeps the driver's stage emissions a tight 10-stage block (route-match through audit); the outer `request-id` stage covers everything including the driver's own setup work.

View File

@@ -0,0 +1,77 @@
# `request-pipeline-emitters` — audit, telemetry, failure-response builders
## Purpose
This module owns the three side-effecting closers the §6 pipeline calls once a request has reached its outcome: `emitAudit` writes an `auditable: true` envelope to the configured sink, `recordTelemetry` increments the `api.requests_total` counter and the `api.request_duration_ms` histogram, and `buildFailureResponse` shapes the JSON `Response` + `ErrorEnvelope` returned on failure paths. These were the bulk of the pre-split `RequestPipeline.fail` / `succeed` methods.
Extracting them keeps the orchestrator focused on chain control flow and lets the emitters be reused identically from the success and failure paths without copying the audit + telemetry + envelope-shaping code.
## Features
- `emitAudit` swallows sink throws — a failing audit sink MUST NOT transform a 200 into a 500. The boundary's error path still logs the failure on the next iteration.
- `recordTelemetry` respects `HostInstruments.tenantLabelOptIn`; when off, the `tenant` attribute is omitted to keep cardinality bounded.
- `buildFailureResponse` always stamps `X-Request-Id` on the failure response and includes the `cause` chain (a single level deep, per the wire-format spec) when the boundary captured one.
- All three functions are pure functions of their arguments; no module-scoped state and no `this`.
- Failure responses always set `content-type: application/json` so downstream proxies do not mis-classify them.
## Usage: Basic
```ts
import {
buildFailureResponse,
emitAudit,
recordTelemetry,
} from "@suaveplan/api-host/request-pipeline-emitters";
await emitAudit({
audit,
request,
requestId,
route,
principal,
tenantCtx,
status: 200,
outcome: "success",
now: () => Date.now(),
});
const { response, envelope } = buildFailureResponse(failure, requestId);
```
## Usage: Advanced
`recordTelemetry` accepts `route: null` so callers can emit telemetry for the 404 path (no matched route). The counter and histogram attributes are derived from the inputs — there is no implicit fallback to a "default" route label:
```ts
recordTelemetry({
instruments,
outcome: "failure",
route: null, // 404 — no route resolved
principal: null,
tenantCtx: null,
status: 404,
startedAt,
now,
});
```
`buildFailureResponse` returns the envelope separately from the response so the caller can attach it to the `PipelineResult.envelope` field without re-deriving the wire shape (this is the only contract the host API tests assert against).
## API Reference
```ts
function emitAudit(args: EmitAuditArgs): Promise<void>;
function recordTelemetry(args: RecordTelemetryArgs): void;
function buildFailureResponse(
failure: BoundaryFailure,
requestId: string
): { response: Response; envelope: ErrorEnvelope };
```
## Implementation Notes
`emitAudit` wraps the sink call in `Promise.resolve(...)` so synchronous sinks (the testing default) are handled identically to async ones. The try/catch deliberately swallows every throw — instrumentation failure must be observable via the boundary's logger, not via a 500 response.
`recordTelemetry` calls `args.now()` twice indirectly (once to compute the duration delta against `startedAt`), but only once per invocation in this module. The caller is responsible for ensuring `startedAt` was sampled with the same clock that `now` reads from; mixing a fake clock for `startedAt` with `Date.now` here would yield nonsense durations.
`buildFailureResponse` only embeds the top-level `cause`. The boundary captures deeper cause chains for the log line but the wire format intentionally truncates to one level — a deeper chain belongs in the request id'd log entry, not in the response body.

View File

@@ -0,0 +1,70 @@
# `request-pipeline-helpers` — pure utilities for the request pipeline
## Purpose
This module concentrates the pure (no-`this`, no side-effecting) helpers the §6 request pipeline relies on: request-id generation, URL parsing tolerant of relative forms, JSON body reader with a size cap, `Response` wrapping for handler-returned values, an `X-Request-Id` header stamper, and the `RequestContext` builder handed to plugin handlers. The split is mechanical — every function used to live inside `request-pipeline.ts` and was extracted verbatim when the file outgrew the CLAUDE.md per-file LOC budget.
Keeping these helpers in their own module lets the driver, the step gates, and the outcome emitters all consume them without circular imports, and lets each helper be unit-tested via the existing end-to-end pipeline tests with zero behavioural drift.
## Features
- `defaultRequestId()` — 32-char hex string drawn from `crypto.getRandomValues`, with a `Math.random` fallback only when WebCrypto is missing.
- `parseUrl(url)` — returns `URL` or `null`; tolerates absolute and relative inputs without throwing.
- `safeReadBody(request)` — JSON body reader that enforces a 5 MiB cap and converts size or parser errors into `HOST.REQUEST_VALIDATION_FAILED`.
- `toResponse(value)` — wraps handler return values as `Response` (passes through existing `Response` unchanged).
- `withRequestHeaders(response, id)` — clones a `Response` with `X-Request-Id` set.
- `buildRequestContext(args)` — assembles the typed `RequestContext` (including the documented `tenant`-may-be-null escape hatch for the foundation-gap-5 default behaviour).
- `DEFAULT_IDEMPOTENCY_TTL_MS` (10 minutes) and `HANDLER_VALIDATION_LIMIT_BYTES` (5 MiB) constants.
## Usage: Basic
```ts
import {
buildRequestContext,
defaultRequestId,
parseUrl,
toResponse,
withRequestHeaders,
} from "@suaveplan/api-host/request-pipeline-helpers";
const id = defaultRequestId();
const url = parseUrl(request.url);
const response = withRequestHeaders(toResponse({ ok: true }), id);
```
## Usage: Advanced
`safeReadBody` is the size-bounded JSON reader the request-validation step uses; consumers needing identical semantics outside the pipeline (e.g. an admin endpoint) can call it directly:
```ts
import { safeReadBody } from "@suaveplan/api-host/request-pipeline-helpers";
try {
const body = await safeReadBody(request);
// body is `unknown`; downstream Zod parse expected.
} catch (cause) {
// HOST.REQUEST_VALIDATION_FAILED — already shaped for the boundary.
throw cause;
}
```
## API Reference
```ts
const DEFAULT_IDEMPOTENCY_TTL_MS: number;
const HANDLER_VALIDATION_LIMIT_BYTES: number;
function defaultRequestId(): string;
function parseUrl(url: string): URL | null;
function safeReadBody(request: Request): Promise<unknown>;
function toResponse(value: unknown): Response;
function withRequestHeaders(response: Response, requestId: string): Response;
function buildRequestContext(args: BuildRequestContextArgs): RequestContext;
```
## Implementation Notes
Every helper is referentially transparent except `safeReadBody` (which reads the request stream) and `defaultRequestId` (which calls into WebCrypto). All other functions take their inputs by parameter and never touch module-scoped state, so they are safe to call from any tier — universal, server, browser, or test.
`buildRequestContext` performs a documented cast for `tenant`/`tenantId`: the shared `RequestContext` type declares both as non-nullable for typed-handler ergonomics, but foundation-gap-5 made the runtime value nullable when the resolver chain misses and `defaultTenant` is `null` (the new default). Handlers consuming tenant-bound capabilities must defensively gate on `ctx.tenant === null`.
The 5 MiB limit on `safeReadBody` is intentional — larger payloads should use a streaming upload route (`streaming: true`) that bypasses request validation buffering entirely. The constant is exported so operators can audit it without grepping the implementation.

View File

@@ -0,0 +1,90 @@
# `request-pipeline-outcomes` — driver config + success/failure result builders
## Purpose
This module ties the bare emitters and validators together for the orchestrator. It exposes `DriverConfig` (the frozen per-pipeline settings bundle), `OutcomeArgs` (the common argument shape carried through every gate-fail branch), `runRequestValidation` (a thin adapter that parses the URL query, short-circuits on streaming / opt-out routes, and emits the dedicated `api.validation.failures_total{where=request}` counter), and the `successResult` / `failResult` builders that translate an outcome into a fully-shaped `PipelineResult`.
These helpers were extracted from `request-pipeline.ts` during the file-size refactor. Behaviour is byte-for-byte identical to the pre-split implementation; the only change is that the shared argument bundle is now reified as a type so each branch can use `...spread` instead of re-listing eight parameters.
## Features
- `DriverConfig` freezes the constructor inputs once and is threaded through every helper, so step-level functions see the same `boundary`, `now`, `instruments`, `defaultTenant`, and `responseValidation` mode.
- `runRequestValidation` is the only piece that knows about the request URL's query — the orchestrator does not parse it directly anymore.
- `successResult` always increments `api.requests_total{outcome=success}` and the duration histogram before returning.
- `failResult` increments the same counter/histogram with `outcome=failure`; the audit envelope is only emitted when a route matched (the 404 path skips audit per spec).
- `OutcomeArgs` is exported so the orchestrator can declare a single `carry` variable and reuse it across every fail branch.
## Usage: Basic
```ts
import {
type DriverConfig,
failResult,
runRequestValidation,
successResult,
} from "@suaveplan/api-host/request-pipeline-outcomes";
const validation = await runRequestValidation(config, { request, route, rootSpan });
if (validation.kind === "fail") {
return failResult(config, { ...carry, failure: validation.failure });
}
return successResult(config, { ...carry, response: handlerResponse });
```
## Usage: Advanced
`DriverConfig` decouples the orchestrator from the `RequestPipeline` class — tests can construct a config object directly and call `runPipeline` to verify behaviour without instantiating the full class:
```ts
const config: DriverConfig = {
options,
instruments,
boundary: new ErrorBoundary({ instruments, logger }),
now: () => 1000,
idempotencyTtlMs: 60_000,
responseValidation: "enforce",
defaultTenant: null,
};
```
`OutcomeArgs.route` is nullable so the 404 path can reuse the same `failResult` shape. Tests that assert "no audit envelope on 404" rely on this branch — supplying a non-null route would silently emit an audit entry.
## API Reference
```ts
interface DriverConfig {
options: RequestPipelineOptions;
instruments: HostInstruments;
boundary: ErrorBoundary;
now: () => number;
idempotencyTtlMs: number;
responseValidation: "enforce" | "warn";
defaultTenant: TenantContext | null;
}
interface OutcomeArgs {
request: Request;
requestId: string;
rootSpan: OtelSpanLike & BoundarySpan;
startedAt: number;
route: PipelineRoute | null;
principal: Principal | null;
tenantCtx: TenantContext | null;
}
function runRequestValidation(
config: DriverConfig,
args: { request: Request; route: PipelineRoute; rootSpan: OtelSpanLike & BoundarySpan }
): Promise<RequestValidationOk | RequestValidationFail>;
function successResult(config: DriverConfig, args: OutcomeArgs & { response: Response }): Promise<PipelineResult>;
function failResult(config: DriverConfig, args: OutcomeArgs & { failure: BoundaryFailure }): Promise<PipelineResult>;
```
## Implementation Notes
`runRequestValidation` is the ONLY function that derives `parsedQuery` from the request URL. Centralising it ensures all three early-validation outcomes (streaming-skip, opt-out, schema-pass) return the same query record shape — without that, the handler ctx could see `{}` on one path and the parsed record on another.
`successResult` calls `withRequestHeaders` exactly once, deliberately. The original implementation also called it once and the post-extracted form preserves that — re-cloning the response would burn an unnecessary `Headers` allocation per request.
`failResult` always emits the `api.requests_total{outcome=failure}` counter, including on the 404 path. That contract is depended on by the `api.errors_by_step` dashboard the host's STATUS docs reference; removing it (e.g. as an optimisation for the no-route case) would silently break the metric.

View File

@@ -0,0 +1,74 @@
# `request-pipeline-stage-emit` — per-stage event helper
## Purpose
This module wraps the `PipelineEventsBus.emit` calls the §6 11-step driver makes around every stage. The helper centralises the entered-at timestamp capture, the matching exit emission, and the `durationMs` calculation so the driver stays readable instead of duplicating the same six-line emit dance on every step.
The helper is private to `@suaveplan/api-host` — consumers wire their own `PipelineEventsBus` via `HostAdapters.pipelineEvents` and never call `beginStage` directly. The conformance harness in `./testing/pipeline-events-conformance/` validates the resulting event stream.
## Features
- One `beginStage(bus, stage, requestId, now)` entry point that emits `entered` and returns a closer.
- Closer exposes `complete()` / `reject(cause)` / `skip()` so the driver can flow control naturally.
- Double-call guard — invoking `complete` then `reject` (or any other combination) on the same emitter only emits the first outcome.
- `durationMs` populated on every exit event using the supplied `now()` function (deterministic under fake-clock tests).
- Cause payload is conditionally attached only when the outcome is `rejected` AND the caller supplies it, keeping `completed` / `skipped` events lean.
## Usage: Basic
```ts
import { beginStage } from "./request-pipeline-stage-emit.js";
const tenant = beginStage(bus, "tenant", requestId, now);
const outcome = await runTenantStep(...);
if (outcome.kind === "fail") {
tenant.reject({ code: outcome.failure.code, message: outcome.failure.message });
return failureResult(...);
}
tenant.complete();
```
## Usage: Advanced
For conditionally-skipped stages the driver invokes `skip()` instead of `complete()` so the conformance harness's `skip` invariant can distinguish "stage didn't run because it wasn't applicable" from "stage ran and passed":
```ts
const csrf = beginStage(bus, "csrf", requestId, now);
const csrfOutcome = runCsrfStep({ ... });
if (csrfOutcome.kind === "skip") {
csrf.skip();
} else if (csrfOutcome.kind === "fail") {
csrf.reject({ code: csrfOutcome.failure.code, message: csrfOutcome.failure.message });
return failureResult(...);
} else {
csrf.complete();
}
```
The closer's double-call guard means an over-cautious caller that invokes both `skip()` and `complete()` only triggers the first one — the conformance harness still treats the stage as having a single exit. Real driver code is structured so only one of the three branches runs.
## API Reference
```ts
export interface StageEmitter {
complete(): void;
reject(cause: PipelineStageCause): void;
skip(): void;
readonly enteredAt: number;
}
export function beginStage(
bus: PipelineEventsBus,
stage: PipelineStage,
requestId: string,
now: () => number
): StageEmitter;
```
## Implementation Notes
The emitter holds a `closed` flag rather than throwing on a double-close because the driver's failure paths can call `reject()` and then walk through cleanup code that defensively closes the same stage if it sees an unfinished emitter. A throw on double-close would convert a recoverable cleanup into a fatal pipeline error.
`enteredAt` is exposed so callers can compute alternative durations (e.g. for OTel histograms scoped to a single stage) without re-reading `now()`. The conformance harness does NOT assert on this value — only on the existence of `entered` + matching exit events.
The conditional cause attach uses a spread guarded by `cause !== undefined` rather than always attaching the field, because the `PipelineStageCause` interface marks the `cause` field as optional and a serialiser that flattens `undefined` to `null` would corrupt the assertion contract.

View File

@@ -0,0 +1,71 @@
# `request-pipeline-stage-skip` — per-stage skip predicates
## Purpose
The §6 11-step request pipeline has four stages (`csrf`, `capability`, `idempotency`, `audit`) that may be conditionally skipped based on route options or request shape. The underlying step functions (`runCsrfStep`, `runRbacStep`, `runIdempotencyStep`) collapse the skip branches into a single `pass` outcome — that is the right shape for the gate-based control flow but it loses the "skipped vs passed" distinction the per-stage event stream needs.
This module re-derives those predicates so the driver can emit `skipped` (not `completed`) for stages that did not actually apply. Without this distinction the conformance harness's "skip" invariant cannot be verified end-to-end.
## Features
- `isCsrfSkipped(request, route, principal)` — GET/HEAD, streaming, csrf-off, or anonymous request.
- `isCapabilitySkipped(route)` — `auth: null | undefined` (public route).
- `isIdempotencySkipped(request, route)` — route opts out OR no `Idempotency-Key` header.
- `isAuditSkipped(route)` — `auditable: false`.
## Usage: Basic
```ts
import { isCsrfSkipped } from "./request-pipeline-stage-skip.js";
const skipped = isCsrfSkipped(request, route, principal);
const stage = beginStage(bus, "csrf", requestId, now);
const outcome = runCsrfStep({ csrf, route, request, principal });
if (outcome.kind === "fail") {
stage.reject({ ... });
} else if (skipped) {
stage.skip();
} else {
stage.complete();
}
```
## Usage: Advanced
The predicates are pure functions so they are trivially testable in isolation without standing up the full driver. The conformance harness exercises the skip path by issuing a GET request (forces `csrf` skip) against an unauthenticated route (forces `capability` skip) without an `Idempotency-Key` header (forces `idempotency` skip) and asserts the three `skipped` events are present in the captured FIFO.
```ts
import { isAuditSkipped } from "./request-pipeline-stage-skip.js";
const route = { options: { auditable: false } } as any;
expect(isAuditSkipped(route)).toBe(true);
```
## API Reference
```ts
export function isCsrfSkipped(
request: Request,
route: { readonly options: { readonly streaming?: boolean; readonly csrf?: boolean } },
principal: Principal | null
): boolean;
export function isCapabilitySkipped(route: {
readonly options: { readonly auth?: string | null };
}): boolean;
export function isIdempotencySkipped(
request: Request,
route: { readonly options: { readonly idempotent?: boolean } }
): boolean;
export function isAuditSkipped(route: {
readonly options: { readonly auditable?: boolean };
}): boolean;
```
## Implementation Notes
The route argument is typed as a structurally-narrow shape rather than the full `PipelineRoute` so each predicate is independent of unrelated route fields. The driver passes the full route in but the predicate never reaches past the documented surface — that keeps the predicate tests honest about what they assert.
Keep these predicates in lock-step with the corresponding step functions in `request-pipeline-steps.ts`. If you change the skip logic in one without updating the other, the conformance harness will fail with a "skip outcome inconsistent with step pass branch" assertion. The unit tests live in `request-pipeline-stage-skip.test.ts` and exercise every branch of every predicate.

View File

@@ -0,0 +1,77 @@
# `request-pipeline-steps` — §6 step gate functions
## Purpose
This module hosts the six per-request gate functions the §6 pipeline runs after route match and before plugin handler invocation: tenant resolution, session resolution, CSRF check, RBAC scope gate, rate-limit consume, and idempotency-key replay check. Each function returns a discriminated `GateOutcome` so the orchestrator can chain `pass → next step` and `fail → failure response` without inheriting the LOC weight of the inline implementations.
The functions were extracted from `request-pipeline.ts` verbatim during the file-size refactor. No behaviour, code path, telemetry counter, or audit envelope was changed; every existing end-to-end pipeline test passes against the extracted forms without modification.
## Features
- `runTenantStep` resolves the tenant via the configured chain, applies the `defaultTenant` fallback for public routes, and emits `api.tenant.unresolved_total` on `auth`-required misses.
- `runSessionStep` resolves the session principal through the boundary; throws map to `HOST.SESSION_REQUIRED` and increment `api.auth.failures_total{reason}`.
- `runCsrfStep` (post-session) returns `pass` for GET/HEAD, streaming routes, non-cookie public requests, and `csrf` flag unset; otherwise consults the validator.
- `runRbacStep` compares `principal.scopes` to `route.options.auth`; failures emit `api.rbac.denials_total{plugin, route, missing_scope}`.
- `runRateLimitStep` buckets by principal id, else tenant id, else `"anon"`; failures emit `api.rate_limit.exceeded_total{key_kind}`.
- `runIdempotencyStep` consults the configured store for `idempotent: true` routes carrying an `Idempotency-Key`; replays emit `api.idempotency.replays_total`.
## Usage: Basic
```ts
import {
runRbacStep,
runTenantStep,
} from "@suaveplan/api-host/request-pipeline-steps";
const tenantOutcome = await runTenantStep({
boundary,
tenant: tenantChain,
route,
span,
request,
defaultTenant,
instruments,
});
if (tenantOutcome.kind === "fail") {
return failResult(config, { ...args, failure: tenantOutcome.failure });
}
```
## Usage: Advanced
`GateOutcome` is a discriminated union shared by every step in this module, so the orchestrator can collapse multiple gates with a uniform fail-side closure:
```ts
const onFail = (g: Extract<GateOutcome<unknown>, { kind: "fail" }>) =>
failResult(config, { ...carry, failure: g.failure });
const csrf = runCsrfStep({ csrf, route, request, principal });
if (csrf.kind === "fail") return onFail(csrf);
const rbac = runRbacStep({ route, principal, instruments });
if (rbac.kind === "fail") return onFail(rbac);
```
Note: side-effecting gates (`runRateLimitStep` consumes a bucket token; `runIdempotencyStep` writes the key entry) MUST run sequentially with early-return on `fail` — running them in parallel changes observable outcomes.
## API Reference
```ts
type GateOutcome<T> =
| { kind: "pass"; value: T }
| { kind: "fail"; failure: BoundaryFailure };
function runTenantStep(args): Promise<GateOutcome<TenantContext | null>>;
function runSessionStep(args): Promise<GateOutcome<Principal | null>>;
function runCsrfStep(args): GateOutcome<undefined>;
function runRbacStep(args): GateOutcome<undefined>;
function runRateLimitStep(args): GateOutcome<undefined>;
function runIdempotencyStep(args): GateOutcome<undefined>;
```
## Implementation Notes
Tenant + session both go through `boundary.runStep` because their resolver implementations can throw (network failure, upstream auth down). CSRF / RBAC / rate-limit / idempotency are synchronous and do not need boundary wrapping — they emit dedicated counters but never throw.
`runRbacStep` short-circuits to `pass` when `route.options.auth` is `null` or `undefined`. That is the public-route signal; the call-site does NOT need to gate on it. A request that reaches `runRbacStep` with `principal === null` and a non-public route raises `HOST.SESSION_REQUIRED` (401), not `HOST.RBAC_DENIED` — the audit log shows that the access was rejected for missing identity, not missing scope.
The `idempotency` step writes the TTL entry only on `pass`. A replay returns `fail` without touching the store, preserving the original entry's expiry. Routes that are `idempotent: true` but receive no `Idempotency-Key` short-circuit to `pass` without storing anything, matching the spec's "key is optional on idempotent routes" provision.

View File

@@ -0,0 +1,90 @@
# `request-pipeline-validation` — Zod gates for request + response
## Purpose
This module owns the two Zod-validation functions the §6 pipeline runs: `validateRequest` (step 6) parses body / query / params against per-route schemas, and `validateResponse` (step 9) confirms the handler-returned JSON matches the declared response shape for the resolved status. Both functions wrap their work in the shared `ErrorBoundary` so a throw becomes a fully-tagged `BoundaryFailure` instead of an unhandled rejection.
The extraction from `request-pipeline.ts` is purely mechanical — the bodies were lifted verbatim and reshaped to take an `ErrorBoundary` parameter instead of accessing it via `this`. No behaviour changed; every existing pipeline test continues to pass.
## Features
- `validateRequest` returns a discriminated union `{ ok: true, value: { body, query } } | { ok: false, failure }`. Body, query, and params are each opt-in via `route.options.request.{body,params,query}`.
- `validateResponse` short-circuits to `{ ok: true }` for streaming routes, routes without declared `responses`, statuses not enumerated in `responses`, and non-JSON content types.
- Zod errors are truncated to the first 10 issues to keep envelope `context.issues` bounded.
- Body size is enforced by `safeReadBody`, so over-limit bodies surface as `HOST.REQUEST_VALIDATION_FAILED` with `context.bytes`.
- Both functions emit `api.errors_total` through the boundary's standard channel — the caller adds the dedicated `api.validation.failures_total{where=request|response|response.warn}` counter.
## Usage: Basic
```ts
import {
validateRequest,
validateResponse,
} from "@suaveplan/api-host/request-pipeline-validation";
const reqOutcome = await validateRequest(
boundary,
request,
route,
parsedQuery,
span
);
if (!reqOutcome.ok) return failureFromBoundary(reqOutcome.failure);
const { body, query } = reqOutcome.value;
```
## Usage: Advanced
For warn-mode response validation, the caller increments a counter and proceeds with the original response:
```ts
const resOutcome = await validateResponse(boundary, route, response, span);
if (!resOutcome.ok && mode === "warn") {
instruments.counters["api.validation.failures_total"].add(1, {
where: "response.warn",
plugin: route.plugin,
route: route.path,
});
logger.error("response validation warning", {
code: resOutcome.failure.code,
});
// continue with the handler's original response
}
```
Tests can invoke either function directly with a minimal `ErrorBoundary` to confirm step-level wiring without setting up the full pipeline.
## API Reference
```ts
type RequestValidationOutcome =
| { ok: true; value: { body: unknown; query: Readonly<Record<string, string>> } }
| { ok: false; failure: BoundaryFailure };
type ResponseValidationOutcome =
| { ok: true }
| { ok: false; failure: BoundaryFailure };
function validateRequest(
boundary: ErrorBoundary,
request: Request,
route: PipelineRoute,
query: Readonly<Record<string, string>>,
span: BoundarySpan
): Promise<RequestValidationOutcome>;
function validateResponse(
boundary: ErrorBoundary,
route: PipelineRoute,
response: Response,
span: BoundarySpan
): Promise<ResponseValidationOutcome>;
```
## Implementation Notes
Both functions delegate every error path to `HostError` so the boundary can map them to `BoundaryFailure` consistently. Direct `throw new Error(...)` inside the closure would bypass the host-code registry and surface as `HOST.HANDLER_THREW` instead of the more specific `HOST.REQUEST_VALIDATION_FAILED` / `HOST.RESPONSE_VALIDATION_FAILED` — the audit and metric labels for those two paths are spec-required.
`validateResponse` clones the response before reading the body so the original stream remains intact for downstream consumers (the failure-response builder, the audit envelope, the eventual return to the caller). Failing to clone would empty the body and the caller would serialise an empty payload.
Streaming routes (`route.options.streaming === true`) skip response validation entirely. Streaming bodies are by definition unbuffered, and forcing a `.clone().json()` on them would defeat the streaming intent and risk OOMing on long-lived responses.

View File

@@ -0,0 +1,140 @@
# `RequestPipeline` — §6 11-step per-request driver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RequestPipeline` implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resolve, RBAC gate, rate limit, idempotency, request validation, plugin handler, response validation, audit emission, and telemetry close. The order matters — every later step assumes earlier steps' invariants, and reordering would silently regress the security boundary.
> The driver was split into sibling modules during the foundation Wave-2 prep (pipeline split per CLAUDE.md file-size budget; behaviour unchanged). The class in this file is the public entry point and constructor; the orchestration body lives in `request-pipeline-driver.ts`, the per-step gates in `request-pipeline-steps.ts`, the Zod validators in `request-pipeline-validation.ts`, the audit / telemetry / failure-response builders in `request-pipeline-emitters.ts`, and pure helpers (request-id, URL parse, body reader, ctx builder) in `request-pipeline-helpers.ts`. Every existing test passes against the split without modification — that is the success metric.
Every step is wrapped by an `ErrorBoundary` so a throw at any point yields a fully-shaped `ErrorEnvelope` instead of an unhandled promise rejection. Tests + production both go through `pipeline.handle(request)` and inspect the returned `PipelineResult.response` / `.envelope`.
## Features
- Tenant resolver chain is honoured first — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400) before any other work happens.
- Session resolver runs against the configured `SessionResolverChain`; resolver throws map to `HOST.SESSION_REQUIRED` via the boundary.
- CSRF runs only on non-GET/HEAD requests with `csrf: true`, and is skipped on `streaming: true` routes per design.
- RBAC compares `principal.scopes` to `route.options.auth`. Missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total` counter.
- Rate limit bucket keyed by principal id (when present) else tenant id; bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429).
- Idempotency-Key consumed for `idempotent: true` routes; replay → `HOST.IDEMPOTENCY_REPLAY` (409).
- Zod request validation: body (JSON only), params, query. Failure → `HOST.REQUEST_VALIDATION_FAILED` (400) carrying up to 10 Zod issues in `details`.
- Plugin handler invoked with a `RequestContext` carrying capabilities, principal, tenant, span, request id, and the parsed body/params/query attached as discoverable fields.
- Response validation strictness `enforce` (default) → 500 on mismatch; `warn` → success path + `api.validation.failures_total{where=response.warn}`.
- Audit envelope emitted for `auditable: true` routes (admin-API sink shared by default).
- Per-request OTel span tree: `api.request` root + `plugin.handler` child. Counter + histogram emitted on every response.
## Usage: Basic
```ts
import { RequestPipeline } from "@suaveplan/api-host/request-pipeline";
import { createHostInstruments } from "@suaveplan/api-host/otel";
const pipeline = new RequestPipeline(
{
router,
auth: sessionResolverChain,
tenant: tenantResolverChain,
capabilities,
rateLimit,
idempotency,
csrf,
audit,
logger,
span: spanFactory,
},
createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" })
);
const result = await pipeline.handle(request);
return result.response;
```
## Usage: Advanced
`responseValidation: "warn"` is the recommended dev-mode setting — the
handler-supplied response propagates even on a schema mismatch but the
counter spike makes the violation visible:
```ts
const pipeline = new RequestPipeline(
{ ...adapters, responseValidation: "warn" },
instruments
);
```
Custom `requestId` + `now` injection point lets snapshot tests stay
deterministic:
```ts
let clock = 0;
const pipeline = new RequestPipeline(
{
...adapters,
now: () => (clock += 1),
requestId: () => "01J5DETERMINISTIC",
},
instruments
);
```
For streaming routes (`options.streaming === true`), request/response
validation buffering is bypassed and the handler's `Response` body
flows through unchanged — `X-Request-Id` is the only header the
pipeline appends.
## API Reference
```ts
class RequestPipeline {
constructor(
options: RequestPipelineOptions,
instruments: HostInstruments
);
handle(request: Request): Promise<PipelineResult>;
}
interface PipelineResult {
response: Response;
status: number;
route: string | null;
plugin: string | null;
outcome: "success" | "failure";
envelope?: ErrorEnvelope;
tenantId: TenantId | null;
}
const PIPELINE_STEPS: readonly PipelineStepName[];
```
## Implementation Notes
The pipeline runs route-match *first* (technically step 2 in §6) so
later step gates (`auth`, `csrf`, `rateLimit`, `idempotent`) can
consult per-route policy. The §6 ordering of "intake → match" is
preserved at the engine adapter; this driver assumes the intake has
already happened (we receive a fully-formed `Request`). Step 1
"HTTP intake" is the router adapter's job and outside the pipeline's
surface.
Tenant resolution returns a stub `{ id: "public", slug: "public" }`
context for public routes that failed to resolve — keeping downstream
ctor invariants ($ctx.tenant$ is never null) at the cost of one
sentinel that's easy to recognise and filter from telemetry. Non-public
routes still return 400 on `null`.
Per-step counters are derived directly from the boundary failure: the
boundary emits `api.errors_total` for every captured throw, the
pipeline adds the dedicated counters (`api.auth.failures_total`,
`api.rbac.denials_total`, `api.tenant.unresolved_total`,
`api.rate_limit.exceeded_total`, `api.idempotency.replays_total`,
`api.validation.failures_total`) so a §12.3 spec slice is one counter
lookup, not a regex over `code` labels.
Response validation runs after the handler returns. JSON-shape
mismatches → 500 (enforce) / counter (warn). Non-JSON responses skip
validation by design — a `Response` of an arbitrary media type is the
plugin's contract with the engine.

View File

@@ -0,0 +1,73 @@
# reserved-prefix
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The reserved-prefix contract publishes the canonical list of URL path prefixes that `@suaveplan/api-host` (and every first-party engine that ships under the host) considers reserved. Without it, downstream adapters such as `@suaveplan/api-auth-better-auth`, `@suaveplan/api-plugin-rbac`, or any user-authored plugin would hard-code a private snapshot of host-internal routes and silently drift the moment a new host module adds an endpoint.
This module is the single source of truth: it exposes the frozen `HOST_RESERVED_PREFIXES` array, the `isReservedPrefix` predicate, the `normalisePrefix` canonicalisation helper, and the `assertPrefixAvailable` assertion that throws a consumer-supplied error on collision. Adapters consult it before mounting their own routes and refuse to install — surfacing their own code namespace (`AUTH_BA_ROUTE_PREFIX_COLLISION`, `RBAC_ROUTE_PREFIX_COLLISION`, …) — when an operator-configured mount path collides with a host-reserved route.
## Features
- Frozen `ReadonlyArray<string>` of always-reserved prefixes (`/_host`, `/internal`).
- Case-insensitive, trailing-slash-tolerant, leading-slash-tolerant prefix matching.
- Exact-match AND child-path detection (`/internal/livez` is also reserved).
- `assertPrefixAvailable(prefix, factory)` throws the factory-supplied error lazily — the factory is only invoked on collision so adapters can defer expensive context construction to the failure path.
- Branded `ReservedPrefix` type tagging values that have been canonicalised through `normalisePrefix`, so type-aware pipelines can prove their input is in the canonical form without re-normalising at every hop.
- Published as a `./reserved-prefix` subpath export — adapters import only the predicate surface without pulling in the full host bundle.
## Usage: Basic
```ts
import {
HOST_RESERVED_PREFIXES,
isReservedPrefix,
} from "@suaveplan/api-host/reserved-prefix";
if (isReservedPrefix("/internal/foo")) {
throw new Error("cannot mount under a host-reserved prefix");
}
console.log(HOST_RESERVED_PREFIXES); // ["/_host", "/internal"]
```
## Usage: Advanced
Adapter-side, the typical wiring uses `assertPrefixAvailable` with the adapter's own error code namespace:
```ts
import { assertPrefixAvailable } from "@suaveplan/api-host/reserved-prefix";
import { SuaveplanError } from "@suaveplan/error";
import { AUTH_BA_CODES } from "@suaveplan/api-auth-better-auth/codes";
export function mountBetterAuth(prefix: string): void {
assertPrefixAvailable(prefix, (collision) =>
new SuaveplanError(AUTH_BA_CODES.ROUTE_PREFIX_COLLISION, {
messageKey: "error.auth_ba.route_prefix_collision",
context: { requested: prefix, collision },
})
);
// …safe to register routes under `prefix` now.
}
```
The `normalisePrefix` helper is also exported so adapters that store an operator-supplied mount prefix at configuration time can canonicalise it once and rely on the form for subsequent comparisons.
## API Reference
- `HOST_RESERVED_PREFIXES: ReadonlyArray<string>` — frozen, deduped, normalised list of always-reserved prefixes.
- `isReservedPrefix(prefix: string): boolean` — `true` when `prefix` (after normalisation) equals or is a child path of any reserved entry.
- `normalisePrefix(prefix: string): string` — lowercases, prepends `/`, collapses trailing slashes; empty input → `/`.
- `assertPrefixAvailable(prefix, codeFactory)` — invokes `codeFactory(normalisedCollision)` and throws when reserved; no-op otherwise.
- `brandPrefix(prefix: string): ReservedPrefix` — normalises and returns the value retyped as the `ReservedPrefix` brand.
- `type ReservedPrefix` — branded string carrying the proof of canonicalisation.
- `type ReservedPrefixErrorFactory` — `(collision: string) => Error`.
## Implementation Notes
The list is initialised by feeding `["/_host", "/internal"]` through `normalisePrefix`, then deduping via `Set`, then `Object.freeze` so the returned array refuses mutation at runtime (strict mode throws `TypeError`). Predicate matching is O(n) over the (currently 2-entry) reserved list; for child-path matching the implementation uses `startsWith(`${reserved}/`)` rather than `startsWith(reserved)` to avoid the `/internal` reserves `/internals` false positive.
Engine-conditional entries (`/admin`, `/graphql`, `/ws`, `/sse`) are intentionally NOT in the static list. They will land via a follow-up `api-host-reserved-prefix-dynamic` capability that inspects the active host registry — keeping the static contract immutable and runtime-deterministic. The branded `ReservedPrefix` type uses a `unique symbol` field; the brand never materialises at runtime, so `JSON.stringify` of a branded value is identical to its underlying string.

View File

@@ -0,0 +1,103 @@
# `createTestHost`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createTestHost` is The keystone of the `@suaveplan/api-host/testing` subpath. It composes every in-memory adapter shipped by the API plugin system — `api-router-in-memory`, `api-auth-anonymous` / `api-auth-static-principal`, `api-db-sqlite-memory`, `api-events-in-memory`, `api-ws-broker-in-memory`, `api-tenant-provider-memory`, `api-secrets-env` — into a single line of consumer code. Plugin authors point their integration suites at `await createTestHost(...)` to exercise the §6 request pipeline end-to-end without standing up a single external service.
The returned `TestHost` extends the regular `Host` surface with four helpers: `installFixturePlugin`, `fetchAs`, `waitForEvent`, and `dispose`. Every helper is built on the public adapter surface, so a test that installs a plugin, fires a request, and asserts on event emission can be written in fewer than 20 lines.
## Features
- Zero-config bootstrap — `await createTestHost()` returns a live host with every adapter in place.
- Per-slot override — pass any `HostAdapters` field through `config.adapters` to swap an in-memory default for a custom one.
- Seed-driven defaults — `seed.principal` / `seed.tenants` / `seed.secrets` populate the corresponding in-memory adapter.
- `installFixturePlugin({ spec, routes })` — wraps `definePlugin` with the pipeline-router registration in one call.
- `fetchAs(principal, request)` — issue a request as any principal without re-wiring the auth chain.
- `waitForEvent(topic, timeoutMs?)` — block until an in-memory bus publish lands, with deadline rejection.
- `dispose()` — idempotent teardown of every wired in-memory resource (db, ledger, ws-broker, event bus, host).
## Usage: Basic
```ts
import { createTestHost } from "@suaveplan/api-host/testing";
import { definePlugin } from "@suaveplan/api-host/define-plugin";
const host = await createTestHost();
const spec = definePlugin({
name: "fx",
version: "0.1.0",
requires: [] as const,
isolation: "trusted",
trustLevel: "first-party",
start: () => undefined,
});
await host.installFixturePlugin({
spec,
routes: [{ method: "GET", path: "/ping", handler: () => ({ ok: true }) }],
});
const res = await host.fetch(new Request("http://t/ping"));
await host.dispose();
```
## Usage: Advanced
```ts
import { createTestHost } from "@suaveplan/api-host/testing";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
const principal: Principal = {
id: "u-1",
scopes: ["write"],
tenantId: null,
claims: {},
};
const host = await createTestHost({
seed: {
principal,
tenants: [
{
id: "t-1" as TenantId,
slug: "main",
status: "active",
createdAt: new Date().toISOString(),
},
],
secrets: { STRIPE_KEY: "sk_test_..." },
},
});
// `ctx.principal`, `ctx.tenant`, and `ctx.secrets.require("STRIPE_KEY")`
// all resolve inside every installed plugin's route handlers.
host.adapters.eventBus.publish("billing.charge", { amount: 100 });
const payload = await host.waitForEvent("billing.charge", 500);
await host.dispose();
```
## API Reference
- `createTestHost(config?: TestHostConfig): Promise<TestHost>` — boot + start a wired host.
- `TestHostConfig.plugins` — pre-installed plugins (same shape as `HostConfig.plugins`).
- `TestHostConfig.adapters` — partial `HostAdapters` override; each field beats the corresponding default.
- `TestHostConfig.seed` — defaults for the in-memory adapters: `principal`, `tenants`, `secrets`, `defaultTenant`.
- `TestHost.adapters.host` — full `HostAdapters` bag in use.
- `TestHost.adapters.eventBus` — `InMemoryEventsBus` from `@suaveplan/api-events-in-memory`.
- `TestHost.adapters.lifecycleEvents` — `EventBus<LifecycleEventMap>` for lifecycle assertion.
- `TestHost.adapters.fixtureRoutes` — flat list of routes registered via `installFixturePlugin`.
- `TestHost.installFixturePlugin(fixture)` — install a `definePlugin` spec + pipeline routes.
- `TestHost.fetchAs(principal, request)` — auth-overridden request driver.
- `TestHost.waitForEvent(topic, timeoutMs?)` — `Promise<unknown>` resolved with first matching event payload.
- `TestHost.dispose()` — idempotent teardown.
## Implementation Notes
The factory wires every default adapter behind an override gate — if a consumer passes `adapters: { router: customRouter }`, the in-memory router is never constructed and never registered as the adapter. Override resolution is per-slot, so a test can keep the in-memory db while replacing the auth chain.
`fetchAs` builds a transient companion host with an overridden auth resolver, shares the same router instance so registered routes match, and disposes the companion after the request completes. The shared router means routes installed on the primary host are visible to `fetchAs` without re-installation.
`waitForEvent` first checks the bus's recorded history (so events emitted before `waitForEvent` was awaited still resolve), then subscribes for future emissions. The subscription is disposed in both the resolve and timeout paths.
`dispose` closes the dedicated `bun:sqlite` ledger handle, the in-memory db (when it exposes `close()`), the ws-broker, the event bus, and stops the wrapped host. Calling `dispose` twice is a no-op — `disposed` is gated by a private flag.

View File

@@ -0,0 +1,91 @@
# `pipeline-events-conformance` — `PipelineEventsBus` conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This harness drives a host factory's `RequestPipeline` through a known-shape happy-path request and a known-shape 404 request, then asserts the four invariants every host MUST satisfy for the per-stage observability contract to be useful: (1) every stage emits one `entered` + one exit, (2) the emit order matches `PIPELINE_STAGE_ORDER`, (3) conditionally-skipped stages emit `skipped` rather than silent omission, and (4) a stage rejection halts subsequent stage emission.
Plugin authors run this harness against their own host wiring (via `createTestHost` or a hand-rolled factory) to gain the same level of confidence the host's own unit tests provide. The harness is also wired into `@suaveplan/api-host`'s own test suite to confirm the in-package implementation conforms to its own contract.
## Features
- Single `runPipelineEventsConformance({ buildHost })` entry point that registers four describe/it blocks.
- Invariant 1 — per-stage emit: every of the 11 stages emits exactly one `entered` and exactly one exit.
- Invariant 2 — order: the `entered` events arrive in the canonical `PIPELINE_STAGE_ORDER` sequence.
- Invariant 3 — skip: csrf / capability / idempotency / audit each emit `skipped` on the open-route happy path.
- Invariant 4 — rejection: the 404 path produces a `route-match` rejection and zero `entered` events for any later stage.
- Per-scenario isolation — each invariant builds a fresh host via the factory so cross-scenario leakage cannot mask a regression.
## Usage: Basic
```ts
import { runPipelineEventsConformance } from "@suaveplan/api-host/testing/pipeline-events-conformance";
import { createTestHost } from "@suaveplan/api-host/testing";
runPipelineEventsConformance({
buildHost: async () => {
const host = await createTestHost({ /* ... */ });
await host.installFixturePlugin({ /* probe route */ });
return {
fetch: (req) => host.fetch(req),
dispose: () => host.dispose(),
bus: host.adapters.pipelineEvents,
};
},
});
```
## Usage: Advanced
The harness expects the host factory to register an open route at `GET /__conformance/probe` that returns a successful response. The probe MUST NOT require auth (so the `capability` stage skips), MUST NOT be marked `auditable` (so the `audit` stage skips), and MUST NOT be marked `idempotent` (so the `idempotency` stage skips). The harness asserts these skips happen.
For hosts that need richer scenarios (e.g. asserting auth rejection halts subsequent stages), wrap the harness in additional describe blocks:
```ts
describe("my-host PipelineEvents", () => {
runPipelineEventsConformance({ buildHost: makeHostFactory() });
it("auth rejection halts subsequent stages", async () => {
const host = await makeHostFactory()();
try {
await host.fetch(new Request("http://h/protected"));
const rejected = host.bus.events.find((e) => e.outcome === "rejected");
expect(rejected?.stage).toBe("auth");
} finally {
await host.dispose();
}
});
});
```
## API Reference
```ts
export interface ConformanceHost {
fetch(request: Request): Promise<Response>;
dispose(): Promise<void>;
readonly bus: PipelineEventsBus & {
readonly events: readonly PipelineStageEvent[];
byRequest(requestId: string): PipelineStageEvent[];
};
}
export type ConformanceHostFactory = () => Promise<ConformanceHost>;
export interface ConformanceConfig {
readonly buildHost: ConformanceHostFactory;
}
export function runPipelineEventsConformance(config: ConformanceConfig): void;
```
## Implementation Notes
The harness uses `beforeAll` + `afterAll` for the shared happy-path bus capture so the three happy-path invariants share one host invocation. Invariant 4 (rejection) needs a separate host (the 404 path drives a different code path) so it builds its own factory instance and disposes it explicitly.
The factory contract returns a host with a `bus` field structurally compatible with `PipelineEventsBus` plus `events` and `byRequest`. The narrowing intersection avoids forcing the harness to import `InMemoryPipelineEventsBus` concretely — any implementation that exposes the same surface conforms.
Invariant 2's strict equality against `PIPELINE_STAGE_ORDER` is intentional: a host that emits stages in a different order is broken regardless of whether the final response is correct. Allowing partial ordering would hide real regressions where two stages got swapped during a refactor.

View File

@@ -0,0 +1,62 @@
# `./testing` — RESERVED subpath
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-host/testing` is a **reserved subpath** in API plugin system. The intent is to populate it with `createTestHost` + the in-memory adapter wiring (`api-router-in-memory`, `api-auth-in-memory`, `api-db-sqlite-memory`, etc.). Stage 2 ships an intentionally-empty surface so downstream packages can declare a peer-dep on the documented path today, and bundler / TypeScript resolution succeeds before the helper actually exists. The package's primary build does not have to wait on The test harness.
The single named export, `STAGE_2_RESERVED`, is a sentinel marker — Stage 5 will replace it with the populated `createTestHost` API while keeping the same subpath. Probes that need to discriminate "reserved-but-empty Stage 2 release" from "Stage 5 populated surface" can branch on `STAGE_2_RESERVED === true`.
## Features
- Stable subpath export — `@suaveplan/api-host/testing` resolves in every release ≥ 0.1.0.
- Single named sentinel (`STAGE_2_RESERVED`) so the surface has zero runtime side effects.
- Type-checked at consumer build time without a runtime dependency on the host's drivers.
- Documented promotion path — Stage 5 replaces the sentinel with the real harness.
## Usage: Basic
```ts
import { STAGE_2_RESERVED } from "@suaveplan/api-host/testing";
if (STAGE_2_RESERVED) {
// Hosting environment is on the reserved-but-empty Stage-2 release.
throw new Error(
"api-host/testing requires Stage-5 helpers — install once they ship."
);
}
```
## Usage: Advanced
A downstream package that wants to consume the helper as soon as Stage 5 ships can declare an optional peer-dep on the subpath and gate-import it at runtime:
```ts
// in package.json
// "peerDependencies": { "@suaveplan/api-host": "^0.1.0" }
// "peerDependenciesMeta": { "@suaveplan/api-host": { "optional": true } }
// in code
const { STAGE_2_RESERVED, createTestHost } = (await import(
"@suaveplan/api-host/testing"
)) as typeof import("@suaveplan/api-host/testing") & {
createTestHost?: () => unknown;
};
if (!STAGE_2_RESERVED && createTestHost !== undefined) {
const host = createTestHost();
// ...
}
```
## API Reference
- `const STAGE_2_RESERVED: true` — sentinel; replaced with `false` (and populated exports) .
- `type Stage2Reserved = typeof STAGE_2_RESERVED` — kept stable so Stage 5 can layer in real types on the same path.
## Implementation Notes
The empty placeholder is not a stub. It is a deliberate subpath reservation documented in the proposal (§What changes → `./testing`) so consumers do not race ahead of Stage 5 and re-implement the helper out-of-tree. Replacing it later is a non-breaking change: the runtime surface grows, the type surface widens, and the sentinel flips from `true` to `false`. No consumer that wires only on the sentinel needs to update.