Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
# @suaveplan/api-sandbox-vm-host-bridge - Host-integration bridge that wraps a @suaveplan/api-sandbox-vm VmSandboxHandle as a PluginModule + PipelineRoute[] bundle for @suaveplan/api-host.installPlugin. Marshals host Request → SerializedRequest into the vm.Context, unmarshals SerializedResponse → host Response, delegates start/dispose lifecycle to the sandbox handle, and surfaces sandbox faults via the SAND_VM_BRIDGE.* code registry.
Host-integration bridge that lets a `VmSandboxHandle` from
`@suaveplan/api-sandbox-vm` install into `@suaveplan/api-host` as a
first-class plugin.
<!-- begin:auto-docs -->
## Documentation tree
- [bridge](./src/bridge/bridge.md) — This module is the package's flagship factory: `createSandboxedVmPluginModule({ sandbox, manifest, registry })` → `Brid…
- [bridge](./src/bridge/bridge.md) — This module is the package's flagship factory: `createSandboxedVmPluginModule({ sandbox, manifest, registry })` → `Brid…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used by every public failure path inside the host br…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used by every public failure path inside the host br…
- [instruments](./src/instruments/instruments.md) — This module owns the OTel instrument bundle the bridge emits. Splitting the bundle off the bridge module makes the metr…
- [instruments](./src/instruments/instruments.md) — This module owns the OTel instrument bundle the bridge emits. Splitting the bundle off the bridge module makes the metr…
- [lifecycle](./src/lifecycle/lifecycle.md) — This module owns the idempotency wrapper around a `VmSandboxHandle`'s `start` / `dispose` lifecycle. The host's lifecyc…
- [lifecycle](./src/lifecycle/lifecycle.md) — This module owns the idempotency wrapper around a `VmSandboxHandle`'s `start` / `dispose` lifecycle. The host's lifecyc…
- [marshal-request](./src/marshal/marshal-request.md) — This module turns the host's per-request `RequestContext` into a plain-data `SerializedRequest` value that the sandbox…
- [marshal-response](./src/marshal/marshal-response.md) — This module turns the value returned by a sandboxed handler into a host `Response`. The sandbox-side handler returns a…
- [route-discovery](./src/route-discovery/route-discovery.md) — This module owns the route-discovery protocol that lets a sandboxed plugin tell the host which HTTP routes it wants to…
- [route-discovery](./src/route-discovery/route-discovery.md) — This module owns the route-discovery protocol that lets a sandboxed plugin tell the host which HTTP routes it wants to…
- [vm-host-bridge-conformance](./src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.md) — This module ships `runVmHostBridgeConformance({ buildBridge })` — a five-invariant conformance suite that every concret…
- [vm-host-bridge-conformance](./src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.md) — This module ships `runVmHostBridgeConformance({ buildBridge })` — a five-invariant conformance suite that every concret…
<!-- end:auto-docs -->
## What it does
A `VmSandboxHandle` is a frozen `vm.Context` running a `SourceTextModule`
with permission-gated `__suaveplan` capabilities — but it is NOT a
`PluginModule`. The host's `installPlugin({ module, manifest, routes })`
takes a plain TS object with `start/stop/dispose/health` hooks plus a
`PipelineRoute[]` registration list. This package builds that
`PluginModule + routes` bundle by:
1. Asking the sandbox (via a host-installed `bridge.registerRoute`
capability) for the plugin's route registrations.
2. Wrapping every registered handler in a marshal closure that turns the
host `Request` into a `SerializedRequest`, calls the sandbox-side
handler, and turns the returned `SerializedResponse` into a host
`Response`.
3. Delegating `start()` / `dispose()` to the underlying sandbox handle,
idempotently.
## Quickstart
```ts
import {
createSandboxedVmPluginModule,
createBridgeRouteRegistry,
} from "@suaveplan/api-sandbox-vm-host-bridge";
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
const registry = createBridgeRouteRegistry();
const sandbox = createVmSandbox({
pluginId: "demo",
pluginModule: { kind: "source", identifier: "demo.mjs", source },
checker, // PermissionChecker
proxies: {
fs,
fetch,
capabilities: { bridge: registry.capability },
},
logger,
});
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest,
registry,
});
await host.installPlugin({
module: bridged.module,
manifest,
manifestHash,
specifier,
routes: bridged.routes,
});
```
## Error codes
`SAND_VM_BRIDGE.*` — five stable codes covering plugin-surface
discovery (`INVALID_PLUGIN_SURFACE`), wire marshalling (`MARSHAL_FAILED`
/ `UNMARSHAL_FAILED`), handler throws (`HANDLER_REJECTED`), and
lifecycle delegation (`LIFECYCLE_FAILED`).
## Conformance
`@suaveplan/api-sandbox-vm-host-bridge/testing` ships
`runVmHostBridgeConformance({ buildBridge })` with the five invariants
the spec mandates: route registration, request round-trip, idempotent
lifecycle, error propagation with `cause` chain, and capability
passthrough including denied capability surfacing `SAND_PERM.DENIED`.

