first commit
This commit is contained in:
118
docs/api-stack/api/api-host/README.md
Normal file
118
docs/api-stack/api/api-host/README.md
Normal 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
|
||||
|
||||
127
docs/api-stack/api/api-host/src/admin-api/admin-api.md
Normal file
127
docs/api-stack/api/api-host/src/admin-api/admin-api.md
Normal 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.
|
||||
73
docs/api-stack/api/api-host/src/admin-api/audit-sink.md
Normal file
73
docs/api-stack/api/api-host/src/admin-api/audit-sink.md
Normal 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.
|
||||
@@ -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.
|
||||
64
docs/api-stack/api/api-host/src/admin-api/rate-limiter.md
Normal file
64
docs/api-stack/api/api-host/src/admin-api/rate-limiter.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
114
docs/api-stack/api/api-host/src/error-boundary/error-boundary.md
Normal file
114
docs/api-stack/api/api-host/src/error-boundary/error-boundary.md
Normal 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.
|
||||
69
docs/api-stack/api/api-host/src/error-codes/error-codes.md
Normal file
69
docs/api-stack/api/api-host/src/error-codes/error-codes.md
Normal 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.
|
||||
157
docs/api-stack/api/api-host/src/host/host.md
Normal file
157
docs/api-stack/api/api-host/src/host/host.md
Normal 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.
|
||||
101
docs/api-stack/api/api-host/src/hot-reload/file-watcher.md
Normal file
101
docs/api-stack/api/api-host/src/hot-reload/file-watcher.md
Normal 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.
|
||||
154
docs/api-stack/api/api-host/src/hot-reload/hot-reload.md
Normal file
154
docs/api-stack/api/api-host/src/hot-reload/hot-reload.md
Normal 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.
|
||||
188
docs/api-stack/api/api-host/src/lifecycle/lifecycle.md
Normal file
188
docs/api-stack/api/api-host/src/lifecycle/lifecycle.md
Normal 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`.
|
||||
86
docs/api-stack/api/api-host/src/otel/otel.md
Normal file
86
docs/api-stack/api/api-host/src/otel/otel.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
83
docs/api-stack/api/api-host/src/plugin-ledger/migrations.md
Normal file
83
docs/api-stack/api/api-host/src/plugin-ledger/migrations.md
Normal 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.
|
||||
121
docs/api-stack/api/api-host/src/plugin-ledger/plugin-ledger.md
Normal file
121
docs/api-stack/api/api-host/src/plugin-ledger/plugin-ledger.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
62
docs/api-stack/api/api-host/src/testing/testing.md
Normal file
62
docs/api-stack/api/api-host/src/testing/testing.md
Normal 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.
|
||||
Reference in New Issue
Block a user