View File

@@ -0,0 +1,113 @@
# bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the package's flagship factory:
`createSandboxedVmPluginModule({ sandbox, manifest, registry })` →
`BridgedPlugin` — a frozen object whose `module` is a
`PluginModule`-shaped lifecycle bundle and whose `routes` is a
materialised `PipelineRoute[]` ready to hand to
`host.installPlugin({ module, manifest, routes, ... })`.
The factory drives the underlying sandbox's `start()` through the
idempotent lifecycle forwarder, asserts the sandbox registered at
least one route via the `bridge.registerRoute` capability, and wraps
every registered handler in a marshal-invoke-unmarshal closure so the
host's request pipeline can dispatch into the sandboxed plugin as if
it were a regular trusted plugin.
## Features
- Lifecycle delegation — `module.start()`, `module.stop()`,
`module.uninstall()` thread through the lifecycle forwarder so the
host's lifecycle driver sees consistent idempotent semantics.
- Route materialisation — every sandbox registration becomes a frozen
`PipelineRoute` with the plugin id, method (already upper-cased),
path, options (defaulted, overridable), wrapped handler, empty
params, and forwarded status map.
- Marshal closure per route — host `Request` → `SerializedRequest` →
sandbox handler → `SerializedResponse` → host `Response`.
- Empty-registry guard — sandboxes that registered no routes raise
`INVALID_PLUGIN_SURFACE` so the host never mounts an empty plugin.
- OTel attribution — each step (marshal, handler invocation,
unmarshal) increments the relevant counter on the bridge's OTel
bundle.
- Cause-chain preservation — sandbox throws surface as
`HANDLER_REJECTED` with the original error preserved as `cause`.
## Usage: Basic
```ts
import { createSandboxedVmPluginModule, createBridgeRouteRegistry }
from "@suaveplan/api-sandbox-vm-host-bridge";
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
const registry = createBridgeRouteRegistry();
const sandbox = createVmSandbox({
pluginId: "demo",
pluginModule: { kind: "source", identifier: "demo.mjs", source },
checker,
proxies: { fs, fetch, capabilities: { bridge: registry.capability } },
logger,
});
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest,
registry,
});
await host.installPlugin({
module: bridged.module,
manifest,
manifestHash,
specifier,
routes: bridged.routes,
});
```
## Usage: Advanced
Supply per-route option overrides keyed by `"METHOD /path"`:
```ts
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest,
registry,
routeOptions: {
"POST /upload": { streaming: true, auth: "tenant.write" },
"GET /healthz": { auth: null, csrf: false },
},
statusMap: { 200: 201 },
});
```
## API Reference
- `interface BridgedPluginModule<Caps>` — frozen lifecycle bundle.
- `interface BridgedPlugin<Caps>` — `module`, `routes`, `dispose`,
`lifecycle`.
- `interface CreateSandboxedVmPluginModuleOptions` — `sandbox`,
`manifest`, `registry`, `routeOptions?`, `statusMap?`.
- `createSandboxedVmPluginModule<Caps>(options):
Promise<BridgedPlugin<Caps>>` — async factory; runs
`lifecycle.start()` synchronously before returning.
## Implementation Notes
The factory deliberately freezes both the returned `BridgedPlugin` and
its nested `module` / `routes` so the host's lifecycle driver cannot
patch a route's handler after install. The wrapped handler captures
the OTel duration histogram across the whole marshal-invoke-unmarshal
cycle — this surfaces back-pressure under load as a single metric the
operator can alert on without instrumenting each sub-step
individually.
The `requires` generic on `BridgedPluginModule` is allowed to be empty
because the bridge owns the only capability the sandbox needs (`bridge`);
plugins that declare other capability requirements (db, secrets) thread
those through the sandbox via the host's existing capability map — the
bridge itself is unaware of them.

View File

@@ -0,0 +1,89 @@
# Error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable `SAND_VM_BRIDGE.*` error-code registry used
by every public failure path inside the host bridge. The registry is
built once via `defineCodes("SAND_VM_BRIDGE", […])` so the namespace
prefix and per-code suffix are co-derived — that guarantee lets the
host's error boundary, audit sink, and the conformance harness branch
on literal code strings without ever decoding free-form messages.
Side-effect-importing this module from `src/index.ts` ensures every
consumer that imports the package barrel (or any subpath export)
participates in the same registration that the
`scripts/conformance.ts` audit walks.
## Features
- Five stable codes — `INVALID_PLUGIN_SURFACE`, `MARSHAL_FAILED`,
`UNMARSHAL_FAILED`, `HANDLER_REJECTED`, `LIFECYCLE_FAILED` — each
mapped to exactly one failure surface inside the bridge.
- A typed `SandboxVmBridgeError` subclass of `SuaveplanError` so
consumers may discriminate via `instanceof` or by `code` string.
- A public constructor (`new SandboxVmBridgeError(code, message,
options?)`) so consumers can synthesise the error at the host edge
when re-wrapping a non-`SuaveplanError` cause.
## Usage: Basic
```ts
import {
SAND_VM_BRIDGE_CODES,
SandboxVmBridgeError,
} from "@suaveplan/api-sandbox-vm-host-bridge/error-codes/error-codes";
throw new SandboxVmBridgeError(
SAND_VM_BRIDGE_CODES.MARSHAL_FAILED,
"request body type is not supported by the bridge marshal layer"
);
```
## Usage: Advanced
Preserve the sandbox-side cause so the host's error boundary can walk
the chain back to the original `SandboxVmError`:
```ts
import {
SAND_VM_BRIDGE_CODES,
SandboxVmBridgeError,
} from "@suaveplan/api-sandbox-vm-host-bridge/error-codes/error-codes";
try {
await invokeHandler();
} catch (err) {
throw new SandboxVmBridgeError(
SAND_VM_BRIDGE_CODES.HANDLER_REJECTED,
"sandboxed handler threw",
{ cause: err, context: { route: "/foo", method: "GET" } }
);
}
```
## API Reference
- `SAND_VM_BRIDGE_CODES` — frozen record mapping enum key (e.g.
`HANDLER_REJECTED`) to the namespaced string code
(`"SAND_VM_BRIDGE.HANDLER_REJECTED"`).
- `type SandboxVmBridgeCode` — literal union of every value in
`SAND_VM_BRIDGE_CODES`.
- `class SandboxVmBridgeError extends SuaveplanError<SandboxVmBridgeCode>`
— public constructor `(code, message, options?)`. `options.cause`
preserves the original sandbox throw for the host's cause-chain
walker.
## Implementation Notes
The registry is declared `as const` so the compiler retains literal
types — essential for exhaustive `switch` narrowing in the bridge's
internal dispatch. The class sets `this.name = "SandboxVmBridgeError"`
so heap snapshots, sourcemaps and uncaught-exception handlers
attribute failures to this package rather than the generic
`SuaveplanError` superclass. Registry ordering reflects the request
lifecycle: discovery first, then per-request marshal failures, then
the in-handler reject, then lifecycle failures last.

View File

@@ -0,0 +1,84 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OTel instrument bundle the bridge emits. Splitting
the bundle off the bridge module makes the metric names a single
source of truth (no fan-out of `meter.createCounter(...)` calls across
the package) and gives the conformance harness a `reset…` entry point
to clear cached instruments between scenarios.
The bundle is built lazily and cached so the first bridge invocation
takes the meter-binding cost; every subsequent call reuses the
instrument handles. Tests that need a clean slate per scenario call
the reset helper in their `beforeEach`.
## Features
- Four instruments — `requestsMarshalledTotal`,
`marshalFailuresTotal`, `handlerRejectionsTotal`,
`bridgeInvocationDurationMs`.
- Stable metric names — all four namespaced under
`sandbox_vm_bridge.*` so dashboards can filter the bridge's metrics
separately from the underlying sandbox host's metrics.
- Lazy binding — meter creation is deferred until first use, so a
consumer that imports the module without ever invoking the bridge
pays no OTel cost.
- Test-friendly reset — `resetSandboxVmBridgeInstruments()` drops the
cached bundle so the next `getSandboxVmBridgeInstruments()` call
re-binds against a freshly-installed `MeterProvider`.
## Usage: Basic
```ts
import { getSandboxVmBridgeInstruments } from "@suaveplan/api-sandbox-vm-host-bridge/instruments/instruments";
getSandboxVmBridgeInstruments().requestsMarshalledTotal.add(1, {
plugin: "demo",
route: "/foo",
method: "GET",
});
```
## Usage: Advanced
```ts
import {
getSandboxVmBridgeInstruments,
resetSandboxVmBridgeInstruments,
} from "@suaveplan/api-sandbox-vm-host-bridge/instruments/instruments";
beforeEach(() => {
resetSandboxVmBridgeInstruments();
// ...install a fresh OTel MeterProvider here so the next
// `getSandboxVmBridgeInstruments()` re-binds against it.
});
afterEach(() => {
const instruments = getSandboxVmBridgeInstruments();
// assert against your provider's exported reader
});
```
## API Reference
- `interface SandboxVmBridgeInstruments` — four instrument fields
documented above.
- `getSandboxVmBridgeInstruments(): SandboxVmBridgeInstruments` —
lazy, cached.
- `resetSandboxVmBridgeInstruments(): void` — drops the cached
bundle.
## Implementation Notes
The `Counter` and `Histogram` types come from `@opentelemetry/api`
directly rather than from any of the `@opentelemetry/sdk-*` packages
so this module stays SDK-agnostic — the consumer's choice of metric
exporter is honoured because the API package looks up the bound
`MeterProvider` on each `metrics.getMeter()` call. The cache is
module-local so an HMR reload in development never strands a stale
bundle (the module reloads, the cache resets implicitly).

View File

@@ -0,0 +1,81 @@
# lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the idempotency wrapper around a `VmSandboxHandle`'s
`start` / `dispose` lifecycle. The host's lifecycle driver may invoke
these hooks multiple times (e.g. a hot-reload's stop+start pair, or a
test harness's deterministic shutdown sequence); the wrapper makes the
double-invocation case a silent no-op rather than a throw, and
reflects under-the-hood sandbox throws via the bridge's
`LIFECYCLE_FAILED` code so the host's error boundary sees a uniform
shape.
Splitting the wrapper into its own module keeps the bridge factory
small (no inline state machine) and gives the conformance harness a
direct seam for asserting idempotency without standing up the full
bridge.
## Features
- Idempotent `start` — second call returns without re-invoking
`sandbox.start()`.
- Idempotent `dispose` — second call returns without re-invoking
`sandbox.dispose()`.
- Forbidden start-after-dispose — calling `start()` after `dispose()`
raises `LIFECYCLE_FAILED` so the host can fail the hot-reload
cleanly rather than silently swallowing the request.
- Error wrapping — underlying sandbox throws surface as
`SandboxVmBridgeError` with `code: LIFECYCLE_FAILED` and the
original error preserved as `cause`.
- Inspection helpers — `hasStarted()` and `isDisposed()` for the
bridge's internal request-pipeline guard.
## Usage: Basic
```ts
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
const forwarder = createSandboxLifecycleForwarder(sandbox);
await forwarder.start();
forwarder.dispose();
forwarder.dispose(); // no-op
```
## Usage: Advanced
```ts
import { createSandboxLifecycleForwarder } from "@suaveplan/api-sandbox-vm-host-bridge/lifecycle/lifecycle";
const forwarder = createSandboxLifecycleForwarder(sandbox);
try {
await forwarder.start();
} catch (err) {
// err.code === "SAND_VM_BRIDGE.LIFECYCLE_FAILED"
// err.cause is the original SandboxVmError
rollbackJournal.markFailed(err);
}
```
## API Reference
- `interface SandboxLifecycleForwarder` — `start()`, `dispose()`,
`hasStarted()`, `isDisposed()`.
- `createSandboxLifecycleForwarder(sandbox: VmSandboxHandle):
SandboxLifecycleForwarder` — factory; returns a frozen object.
## Implementation Notes
The internal `started` / `disposed` flags are kept in a closure rather
than on the returned object so the sandbox cannot mutate them via a
property-set on the forwarder. The forwarder itself is `Object.freeze`d
so consumers cannot patch `dispose` to skip the cleanup. Errors raised
inside `dispose()` are caught and rewrapped — the alternative (letting
them propagate raw) would mean the host's error boundary sees a mixed
shape (sometimes `SandboxVmError`, sometimes
`SandboxVmBridgeError`); the wrap keeps the boundary's branch arm
tight.

View File

@@ -0,0 +1,84 @@
# marshal-request
## Purpose
This module turns the host's per-request `RequestContext` into a
plain-data `SerializedRequest` value that the sandbox can consume. The
marshal step is the only place where the host's class-flavoured types
(`Headers`, `Request`, `Principal`, `TenantContext`) are projected onto
the JSON-safe, transport-agnostic shape published by
`@suaveplan/api-types/sandbox-bridge`. Every downstream consumer of
`SerializedRequest` — whether it crossed a `vm.Context` boundary or
travelled through `WebAssembly.Memory` as raw bytes — sees the same
record.
A Zod schema validates the result before it leaves this module so any
host-side wiring bug surfaces at this seam (with a clear
`MARSHAL_FAILED` code) rather than producing a cryptic crash inside the
sandbox handler.
## Features
- Header projection — `Headers` → ordered `[name, value]` tuple array
via `Headers#forEach`. Names are lowercased; duplicates are preserved
in iteration order so the sandbox can rebuild a `Headers` instance
with identical semantics.
- Body drain + base64-url encoding — `Request.arrayBuffer()` materialises
the request body into a `Uint8Array`, then `Buffer.from(bytes).toString("base64url")`
produces the JSON-safe `bodyBase64` string. `GET`/`HEAD` requests skip
the drain and emit an empty string.
- Principal + tenant projection — drops to bare branded id values
(`principalId`, `tenantId`). Plugins call back into the host via the
capability proxy if they need scopes / claims / slug.
- Capability snapshot — flat `string[]` of capability ids the sandbox is
permitted to invoke from this request, in resolver order.
- Timestamp — host wall-clock (`Date.now()`) at marshal time, useful for
cross-boundary trace correlation and sandbox-side clock-skew checks.
- Output validation — every marshalled value is `safeParse`-validated
against `SerializedRequestSchema`; failures raise
`SAND_VM_BRIDGE.MARSHAL_FAILED` with the Zod issue list attached.
## Usage: Basic
```ts
import { marshalRequest } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-request";
const serialized = await marshalRequest(ctx);
console.log(serialized.url, serialized.method, serialized.requestId);
```
## Usage: Advanced
The exported Zod schema is reusable on the consumer side — for example a
host-tier replay layer that wants to validate stored requests before
re-injection:
```ts
import { SerializedRequestSchema } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-request";
const result = SerializedRequestSchema.safeParse(replayCandidate);
if (!result.success) {
throw new BridgeError("replay payload failed marshalling schema");
}
```
## API Reference
- `marshalRequest(ctx: RequestContext): Promise<SerializedRequest>` —
async because body draining is async.
- `SerializedRequestSchema: z.ZodType<SerializedRequest>` — the Zod
validator used internally; mirrors the contract published by
`@suaveplan/api-types/sandbox-bridge`.
## Implementation Notes
The marshal layer rejects non-WHATWG `Request` values up front because
the host pipeline always synthesises a `Request` before calling the
route handler — anything else indicates a contract violation worth
surfacing immediately. The body-drain path catches `arrayBuffer()`
rejection (locked streams, network errors mid-stream) and re-throws as
`MARSHAL_FAILED` so the handler never sees a partially-drained body.
Base64-url-no-pad is the canonical encoding shared with the wasm-tier
sibling bridge (`@suaveplan/api-sandbox-wasm-host-bridge`); both bridges
emit byte-for-byte identical `SerializedRequest` records given the same
host inputs.

View File

@@ -0,0 +1,78 @@
# marshal-response
## Purpose
This module turns the value returned by a sandboxed handler into a host
`Response`. The sandbox-side handler returns a `SerializedResponse`
(plain object with `status`, ordered header pairs, and base64-url
`bodyBase64`); this module validates that shape with a Zod schema and
constructs the corresponding WHATWG `Response` for the host's request
pipeline to forward to the caller.
The validation step is what stops a misbehaving sandboxed handler
(returning `null`, returning a primitive, returning a partial object
missing `status`) from crashing the host pipeline downstream — and
re-rehydrates the structurally pure JSON shape (`Array<[k, v]>` headers,
`bodyBase64` string) back into the host's class-flavoured `Response`
type.
## Features
- Zod-validated unmarshal — `SerializedResponseSchema` enforces
`status ∈ [100,599]`, ordered `[name, value]` header pairs, and
`bodyBase64: string` plus the optional `errorCode`/`errorMessage`
pair the wire format uses for sandbox-tier exceptions.
- Failure surfacing — non-conformant shapes raise
`SAND_VM_BRIDGE.UNMARSHAL_FAILED` with the Zod issue list attached
as `context.issues`.
- Base64-url body decode — `Buffer.from(str, "base64url")` rehydrates
the bytes; the result feeds straight into `Response`'s
`BodyInit`-compatible `Uint8Array` slot.
- Headers rehydration — `Array<[name, value]>` is appended onto a fresh
`Headers` instance so duplicate-name semantics from the sandbox are
preserved as the response leaves the bridge.
- Bodyless-status enforcement — 204/304 responses drop the body even if
the sandbox produced one, matching the WHATWG fetch rule the host
pipeline expects.
## Usage: Basic
```ts
import { unmarshalResponse } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-response";
const response = unmarshalResponse(await sandboxHandler(serialized));
```
## Usage: Advanced
Use the exported schema to pre-validate cached or replayed responses
before they re-enter the pipeline:
```ts
import { SerializedResponseSchema } from "@suaveplan/api-sandbox-vm-host-bridge/marshal/marshal-response";
const cached = await responseCache.get(key);
const result = SerializedResponseSchema.safeParse(cached);
if (result.success) {
return cached;
}
return rebuildFromSandbox();
```
## API Reference
- `unmarshalResponse(value: unknown): Response` — synchronous; throws
`SandboxVmBridgeError(UNMARSHAL_FAILED)` on shape failure.
- `SerializedResponseSchema: z.ZodType<SerializedResponse>` — the Zod
validator used internally; mirrors the contract published by
`@suaveplan/api-types/sandbox-bridge`.
## Implementation Notes
Headers cross as a `Headers` instance built via `append()` so duplicates
(multi-`Set-Cookie`, etc.) preserve their iteration order. The body
decode skips the `Buffer.from` call for empty strings so the common
"no body" path costs nothing. Status 204/304 force a `null` body in the
constructed `Response` because WHATWG forbids a body on those codes —
the bridge enforces this so a sandbox that mistakenly attaches one
cannot break the host pipeline.

View File

@@ -0,0 +1,98 @@
# route-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the route-discovery protocol that lets a sandboxed
plugin tell the host which HTTP routes it wants to serve. Because the
sandboxed plugin runs inside a `vm.Context` with no direct access to
the host's router, the bridge installs a `bridge.registerRoute`
capability that the plugin invokes during its module-evaluation phase;
the host captures every registration and replays them as
`PipelineRoute` entries on the bridge's `PluginModule`.
The pattern intentionally mirrors how `__suaveplan.capabilities.*`
already works in `@suaveplan/api-sandbox-vm` — a sandboxed plugin sees
`bridge` as just another capability, so the contract for declaring a
route is the same as the contract for any other host-resident API.
## Features
- `createBridgeRouteRegistry()` factory — produces a fresh capture
registry + the `bridge` capability bag in one call.
- Zod-validated registrations — `registerRoute` rejects non-string
methods, paths missing the leading `/`, or non-function handlers
with `SAND_VM_BRIDGE.INVALID_PLUGIN_SURFACE`.
- Method normalisation — methods are upper-cased before storage so
`"get"` and `"GET"` produce equivalent registrations.
- `requireNonEmptyRegistry()` — guard the bridge calls after
`sandbox.start()` resolves to ensure the plugin registered at least
one route. Empty registries raise `INVALID_PLUGIN_SURFACE`.
- Frozen capability bag — the sandbox cannot patch `registerRoute` to
bypass the validator.
## Usage: Basic
```ts
import { createBridgeRouteRegistry } from "@suaveplan/api-sandbox-vm-host-bridge/route-discovery/route-discovery";
const registry = createBridgeRouteRegistry();
const sandbox = createVmSandbox({
proxies: {
fs,
fetch,
capabilities: { bridge: registry.capability },
},
// ...
});
await sandbox.start();
const routes = registry.registrations();
```
## Usage: Advanced
```ts
import {
createBridgeRouteRegistry,
requireNonEmptyRegistry,
} from "@suaveplan/api-sandbox-vm-host-bridge/route-discovery/route-discovery";
const registry = createBridgeRouteRegistry();
// pass registry.capability to the sandbox under
// `proxies.capabilities.bridge`, then start the sandbox.
await sandbox.start();
const routes = requireNonEmptyRegistry(registry, "demo-plugin");
// routes is `readonly BridgeRouteRegistration[]`
```
## API Reference
- `type SandboxRouteHandler = (req: SerializedRequest) =>
Promise<SerializedResponse> | SerializedResponse`.
- `interface BridgeRouteRegistration` — `method`, `path`, `handler`.
- `interface BridgeCapability` — `registerRoute(registration:
unknown): Promise<void>` plus the open string-indexed mirror so the
capability bag conforms to `@suaveplan/api-sandbox-vm`'s
`CapabilityBag` structural type.
- `interface BridgeRouteRegistry` — `registrations()` and
`capability`.
- `createBridgeRouteRegistry(): BridgeRouteRegistry`.
- `requireNonEmptyRegistry(registry, pluginId):
readonly BridgeRouteRegistration[]`.
## Implementation Notes
`registerRoute` is implemented as an `async` function on the capability
bag because `@suaveplan/api-sandbox-vm`'s `installCapabilityProxies`
contract requires every capability method to be async. The validator
runs synchronously inside the async body so failures surface on the
sandbox side via the proxy's permission-gate wrapper.
The Zod schema uses `z.custom<SandboxRouteHandler>` for the handler
field because the runtime check (`typeof v === "function"`) is the
only invariant we can verify before invocation — the signature shape
is type-only and cannot be runtime-checked without invoking the
handler with a probe value, which would defeat the purpose.

View File

@@ -0,0 +1,82 @@
# vm-host-bridge-conformance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module ships `runVmHostBridgeConformance({ buildBridge })` — a
five-invariant conformance suite that every concrete bridge
configuration MUST pass. The harness encodes the public contract from
the OpenSpec change `api-sandbox-vm-host-bridge`: route registration,
request round-trip, idempotent lifecycle, error propagation with
preserved `cause`, and capability-gate fidelity (denied capabilities
do NOT silently succeed).
A bridge integrator (e.g. the foundation-stack-full closure smoke,
`api-plugin-example`'s vm-sandboxed deployment fixture) wires their
own factory into the harness; the harness drives the bridge with the
five scenarios and asserts each invariant.
## Features
- Five scenarios — one per requirement in the spec delta.
- Reusable `RequestContext` builder — handles GET/HEAD bodies, binary
POST bodies, and the minimum required field set so integrators do
not have to re-author it.
- Capability-denial scenario — accepts an integrator-supplied
`checkCapability` shim so the same harness exercises both the
default (allow-all) checker and a tightened policy.
- `SandboxVmBridgeError` assertions — the harness checks `instanceof`
AND the `code` string, so a faulty bridge that swallows errors
silently is caught.
## Usage: Basic
```ts
import { runVmHostBridgeConformance } from "@suaveplan/api-sandbox-vm-host-bridge/testing";
import { createMyBridge } from "./my-bridge.js";
runVmHostBridgeConformance("my-bridge", async ({ source, checkCapability }) => {
const bridged = await createMyBridge({ source, checkCapability });
return { bridged, dispose: () => bridged.dispose() };
});
```
## Usage: Advanced
The harness is `describe`-flavoured — embed it inside an existing
`describe` block to add label-scoped setup/teardown:
```ts
import { runVmHostBridgeConformance } from "@suaveplan/api-sandbox-vm-host-bridge/testing";
describe("api-plugin-example vm-sandboxed deployment", () => {
runVmHostBridgeConformance("api-plugin-example",
buildBridgeForExamplePlugin);
});
```
## API Reference
- `interface BuildBridgeOptions` — `source`, `checkCapability?`.
- `interface BuildBridgeResult` — `bridged` (`BridgedPlugin`),
`dispose()`.
- `type BuildBridge = (options) => Promise<BuildBridgeResult>`.
- `runVmHostBridgeConformance(label: string, buildBridge:
BuildBridge): void`.
## Implementation Notes
The harness deliberately tolerates either `SAND_VM.PERMISSION_DENIED`
(the underlying sandbox's own code) or `SAND_PERM.DENIED` (the
shared permission-package's code) as the cause-chain leaf for the
capability-gate scenario. Both indicate a permission-denial path; the
test fails only when a denied capability silently completes (i.e. the
handler returns 200 without raising at all).
`makeCtx` synthesises only the fields a marshal-stage actually
references — full `RequestContext` construction would pull in
production-only fixtures (OTel span factory, secrets adapter) that
the harness has no business owning.