Initial Commit
This commit is contained in:
95
packages/api-stack/api/api-sandbox-vm-host-bridge/README.md
Normal file
95
packages/api-stack/api/api-sandbox-vm-host-bridge/README.md
Normal 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`.
|
||||
|
||||
116
packages/api-stack/api/api-sandbox-vm-host-bridge/package.json
Normal file
116
packages/api-stack/api/api-sandbox-vm-host-bridge/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@suaveplan/api-sandbox-vm-host-bridge",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./bridge/bridge": {
|
||||
"import": {
|
||||
"types": "./dist/bridge/bridge.d.ts",
|
||||
"default": "./dist/bridge/bridge.js"
|
||||
}
|
||||
},
|
||||
"./marshal/marshal-request": {
|
||||
"import": {
|
||||
"types": "./dist/marshal/marshal-request.d.ts",
|
||||
"default": "./dist/marshal/marshal-request.js"
|
||||
}
|
||||
},
|
||||
"./marshal/marshal-response": {
|
||||
"import": {
|
||||
"types": "./dist/marshal/marshal-response.d.ts",
|
||||
"default": "./dist/marshal/marshal-response.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./lifecycle/lifecycle": {
|
||||
"import": {
|
||||
"types": "./dist/lifecycle/lifecycle.d.ts",
|
||||
"default": "./dist/lifecycle/lifecycle.js"
|
||||
}
|
||||
},
|
||||
"./route-discovery/route-discovery": {
|
||||
"import": {
|
||||
"types": "./dist/route-discovery/route-discovery.d.ts",
|
||||
"default": "./dist/route-discovery/route-discovery.js"
|
||||
}
|
||||
},
|
||||
"./instruments/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.d.ts",
|
||||
"default": "./dist/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-host": "workspace:^",
|
||||
"@suaveplan/api-sandbox-vm": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,323 @@
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
import type { RequestContext } from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { createBridgeRouteRegistry } from "../route-discovery/route-discovery.js";
|
||||
import { createSandboxedVmPluginModule } from "./bridge.js";
|
||||
|
||||
const SILENT: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function buildSandbox(source: string, pluginId = "demo") {
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const sandbox = createVmSandbox({
|
||||
pluginId,
|
||||
logger: SILENT,
|
||||
checker: createPermissionChecker({ policy: {} }),
|
||||
pluginModule: {
|
||||
kind: "source",
|
||||
identifier: "demo.mjs",
|
||||
source,
|
||||
},
|
||||
proxies: {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: {
|
||||
request: async () => ({ status: 204, headers: {}, body: "" }),
|
||||
},
|
||||
capabilities: { bridge: registry.capability },
|
||||
},
|
||||
});
|
||||
return { sandbox, registry };
|
||||
}
|
||||
|
||||
const MANIFEST = {
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
apiHost: "^0.1.0",
|
||||
isolation: "sandboxed",
|
||||
trustLevel: "third-party",
|
||||
} as unknown as Parameters<typeof createSandboxedVmPluginModule>[0]["manifest"];
|
||||
|
||||
function makeRequestContext(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: BodyInit
|
||||
): RequestContext {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
init.body = body;
|
||||
}
|
||||
const request = new Request(`https://api.example.com${path}`, init);
|
||||
return {
|
||||
request,
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
requestId: "req_1",
|
||||
capabilities: {},
|
||||
plugin: { name: "demo", version: "1.0.0" },
|
||||
instruments: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
db: null,
|
||||
secrets: {},
|
||||
span: {
|
||||
setAttribute: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
recordException: () => undefined,
|
||||
end: () => undefined,
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
}
|
||||
|
||||
describe("createSandboxedVmPluginModule", () => {
|
||||
it("PluginModule.routes mirrors sandboxed plugin's exported routes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(`
|
||||
await Promise.all([
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/foo",
|
||||
handler: async () => ({ status: 200, headers: [], bodyBase64: "" }),
|
||||
}),
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/bar",
|
||||
handler: async (req) => ({ status: 201, headers: [], bodyBase64: req.bodyBase64 }),
|
||||
}),
|
||||
]);
|
||||
`);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
expect(bridged.routes).toHaveLength(2);
|
||||
expect(bridged.routes[0]?.method).toBe("GET");
|
||||
expect(bridged.routes[0]?.path).toBe("/foo");
|
||||
expect(bridged.routes[1]?.method).toBe("POST");
|
||||
expect(bridged.routes[1]?.path).toBe("/bar");
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("module fields match the manifest", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/x", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
expect(bridged.module.name).toBe("demo");
|
||||
expect(bridged.module.version).toBe("1.0.0");
|
||||
expect(bridged.module.isolation).toBe("sandboxed");
|
||||
expect(bridged.module.trustLevel).toBe("third-party");
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("INVALID_PLUGIN_SURFACE when plugin registers no routes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox("globalThis.foo;");
|
||||
await expect(
|
||||
createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.INVALID_PLUGIN_SURFACE",
|
||||
});
|
||||
});
|
||||
|
||||
it("sandbox throw surfaces as HANDLER_REJECTED with cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/boom", handler: async () => { throw new Error("kaboom"); } });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = makeRequestContext("GET", "/boom");
|
||||
try {
|
||||
await handler(ctx);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; cause?: unknown };
|
||||
expect(e.code).toBe("SAND_VM_BRIDGE.HANDLER_REJECTED");
|
||||
expect(e.cause).toBeDefined();
|
||||
}
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("response body+headers cross intact through bridge handler", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(`
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/echo",
|
||||
handler: async (req) => ({
|
||||
status: 200,
|
||||
headers: [["x-echo", "yes"]],
|
||||
bodyBase64: req.bodyBase64,
|
||||
}),
|
||||
});
|
||||
`);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = makeRequestContext(
|
||||
"POST",
|
||||
"/echo",
|
||||
new Uint8Array([72, 101, 108, 108, 111])
|
||||
);
|
||||
const response = (await handler(ctx)) as Response;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-echo")).toBe("yes");
|
||||
expect(await response.text()).toBe("Hello");
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("UNMARSHAL_FAILED when sandbox returns wrong shape", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/bad", handler: async () => ({ noStatus: true }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = makeRequestContext("GET", "/bad");
|
||||
await expect(handler(ctx)).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.UNMARSHAL_FAILED",
|
||||
});
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("routeOptions override is forwarded onto matching route", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/v", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
routeOptions: { "GET /v": { auth: "tenant.read", csrf: false } },
|
||||
statusMap: { 200: 201 },
|
||||
});
|
||||
expect(bridged.routes[0]?.options.auth).toBe("tenant.read");
|
||||
expect(bridged.routes[0]?.statusMap).toEqual({ 200: 201 });
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("default options when no routeOptions override supplied", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/d", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
expect(bridged.routes[0]?.options).toEqual({});
|
||||
expect(bridged.routes[0]?.statusMap).toEqual({});
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("routeOptions for an unmatched key falls back to default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/x", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
routeOptions: { "POST /y": { auth: "x" } },
|
||||
});
|
||||
expect(bridged.routes[0]?.options).toEqual({});
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent across the bridged plugin handle", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/x", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
bridged.dispose();
|
||||
bridged.dispose();
|
||||
expect(bridged.lifecycle.isDisposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("MARSHAL_FAILED surfaces from non-Request request value", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/m", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const ctx = makeRequestContext("GET", "/m");
|
||||
(ctx as { request: unknown }).request = {
|
||||
url: "x",
|
||||
} as unknown as Request;
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
await expect(handler(ctx)).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("module.start re-invocation is a no-op (idempotent)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildSandbox(
|
||||
`await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/x", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
await bridged.module.start({} as never);
|
||||
await bridged.module.start({} as never);
|
||||
expect(bridged.lifecycle.hasStarted()).toBe(true);
|
||||
await bridged.module.stop({} as never);
|
||||
await bridged.module.uninstall({} as never);
|
||||
expect(bridged.lifecycle.isDisposed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
|
||||
import type {
|
||||
CapabilityRequirements,
|
||||
PipelineRoute,
|
||||
PipelineRouteOptions,
|
||||
PluginContext,
|
||||
PluginManifest,
|
||||
RequestContext,
|
||||
} from "@suaveplan/api-types";
|
||||
import type { SerializedResponse } from "@suaveplan/api-types/sandbox-bridge";
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getSandboxVmBridgeInstruments } from "../instruments/instruments.js";
|
||||
import {
|
||||
createSandboxLifecycleForwarder,
|
||||
type SandboxLifecycleForwarder,
|
||||
} from "../lifecycle/lifecycle.js";
|
||||
import { marshalRequest } from "../marshal/marshal-request.js";
|
||||
import { unmarshalResponse } from "../marshal/marshal-response.js";
|
||||
import {
|
||||
type BridgeRouteRegistry,
|
||||
requireNonEmptyRegistry,
|
||||
type SandboxRouteHandler,
|
||||
} from "../route-discovery/route-discovery.js";
|
||||
|
||||
/**
|
||||
* `PluginModule`-shaped lifecycle bundle the host's lifecycle driver
|
||||
* consumes. Mirrors the relevant subset of
|
||||
* `@suaveplan/api-host`'s `PluginModule` so the bridge does not need to
|
||||
* import that package's type at the API boundary (avoiding a cyclic
|
||||
* import — `api-host` ships its own `PluginModule` shape from
|
||||
* `define-plugin`).
|
||||
*/
|
||||
export interface BridgedPluginModule<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly requires: Caps;
|
||||
readonly isolation: PluginManifest["isolation"];
|
||||
readonly trustLevel: PluginManifest["trustLevel"];
|
||||
readonly start: (ctx: PluginContext<Caps>) => Promise<void>;
|
||||
readonly stop: (ctx: PluginContext<Caps>) => Promise<void>;
|
||||
readonly uninstall: (ctx: PluginContext<Caps>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link createSandboxedVmPluginModule}. `module` is the
|
||||
* lifecycle bundle (start/stop/uninstall delegate to the sandbox);
|
||||
* `routes` is the materialised `PipelineRoute[]` the host registers via
|
||||
* `host.installPlugin({ routes })`. `dispose` releases the underlying
|
||||
* sandbox — call it after `host.uninstallPlugin(...)` (or directly
|
||||
* during test teardown).
|
||||
*/
|
||||
export interface BridgedPlugin<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> {
|
||||
readonly module: BridgedPluginModule<Caps>;
|
||||
readonly routes: ReadonlyArray<PipelineRoute>;
|
||||
readonly dispose: () => void;
|
||||
/** Inspection — the forwarder behind `start` / `stop` / `dispose`. */
|
||||
readonly lifecycle: SandboxLifecycleForwarder;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createSandboxedVmPluginModule}. */
|
||||
export interface CreateSandboxedVmPluginModuleOptions {
|
||||
readonly sandbox: VmSandboxHandle;
|
||||
readonly manifest: PluginManifest;
|
||||
readonly registry: BridgeRouteRegistry;
|
||||
/** Optional per-route option overrides keyed by `"METHOD /path"`. */
|
||||
readonly routeOptions?: Readonly<
|
||||
Record<string, PipelineRouteOptions | undefined>
|
||||
>;
|
||||
/** Optional status-code overrides forwarded onto every route. */
|
||||
readonly statusMap?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
const DEFAULT_ROUTE_OPTIONS: PipelineRouteOptions = Object.freeze({});
|
||||
|
||||
function routeOptionsFor(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: CreateSandboxedVmPluginModuleOptions["routeOptions"]
|
||||
): PipelineRouteOptions {
|
||||
if (overrides === undefined) return DEFAULT_ROUTE_OPTIONS;
|
||||
const key = `${method} ${path}`;
|
||||
return overrides[key] ?? DEFAULT_ROUTE_OPTIONS;
|
||||
}
|
||||
|
||||
function buildRouteHandler(
|
||||
pluginId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
sandboxHandler: SandboxRouteHandler
|
||||
): PipelineRoute["handler"] {
|
||||
const instruments = getSandboxVmBridgeInstruments();
|
||||
return async (ctx: RequestContext): Promise<unknown> => {
|
||||
const start = Date.now();
|
||||
const serialized = await marshalRequest(ctx).catch(
|
||||
(err: unknown): never => {
|
||||
instruments.marshalFailuresTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
direction: "request",
|
||||
// `marshalRequest` always throws a SandboxVmBridgeError —
|
||||
// direct code access avoids a `??` branch.
|
||||
code: (err as SandboxVmBridgeError).code,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
instruments.requestsMarshalledTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
route: path,
|
||||
method,
|
||||
});
|
||||
let result: SerializedResponse;
|
||||
try {
|
||||
result = (await sandboxHandler(serialized)) as SerializedResponse;
|
||||
} catch (err) {
|
||||
instruments.handlerRejectionsTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
route: path,
|
||||
});
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.HANDLER_REJECTED,
|
||||
`sandboxed handler for ${method} ${path} threw`,
|
||||
{
|
||||
cause: err,
|
||||
context: { plugin: pluginId, route: path, method },
|
||||
}
|
||||
);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = unmarshalResponse(result);
|
||||
} catch (err) {
|
||||
instruments.marshalFailuresTotal.add(1, {
|
||||
plugin: pluginId,
|
||||
direction: "response",
|
||||
// `unmarshalResponse` always throws a SandboxVmBridgeError.
|
||||
code: (err as SandboxVmBridgeError).code,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
instruments.bridgeInvocationDurationMs.record(Date.now() - start, {
|
||||
plugin: pluginId,
|
||||
route: path,
|
||||
method,
|
||||
});
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `BridgedPlugin` from a started `VmSandboxHandle`. The
|
||||
* sandbox MUST already have its `bridge.registerRoute` capability
|
||||
* wired and have `start()` called before this factory is invoked —
|
||||
* the factory itself drives the start through the lifecycle
|
||||
* forwarder so the bridge can capture sandbox-side throws as
|
||||
* `LIFECYCLE_FAILED`.
|
||||
*
|
||||
* If the sandbox registered zero routes during evaluation, the
|
||||
* factory rejects with `INVALID_PLUGIN_SURFACE`.
|
||||
*/
|
||||
export async function createSandboxedVmPluginModule<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
>(options: CreateSandboxedVmPluginModuleOptions): Promise<BridgedPlugin<Caps>> {
|
||||
const { sandbox, manifest, registry, statusMap } = options;
|
||||
const forwarder = createSandboxLifecycleForwarder(sandbox);
|
||||
await forwarder.start();
|
||||
const registrations = requireNonEmptyRegistry(registry, sandbox.pluginId);
|
||||
|
||||
const routes: PipelineRoute[] = registrations.map((reg) =>
|
||||
Object.freeze({
|
||||
plugin: sandbox.pluginId,
|
||||
method: reg.method,
|
||||
path: reg.path,
|
||||
options: routeOptionsFor(
|
||||
reg.method,
|
||||
reg.path,
|
||||
options.routeOptions
|
||||
),
|
||||
handler: buildRouteHandler(
|
||||
sandbox.pluginId,
|
||||
reg.method,
|
||||
reg.path,
|
||||
reg.handler
|
||||
),
|
||||
params: Object.freeze({}),
|
||||
statusMap: Object.freeze({ ...(statusMap ?? {}) }),
|
||||
})
|
||||
);
|
||||
|
||||
const requires = [] as unknown as Caps;
|
||||
const module: BridgedPluginModule<Caps> = Object.freeze({
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
requires,
|
||||
isolation: manifest.isolation,
|
||||
trustLevel: manifest.trustLevel,
|
||||
async start(): Promise<void> {
|
||||
// The sandbox has already been started by the factory; the
|
||||
// host's lifecycle hook is a no-op so re-invocation across a
|
||||
// hot-reload cycle is safe.
|
||||
await forwarder.start();
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
// Stop is a logical signal to the plugin; the sandbox
|
||||
// remains evaluated until `dispose()` is called separately.
|
||||
// The bridge's `dispose()` (returned alongside the module)
|
||||
// is the authoritative teardown.
|
||||
},
|
||||
async uninstall(): Promise<void> {
|
||||
forwarder.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
module,
|
||||
routes: Object.freeze([...routes]),
|
||||
dispose: () => forwarder.dispose(),
|
||||
lifecycle: forwarder,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type BridgedPlugin,
|
||||
type BridgedPluginModule,
|
||||
type CreateSandboxedVmPluginModuleOptions,
|
||||
createSandboxedVmPluginModule,
|
||||
} from "./bridge.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SAND_VM_BRIDGE_CODES, SandboxVmBridgeError } from "./error-codes.js";
|
||||
|
||||
describe("SAND_VM_BRIDGE_CODES", () => {
|
||||
it("registers five namespaced codes", { timeout: 5000 }, () => {
|
||||
expect(SAND_VM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE).toBe(
|
||||
"SAND_VM_BRIDGE.INVALID_PLUGIN_SURFACE"
|
||||
);
|
||||
expect(SAND_VM_BRIDGE_CODES.MARSHAL_FAILED).toBe(
|
||||
"SAND_VM_BRIDGE.MARSHAL_FAILED"
|
||||
);
|
||||
expect(SAND_VM_BRIDGE_CODES.UNMARSHAL_FAILED).toBe(
|
||||
"SAND_VM_BRIDGE.UNMARSHAL_FAILED"
|
||||
);
|
||||
expect(SAND_VM_BRIDGE_CODES.HANDLER_REJECTED).toBe(
|
||||
"SAND_VM_BRIDGE.HANDLER_REJECTED"
|
||||
);
|
||||
expect(SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED).toBe(
|
||||
"SAND_VM_BRIDGE.LIFECYCLE_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SandboxVmBridgeError", () => {
|
||||
it("carries code, message, name + cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.HANDLER_REJECTED,
|
||||
"sandboxed handler threw",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.code).toBe("SAND_VM_BRIDGE.HANDLER_REJECTED");
|
||||
expect(err.message).toBe("sandboxed handler threw");
|
||||
expect(err.name).toBe("SandboxVmBridgeError");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("works without options", { timeout: 5000 }, () => {
|
||||
const err = new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
|
||||
"boot failed"
|
||||
);
|
||||
expect(err.code).toBe("SAND_VM_BRIDGE.LIFECYCLE_FAILED");
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-sandbox-vm-host-bridge`.
|
||||
*
|
||||
* Every failure surface inside the bridge raises a
|
||||
* {@link SandboxVmBridgeError} whose `code` is one of the namespaced
|
||||
* `SAND_VM_BRIDGE.*` strings declared below.
|
||||
*
|
||||
* Code semantics:
|
||||
*
|
||||
* - `INVALID_PLUGIN_SURFACE` — the sandboxed plugin did not register any
|
||||
* routes via the host-installed `bridge.registerRoute` capability
|
||||
* before its module body finished evaluating, OR a registration was
|
||||
* structurally invalid (missing method, missing path, non-function
|
||||
* handler). The bridge refuses to expose a partially-initialised
|
||||
* plugin to the host.
|
||||
* - `MARSHAL_FAILED` — the host's `Request` could not be serialised
|
||||
* into a {@link SerializedRequest}. The most common cause is an
|
||||
* unsupported body type (e.g. `ReadableStream` that the marshal layer
|
||||
* cannot drain inside the request handler) or a header value that
|
||||
* failed the Zod validator. The route handler does NOT reach the
|
||||
* sandbox in this case.
|
||||
* - `UNMARSHAL_FAILED` — the sandbox-side handler returned a value the
|
||||
* bridge could not turn into a {@link SerializedResponse} — either
|
||||
* a non-object, an object missing the required fields, or a body of
|
||||
* an unsupported type.
|
||||
* - `HANDLER_REJECTED` — the sandboxed handler threw or its returned
|
||||
* promise rejected. The thrown value is preserved as `cause` so the
|
||||
* host's error boundary can walk the chain back to the original
|
||||
* `SandboxVmError` (or whatever the sandbox emitted).
|
||||
* - `LIFECYCLE_FAILED` — a delegated `start` or `dispose` call into the
|
||||
* sandbox failed. Used only when the underlying sandbox throws — the
|
||||
* bridge's own idempotency guards never raise this code.
|
||||
*/
|
||||
export const SAND_VM_BRIDGE_CODES = defineCodes("SAND_VM_BRIDGE", [
|
||||
"INVALID_PLUGIN_SURFACE",
|
||||
"MARSHAL_FAILED",
|
||||
"UNMARSHAL_FAILED",
|
||||
"HANDLER_REJECTED",
|
||||
"LIFECYCLE_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code in {@link SAND_VM_BRIDGE_CODES}. */
|
||||
export type SandboxVmBridgeCode =
|
||||
(typeof SAND_VM_BRIDGE_CODES)[keyof typeof SAND_VM_BRIDGE_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for `@suaveplan/api-sandbox-vm-host-bridge`. Extends
|
||||
* {@link SuaveplanError} so callers may discriminate by
|
||||
* `instanceof SandboxVmBridgeError` OR by `code` string.
|
||||
*/
|
||||
export class SandboxVmBridgeError extends SuaveplanError<SandboxVmBridgeCode> {
|
||||
public constructor(
|
||||
code: SandboxVmBridgeCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "SandboxVmBridgeError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
type SandboxVmBridgeCode,
|
||||
SandboxVmBridgeError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
// Side-effect: registers the SAND_VM_BRIDGE.* code namespace so subpath
|
||||
// consumers participate in the same registry as the package barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
type BridgedPlugin,
|
||||
type BridgedPluginModule,
|
||||
type CreateSandboxedVmPluginModuleOptions,
|
||||
createSandboxedVmPluginModule,
|
||||
} from "./bridge/bridge.js";
|
||||
export {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
type SandboxVmBridgeCode,
|
||||
SandboxVmBridgeError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
getSandboxVmBridgeInstruments,
|
||||
resetSandboxVmBridgeInstruments,
|
||||
type SandboxVmBridgeInstruments,
|
||||
} from "./instruments/instruments.js";
|
||||
export {
|
||||
createSandboxLifecycleForwarder,
|
||||
type SandboxLifecycleForwarder,
|
||||
} from "./lifecycle/lifecycle.js";
|
||||
export {
|
||||
marshalRequest,
|
||||
SerializedRequestSchema,
|
||||
} from "./marshal/marshal-request.js";
|
||||
export {
|
||||
SerializedResponseSchema,
|
||||
unmarshalResponse,
|
||||
} from "./marshal/marshal-response.js";
|
||||
export {
|
||||
type BridgeCapability,
|
||||
type BridgeRouteRegistration,
|
||||
type BridgeRouteRegistry,
|
||||
createBridgeRouteRegistry,
|
||||
requireNonEmptyRegistry,
|
||||
type SandboxRouteHandler,
|
||||
} from "./route-discovery/route-discovery.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getSandboxVmBridgeInstruments,
|
||||
resetSandboxVmBridgeInstruments,
|
||||
type SandboxVmBridgeInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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).
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getSandboxVmBridgeInstruments,
|
||||
resetSandboxVmBridgeInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("instruments", () => {
|
||||
it("returns a cached bundle on repeat calls", { timeout: 5000 }, () => {
|
||||
resetSandboxVmBridgeInstruments();
|
||||
const a = getSandboxVmBridgeInstruments();
|
||||
const b = getSandboxVmBridgeInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("re-binds after reset", { timeout: 5000 }, () => {
|
||||
const a = getSandboxVmBridgeInstruments();
|
||||
resetSandboxVmBridgeInstruments();
|
||||
const b = getSandboxVmBridgeInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("exposes the four named instruments", { timeout: 5000 }, () => {
|
||||
const bundle = getSandboxVmBridgeInstruments();
|
||||
expect(bundle.requestsMarshalledTotal).toBeDefined();
|
||||
expect(bundle.marshalFailuresTotal).toBeDefined();
|
||||
expect(bundle.handlerRejectionsTotal).toBeDefined();
|
||||
expect(bundle.bridgeInvocationDurationMs).toBeDefined();
|
||||
// .add and .record must be callable without throwing
|
||||
bundle.requestsMarshalledTotal.add(1, { plugin: "demo" });
|
||||
bundle.marshalFailuresTotal.add(1, { plugin: "demo" });
|
||||
bundle.handlerRejectionsTotal.add(1, { plugin: "demo" });
|
||||
bundle.bridgeInvocationDurationMs.record(1, { plugin: "demo" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the host bridge.
|
||||
*
|
||||
* - `requestsMarshalledTotal` — incremented every time a host
|
||||
* `Request` is successfully marshalled into a `SerializedRequest`.
|
||||
* Attributes: `plugin`, `route`, `method`.
|
||||
* - `marshalFailuresTotal` — incremented when marshal or unmarshal
|
||||
* raises `SAND_VM_BRIDGE.{MARSHAL_FAILED,UNMARSHAL_FAILED}`.
|
||||
* Attributes: `plugin`, `direction = "request"|"response"`, `code`.
|
||||
* - `handlerRejectionsTotal` — incremented every time the sandboxed
|
||||
* handler throws or its returned promise rejects.
|
||||
* Attributes: `plugin`, `route`.
|
||||
* - `bridgeInvocationDurationMs` — histogram of wall-time per bridged
|
||||
* request handler call, measured from before marshal to after
|
||||
* unmarshal.
|
||||
*/
|
||||
export interface SandboxVmBridgeInstruments {
|
||||
readonly requestsMarshalledTotal: Counter;
|
||||
readonly marshalFailuresTotal: Counter;
|
||||
readonly handlerRejectionsTotal: Counter;
|
||||
readonly bridgeInvocationDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: SandboxVmBridgeInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the bridge instruments bundle against the
|
||||
* current global `MeterProvider`. Call
|
||||
* {@link resetSandboxVmBridgeInstruments} in test setup to force a
|
||||
* re-bind against a fresh provider.
|
||||
*/
|
||||
export function getSandboxVmBridgeInstruments(): SandboxVmBridgeInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-sandbox-vm-host-bridge");
|
||||
cached = {
|
||||
requestsMarshalledTotal: meter.createCounter(
|
||||
"sandbox_vm_bridge.requests_marshalled_total",
|
||||
{
|
||||
description:
|
||||
"Total requests successfully marshalled across the vm sandbox bridge",
|
||||
}
|
||||
),
|
||||
marshalFailuresTotal: meter.createCounter(
|
||||
"sandbox_vm_bridge.marshal_failures_total",
|
||||
{
|
||||
description:
|
||||
"Total marshal or unmarshal failures inside the vm sandbox bridge",
|
||||
}
|
||||
),
|
||||
handlerRejectionsTotal: meter.createCounter(
|
||||
"sandbox_vm_bridge.handler_rejections_total",
|
||||
{
|
||||
description:
|
||||
"Total sandboxed-handler throws caught by the bridge",
|
||||
}
|
||||
),
|
||||
bridgeInvocationDurationMs: meter.createHistogram(
|
||||
"sandbox_vm_bridge.invocation_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-time spent per bridged request handler invocation",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drop the cached instrument bundle so the next call re-binds. */
|
||||
export function resetSandboxVmBridgeInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createSandboxLifecycleForwarder,
|
||||
type SandboxLifecycleForwarder,
|
||||
} from "./lifecycle.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SandboxVmBridgeError } from "../error-codes/error-codes.js";
|
||||
import { createSandboxLifecycleForwarder } from "./lifecycle.js";
|
||||
|
||||
function fakeHandle(overrides: Partial<VmSandboxHandle> = {}): {
|
||||
handle: VmSandboxHandle;
|
||||
counts: { start: number; dispose: number };
|
||||
} {
|
||||
const counts = { start: 0, dispose: 0 };
|
||||
const handle = {
|
||||
pluginId: "demo",
|
||||
capabilities: {},
|
||||
async start() {
|
||||
counts.start += 1;
|
||||
},
|
||||
dispose() {
|
||||
counts.dispose += 1;
|
||||
},
|
||||
isDisposed: () => false,
|
||||
...overrides,
|
||||
} as unknown as VmSandboxHandle;
|
||||
return { handle, counts };
|
||||
}
|
||||
|
||||
describe("createSandboxLifecycleForwarder", () => {
|
||||
it("start delegates to sandbox.start exactly once", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handle, counts } = fakeHandle();
|
||||
const f = createSandboxLifecycleForwarder(handle);
|
||||
await f.start();
|
||||
await f.start();
|
||||
await f.start();
|
||||
expect(counts.start).toBe(1);
|
||||
expect(f.hasStarted()).toBe(true);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, () => {
|
||||
const { handle, counts } = fakeHandle();
|
||||
const f = createSandboxLifecycleForwarder(handle);
|
||||
f.dispose();
|
||||
f.dispose();
|
||||
expect(counts.dispose).toBe(1);
|
||||
expect(f.isDisposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("start after dispose raises LIFECYCLE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handle } = fakeHandle();
|
||||
const f = createSandboxLifecycleForwarder(handle);
|
||||
f.dispose();
|
||||
await expect(f.start()).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.LIFECYCLE_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps sandbox.start throw as LIFECYCLE_FAILED with cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("boot exploded");
|
||||
const { handle } = fakeHandle({
|
||||
async start() {
|
||||
throw cause;
|
||||
},
|
||||
});
|
||||
const f = createSandboxLifecycleForwarder(handle);
|
||||
try {
|
||||
await f.start();
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxVmBridgeError);
|
||||
expect((err as SandboxVmBridgeError).code).toBe(
|
||||
"SAND_VM_BRIDGE.LIFECYCLE_FAILED"
|
||||
);
|
||||
expect((err as SandboxVmBridgeError).cause).toBe(cause);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps sandbox.dispose throw as LIFECYCLE_FAILED with cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("dispose exploded");
|
||||
const { handle } = fakeHandle({
|
||||
dispose() {
|
||||
throw cause;
|
||||
},
|
||||
});
|
||||
const f = createSandboxLifecycleForwarder(handle);
|
||||
try {
|
||||
f.dispose();
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxVmBridgeError);
|
||||
expect((err as SandboxVmBridgeError).cause).toBe(cause);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Idempotent lifecycle forwarder around a `VmSandboxHandle`. Wraps
|
||||
* `start` so a second call is a silent no-op (the underlying sandbox
|
||||
* has already evaluated its module) and wraps `dispose` so a second
|
||||
* call is also a no-op (the sandbox's own `isDisposed()` would short-
|
||||
* circuit, but having the guard at the bridge layer means the host
|
||||
* never sees a stray throw if a future sandbox impl tightens its
|
||||
* behaviour).
|
||||
*
|
||||
* Underlying-handle failures are re-wrapped as
|
||||
* `SAND_VM_BRIDGE.LIFECYCLE_FAILED` with the original error preserved
|
||||
* as `cause`.
|
||||
*/
|
||||
export interface SandboxLifecycleForwarder {
|
||||
readonly start: () => Promise<void>;
|
||||
readonly dispose: () => void;
|
||||
readonly hasStarted: () => boolean;
|
||||
readonly isDisposed: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the idempotent lifecycle forwarder around a `VmSandboxHandle`.
|
||||
*/
|
||||
export function createSandboxLifecycleForwarder(
|
||||
sandbox: VmSandboxHandle
|
||||
): SandboxLifecycleForwarder {
|
||||
let started = false;
|
||||
let disposed = false;
|
||||
|
||||
return Object.freeze({
|
||||
async start(): Promise<void> {
|
||||
if (started) return;
|
||||
if (disposed) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
|
||||
"cannot start a disposed sandbox bridge"
|
||||
);
|
||||
}
|
||||
try {
|
||||
await sandbox.start();
|
||||
} catch (err) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
|
||||
"sandbox start failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
started = true;
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
sandbox.dispose();
|
||||
} catch (err) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.LIFECYCLE_FAILED,
|
||||
"sandbox dispose failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
},
|
||||
hasStarted(): boolean {
|
||||
return started;
|
||||
},
|
||||
isDisposed(): boolean {
|
||||
return disposed;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
marshalRequest,
|
||||
SerializedRequestSchema,
|
||||
} from "./marshal-request.js";
|
||||
export {
|
||||
SerializedResponseSchema,
|
||||
unmarshalResponse,
|
||||
} from "./marshal-response.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { RequestContext } from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SandboxVmBridgeError } from "../error-codes/error-codes.js";
|
||||
import { marshalRequest, SerializedRequestSchema } from "./marshal-request.js";
|
||||
|
||||
function makeCtx(overrides: Partial<RequestContext> = {}): RequestContext {
|
||||
const request = new Request("https://api.example.com/v1/foo", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-trace": "abc" },
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
});
|
||||
const span = {
|
||||
setAttribute: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
recordException: () => undefined,
|
||||
end: () => undefined,
|
||||
};
|
||||
return {
|
||||
request,
|
||||
principal: {
|
||||
id: "u_1",
|
||||
scopes: [],
|
||||
claims: { sub: "u_1" },
|
||||
},
|
||||
tenantId: "t_1",
|
||||
tenant: { id: "t_1", slug: "acme" },
|
||||
requestId: "req_1",
|
||||
span,
|
||||
capabilities: { db: { query: async () => undefined } },
|
||||
plugin: { name: "demo", version: "1.0.0" },
|
||||
instruments: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
db: null,
|
||||
secrets: {},
|
||||
...overrides,
|
||||
} as unknown as RequestContext;
|
||||
}
|
||||
|
||||
function findHeader(
|
||||
pairs: ReadonlyArray<readonly [string, string]>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
return pairs.find(([k]) => k === name)?.[1];
|
||||
}
|
||||
|
||||
describe("marshalRequest", () => {
|
||||
it("captures URL, method, headers, bodyBase64, principalId, tenantId, requestId, timestamp, capabilities", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const before = Date.now();
|
||||
const ctx = makeCtx();
|
||||
const out = await marshalRequest(ctx);
|
||||
const after = Date.now();
|
||||
expect(out.url).toBe("https://api.example.com/v1/foo");
|
||||
expect(out.method).toBe("POST");
|
||||
expect(findHeader(out.headers, "content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
expect(findHeader(out.headers, "x-trace")).toBe("abc");
|
||||
// Body round-trips through base64-url.
|
||||
expect(out.bodyBase64.length).toBeGreaterThan(0);
|
||||
const bytes = new Uint8Array(Buffer.from(out.bodyBase64, "base64url"));
|
||||
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
||||
expect(parsed).toEqual({ hello: "world" });
|
||||
expect(out.principalId).toBe("u_1");
|
||||
expect(out.tenantId).toBe("t_1");
|
||||
expect(out.requestId).toBe("req_1");
|
||||
expect(out.capabilities).toEqual(["db"]);
|
||||
expect(out.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(out.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("GET request bodies marshal to empty bodyBase64", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
request: new Request("https://api.example.com/v1/foo"),
|
||||
} as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.bodyBase64).toBe("");
|
||||
expect(out.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("anonymous principal marshals to null principalId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({ principal: null });
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.principalId).toBeNull();
|
||||
});
|
||||
|
||||
it("missing tenant marshals to null tenantId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
} as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.tenantId).toBeNull();
|
||||
});
|
||||
|
||||
it("empty POST body marshals to empty bodyBase64", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
request: new Request("https://api.example.com/x", {
|
||||
method: "POST",
|
||||
}),
|
||||
} as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.bodyBase64).toBe("");
|
||||
});
|
||||
|
||||
it("unsupported body type surfaces as MARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Drain the request body up-front so a second drain rejects.
|
||||
const r = new Request("https://api.example.com/x", {
|
||||
method: "POST",
|
||||
body: "abc",
|
||||
});
|
||||
await r.arrayBuffer();
|
||||
const ctx = makeCtx({ request: r } as Partial<RequestContext>);
|
||||
await expect(marshalRequest(ctx)).rejects.toBeInstanceOf(
|
||||
SandboxVmBridgeError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-WHATWG Request with MARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
request: { url: "x" } as unknown as Request,
|
||||
} as Partial<RequestContext>);
|
||||
await expect(marshalRequest(ctx)).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("capability snapshot is the list of capability names", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
capabilities: {
|
||||
db: { query: async () => undefined },
|
||||
fetch: { request: async () => undefined },
|
||||
},
|
||||
} as unknown as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect([...out.capabilities].sort()).toEqual(["db", "fetch"]);
|
||||
});
|
||||
|
||||
it("schema validates serialised value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = SerializedRequestSchema.safeParse({
|
||||
url: "x",
|
||||
method: "GET",
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
principalId: null,
|
||||
tenantId: null,
|
||||
capabilities: [],
|
||||
requestId: "r",
|
||||
timestamp: 1700000000000,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("header pairs preserve lowercase names + duplicates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = new Headers();
|
||||
h.append("x-multi", "one");
|
||||
h.append("X-Multi", "two");
|
||||
const req = new Request("https://api.example.com/x", {
|
||||
method: "POST",
|
||||
headers: h,
|
||||
body: "body",
|
||||
});
|
||||
const ctx = makeCtx({ request: req } as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
// WHATWG Headers coalesces duplicates into a comma-joined value
|
||||
// (Headers#forEach yields a single entry). Lowercase name is
|
||||
// preserved.
|
||||
const matches = out.headers.filter(([k]) => k === "x-multi");
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]?.[1]).toContain("one");
|
||||
expect(matches[0]?.[1]).toContain("two");
|
||||
});
|
||||
|
||||
it("schema-validation failure raises MARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
requestId: 42 as unknown as RequestContext["requestId"],
|
||||
} as unknown as Partial<RequestContext>);
|
||||
await expect(marshalRequest(ctx)).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("empty POST body (0 bytes after drain) marshals to empty bodyBase64", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const r = new Request("https://api.example.com/x", {
|
||||
method: "POST",
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
} as unknown as RequestInit);
|
||||
const ctx = makeCtx({ request: r } as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.bodyBase64).toBe("");
|
||||
});
|
||||
|
||||
it("method is normalised to uppercase", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Request constructor already uppercases, but the marshal layer
|
||||
// re-applies for defence-in-depth.
|
||||
const ctx = makeCtx({
|
||||
request: new Request("https://api.example.com/x", {
|
||||
method: "post",
|
||||
}),
|
||||
} as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
expect(out.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("body bytes round-trip through base64url with no loss", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
|
||||
const r = new Request("https://api.example.com/x", {
|
||||
method: "POST",
|
||||
body: raw,
|
||||
});
|
||||
const ctx = makeCtx({ request: r } as Partial<RequestContext>);
|
||||
const out = await marshalRequest(ctx);
|
||||
const decoded = new Uint8Array(
|
||||
Buffer.from(out.bodyBase64, "base64url")
|
||||
);
|
||||
expect(Array.from(decoded)).toEqual(Array.from(raw));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
PrincipalId,
|
||||
RequestContext,
|
||||
TenantId,
|
||||
} from "@suaveplan/api-types";
|
||||
import type { SerializedRequest } from "@suaveplan/api-types/sandbox-bridge";
|
||||
import z from "zod";
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Zod validator for a {@link SerializedRequest}. Run before the marshal
|
||||
* layer hands the value across the `vm.Context` boundary so any host
|
||||
* wiring bug surfaces at this seam rather than inside the sandbox
|
||||
* handler.
|
||||
*
|
||||
* Mirrors the shared `SerializedRequest` contract published by
|
||||
* `@suaveplan/api-types/sandbox-bridge` — a JSON-safe, transport-agnostic
|
||||
* shape that round-trips through `structuredClone` (vm-tier) and
|
||||
* `WebAssembly.Memory` (wasm-tier) without further encoding work.
|
||||
*/
|
||||
export const SerializedRequestSchema: z.ZodType<SerializedRequest> = z.object({
|
||||
url: z.string(),
|
||||
method: z.string(),
|
||||
headers: z.array(z.tuple([z.string(), z.string()])),
|
||||
bodyBase64: z.string(),
|
||||
principalId: z.union([z.string(), z.null()]),
|
||||
tenantId: z.union([z.string(), z.null()]),
|
||||
capabilities: z.array(z.string()),
|
||||
requestId: z.string(),
|
||||
timestamp: z.number(),
|
||||
}) as unknown as z.ZodType<SerializedRequest>;
|
||||
|
||||
function headersToOrderedPairs(
|
||||
headers: Headers
|
||||
): ReadonlyArray<readonly [string, string]> {
|
||||
const out: Array<readonly [string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
// WHATWG `Headers#forEach` already enumerates names in
|
||||
// lowercase; we freeze each tuple defensively so downstream
|
||||
// structuredClone consumers cannot mutate them in-place.
|
||||
out.push(
|
||||
Object.freeze([key.toLowerCase(), value]) as readonly [
|
||||
string,
|
||||
string,
|
||||
]
|
||||
);
|
||||
});
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
async function readBodyBytes(request: Request): Promise<Uint8Array> {
|
||||
if (request.method === "GET" || request.method === "HEAD") {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (request.body === null) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
try {
|
||||
const buf = await request.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
} catch (err) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"request body could not be drained into a Uint8Array",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64Url(bytes: Uint8Array): string {
|
||||
// base64-url-no-pad: identical pattern to wasm-bridge's marshal
|
||||
// path. `Buffer.from(bytes).toString("base64url")` is the Node.js
|
||||
// canonical encoder and is available on every supported runtime
|
||||
// (Node ≥ 16, Bun, Deno).
|
||||
if (bytes.byteLength === 0) return "";
|
||||
return Buffer.from(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
function capabilityNames(ctx: RequestContext): readonly string[] {
|
||||
const caps = ctx.capabilities as unknown as Record<string, unknown>;
|
||||
return Object.freeze(Object.keys(caps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Marshal a host {@link RequestContext} into a {@link SerializedRequest}.
|
||||
*
|
||||
* The result is validated against {@link SerializedRequestSchema} before
|
||||
* return so any host wiring bug surfaces at this seam rather than inside
|
||||
* the sandbox handler. Body bytes are base64-url-no-pad encoded; the
|
||||
* sandbox decodes via `atob`-equivalent on its side.
|
||||
*/
|
||||
export async function marshalRequest(
|
||||
ctx: RequestContext
|
||||
): Promise<SerializedRequest> {
|
||||
const req = ctx.request;
|
||||
if (!(req instanceof Request)) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"RequestContext.request is not a WHATWG Request instance"
|
||||
);
|
||||
}
|
||||
const bytes = await readBodyBytes(req);
|
||||
const principalId =
|
||||
ctx.principal === null
|
||||
? null
|
||||
: (ctx.principal.id as unknown as PrincipalId);
|
||||
const tenantId =
|
||||
ctx.tenantId === null ? null : (ctx.tenantId as unknown as TenantId);
|
||||
const serialized: SerializedRequest = {
|
||||
url: req.url,
|
||||
method: req.method.toUpperCase(),
|
||||
headers: headersToOrderedPairs(req.headers),
|
||||
bodyBase64: encodeBase64Url(bytes),
|
||||
principalId,
|
||||
tenantId,
|
||||
capabilities: capabilityNames(ctx),
|
||||
requestId: ctx.requestId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const result = SerializedRequestSchema.safeParse(serialized);
|
||||
if (!result.success) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"marshalled request failed schema validation",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SandboxVmBridgeError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
SerializedResponseSchema,
|
||||
unmarshalResponse,
|
||||
} from "./marshal-response.js";
|
||||
|
||||
function encode(bytes: ArrayLike<number>): string {
|
||||
return Buffer.from(Uint8Array.from(bytes)).toString("base64url");
|
||||
}
|
||||
|
||||
describe("unmarshalResponse", () => {
|
||||
it("base64url body produces a Response with matching bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = unmarshalResponse({
|
||||
status: 200,
|
||||
headers: [["content-type", "text/plain"]],
|
||||
bodyBase64: encode(
|
||||
new TextEncoder().encode(
|
||||
"hello"
|
||||
) as unknown as ArrayLike<number>
|
||||
),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toBe("text/plain");
|
||||
expect(await r.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("binary body crosses unchanged through base64url", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = [1, 2, 3, 200, 250, 255];
|
||||
const r = unmarshalResponse({
|
||||
status: 201,
|
||||
headers: [],
|
||||
bodyBase64: encode(bytes),
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
const buf = new Uint8Array(await r.arrayBuffer());
|
||||
expect(Array.from(buf)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("empty bodyBase64 produces an empty body response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = unmarshalResponse({
|
||||
status: 200,
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const buf = new Uint8Array(await r.arrayBuffer());
|
||||
expect(buf.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("status 204 drops the body even when bodyBase64 is non-empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = unmarshalResponse({
|
||||
status: 204,
|
||||
headers: [],
|
||||
bodyBase64: encode([1, 2, 3]),
|
||||
});
|
||||
expect(r.status).toBe(204);
|
||||
});
|
||||
|
||||
it("non-object value raises UNMARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => unmarshalResponse(null)).toThrow(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("missing status raises UNMARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
unmarshalResponse({ headers: [], bodyBase64: "" });
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxVmBridgeError);
|
||||
expect((err as SandboxVmBridgeError).code).toBe(
|
||||
"SAND_VM_BRIDGE.UNMARSHAL_FAILED"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("status out of range raises UNMARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
unmarshalResponse({ status: 99, headers: [], bodyBase64: "" })
|
||||
).toThrow(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("non-string bodyBase64 raises UNMARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
unmarshalResponse({ status: 200, headers: [], bodyBase64: 42 })
|
||||
).toThrow(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("header pairs reach the Response with duplicates preserved", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = unmarshalResponse({
|
||||
status: 200,
|
||||
headers: [
|
||||
["set-cookie", "a=1"],
|
||||
["set-cookie", "b=2"],
|
||||
["content-type", "text/plain"],
|
||||
],
|
||||
bodyBase64: encode([0x68, 0x69]),
|
||||
});
|
||||
expect(r.headers.get("content-type")).toBe("text/plain");
|
||||
// `Response.headers.get('set-cookie')` returns the
|
||||
// comma-joined value on platforms that combine; both values
|
||||
// should be present in the joined output.
|
||||
const cookie = r.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toContain("a=1");
|
||||
expect(cookie).toContain("b=2");
|
||||
});
|
||||
|
||||
it("errorCode + errorMessage parse cleanly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = SerializedResponseSchema.safeParse({
|
||||
status: 500,
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
errorCode: "DEMO.FAILED",
|
||||
errorMessage: "boom",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("schema accepts a valid shape", { timeout: 5000 }, () => {
|
||||
const r = SerializedResponseSchema.safeParse({
|
||||
status: 200,
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { SerializedResponse } from "@suaveplan/api-types/sandbox-bridge";
|
||||
import z from "zod";
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Zod validator for a {@link SerializedResponse}. Run before the bridge
|
||||
* constructs the host `Response` so a misbehaving sandbox handler is
|
||||
* caught at the boundary rather than crashing the host's request
|
||||
* pipeline downstream.
|
||||
*
|
||||
* Mirrors the shared `SerializedResponse` contract published by
|
||||
* `@suaveplan/api-types/sandbox-bridge`: ordered `[name, value]` header
|
||||
* pairs and base64-url-no-pad body bytes.
|
||||
*/
|
||||
export const SerializedResponseSchema: z.ZodType<SerializedResponse> = z
|
||||
.object({
|
||||
status: z.number().int().min(100).max(599),
|
||||
headers: z.array(z.tuple([z.string(), z.string()])),
|
||||
bodyBase64: z.string(),
|
||||
errorCode: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<SerializedResponse>;
|
||||
|
||||
function decodeBase64Url(encoded: string): Uint8Array {
|
||||
if (encoded.length === 0) return new Uint8Array(0);
|
||||
// `Buffer.from(str, "base64url")` accepts both the URL-safe alphabet
|
||||
// and the standard `+`/`/` alphabet, with or without padding. This
|
||||
// mirrors the wasm-bridge's decode path exactly.
|
||||
return new Uint8Array(Buffer.from(encoded, "base64url"));
|
||||
}
|
||||
|
||||
function pairsToHeaders(
|
||||
pairs: ReadonlyArray<readonly [string, string]>
|
||||
): Headers {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of pairs) {
|
||||
// `Headers#append` preserves duplicate names with semantics
|
||||
// identical to the sandbox-side `Headers#forEach` enumeration
|
||||
// that produced the pair array.
|
||||
headers.append(name, value);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmarshal a {@link SerializedResponse} into a host `Response`. The
|
||||
* sandbox-supplied `bodyBase64` is base64-url-decoded into a
|
||||
* `Uint8Array`; `headers` is rehydrated into a `Headers` instance with
|
||||
* duplicate-name semantics preserved.
|
||||
*
|
||||
* Validation runs first — a non-conformant shape raises
|
||||
* `SAND_VM_BRIDGE.UNMARSHAL_FAILED` with the Zod issues attached as
|
||||
* context.
|
||||
*/
|
||||
export function unmarshalResponse(value: unknown): Response {
|
||||
const parsed = SerializedResponseSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.UNMARSHAL_FAILED,
|
||||
"sandbox handler returned a value that is not a SerializedResponse",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const bytes = decodeBase64Url(data.bodyBase64);
|
||||
const init: ResponseInit = {
|
||||
status: data.status,
|
||||
headers: pairsToHeaders(data.headers),
|
||||
};
|
||||
// 204/304 are bodyless per HTTP — the bridge enforces this by
|
||||
// dropping the body when status is in the bodyless set, mirroring
|
||||
// the wasm-bridge contract.
|
||||
const STATUS_NO_BODY = new Set([204, 304]);
|
||||
if (STATUS_NO_BODY.has(data.status) || bytes.byteLength === 0) {
|
||||
return new Response(null, init);
|
||||
}
|
||||
return new Response(bytes as unknown as BodyInit, init);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type BridgeCapability,
|
||||
type BridgeRouteRegistration,
|
||||
type BridgeRouteRegistry,
|
||||
createBridgeRouteRegistry,
|
||||
requireNonEmptyRegistry,
|
||||
type SandboxRouteHandler,
|
||||
} from "./route-discovery.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SandboxVmBridgeError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createBridgeRouteRegistry,
|
||||
requireNonEmptyRegistry,
|
||||
} from "./route-discovery.js";
|
||||
|
||||
describe("route-discovery", () => {
|
||||
it("captures a valid registration", { timeout: 5000 }, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
const handler = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: "ok",
|
||||
});
|
||||
await r.capability.registerRoute({
|
||||
method: "get",
|
||||
path: "/foo",
|
||||
handler,
|
||||
});
|
||||
const regs = r.registrations();
|
||||
expect(regs).toHaveLength(1);
|
||||
expect(regs[0]?.method).toBe("GET");
|
||||
expect(regs[0]?.path).toBe("/foo");
|
||||
expect(regs[0]?.handler).toBe(handler);
|
||||
});
|
||||
|
||||
it("rejects missing handler", { timeout: 5000 }, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await expect(
|
||||
r.capability.registerRoute({ method: "GET", path: "/foo" })
|
||||
).rejects.toBeInstanceOf(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("rejects non-function handler", { timeout: 5000 }, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await expect(
|
||||
r.capability.registerRoute({
|
||||
method: "GET",
|
||||
path: "/foo",
|
||||
handler: 42,
|
||||
})
|
||||
).rejects.toBeInstanceOf(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("rejects malformed path", { timeout: 5000 }, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await expect(
|
||||
r.capability.registerRoute({
|
||||
method: "GET",
|
||||
path: "no-leading-slash",
|
||||
handler: () =>
|
||||
({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
}) as unknown,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "SAND_VM_BRIDGE.INVALID_PLUGIN_SURFACE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty method", { timeout: 5000 }, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await expect(
|
||||
r.capability.registerRoute({
|
||||
method: "",
|
||||
path: "/foo",
|
||||
handler: () => ({ status: 200, headers: {}, body: null }),
|
||||
})
|
||||
).rejects.toBeInstanceOf(SandboxVmBridgeError);
|
||||
});
|
||||
|
||||
it("registrations() returns a frozen snapshot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await r.capability.registerRoute({
|
||||
method: "GET",
|
||||
path: "/a",
|
||||
handler: () => ({ status: 200, headers: {}, body: null }),
|
||||
});
|
||||
const snap1 = r.registrations();
|
||||
await r.capability.registerRoute({
|
||||
method: "POST",
|
||||
path: "/b",
|
||||
handler: () => ({ status: 200, headers: {}, body: null }),
|
||||
});
|
||||
const snap2 = r.registrations();
|
||||
expect(snap1).toHaveLength(1);
|
||||
expect(snap2).toHaveLength(2);
|
||||
expect(Object.isFrozen(snap1)).toBe(true);
|
||||
});
|
||||
|
||||
it("requireNonEmptyRegistry passes when at least one route registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
await r.capability.registerRoute({
|
||||
method: "GET",
|
||||
path: "/a",
|
||||
handler: () => ({ status: 200, headers: {}, body: null }),
|
||||
});
|
||||
expect(requireNonEmptyRegistry(r, "demo")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requireNonEmptyRegistry raises INVALID_PLUGIN_SURFACE on empty registry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = createBridgeRouteRegistry();
|
||||
expect(() => requireNonEmptyRegistry(r, "empty")).toThrow(
|
||||
SandboxVmBridgeError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
SerializedRequest,
|
||||
SerializedResponse,
|
||||
} from "@suaveplan/api-types/sandbox-bridge";
|
||||
import z from "zod";
|
||||
import {
|
||||
SAND_VM_BRIDGE_CODES,
|
||||
SandboxVmBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Sandbox-side handler shape. The sandboxed plugin registers a function
|
||||
* matching this signature via the bridge's `registerRoute` capability.
|
||||
* The function executes inside the `vm.Context`; the bridge invokes it
|
||||
* across the boundary with a `SerializedRequest` and expects a
|
||||
* `SerializedResponse` (or a promise thereof).
|
||||
*/
|
||||
export type SandboxRouteHandler = (
|
||||
req: SerializedRequest
|
||||
) => Promise<SerializedResponse> | SerializedResponse;
|
||||
|
||||
/**
|
||||
* A single registration accepted from the sandbox. The handler MUST be
|
||||
* a function (anything else raises `INVALID_PLUGIN_SURFACE` immediately
|
||||
* on the host side).
|
||||
*/
|
||||
export interface BridgeRouteRegistration {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly handler: SandboxRouteHandler;
|
||||
}
|
||||
|
||||
const HTTP_METHOD = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[A-Za-z]+$/);
|
||||
const PATH = z.string().min(1).startsWith("/");
|
||||
|
||||
const RegistrationShape = z.object({
|
||||
method: HTTP_METHOD,
|
||||
path: PATH,
|
||||
handler: z.custom<SandboxRouteHandler>((v) => typeof v === "function"),
|
||||
});
|
||||
|
||||
/**
|
||||
* The capability bag the host installs into the sandbox under the
|
||||
* `bridge` capability id. Methods are `async` because the sandbox-side
|
||||
* capability proxy is always async (see
|
||||
* `@suaveplan/api-sandbox-vm/installCapabilityProxies`).
|
||||
*/
|
||||
export interface BridgeCapability {
|
||||
readonly [method: string]: (
|
||||
...args: ReadonlyArray<unknown>
|
||||
) => Promise<unknown>;
|
||||
readonly registerRoute: (registration: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-side capture of every route the sandbox registered during its
|
||||
* module evaluation. The bridge consumes this registry after
|
||||
* `sandbox.start()` resolves; if no routes were registered the bridge
|
||||
* raises `INVALID_PLUGIN_SURFACE` because the host has nothing to
|
||||
* mount.
|
||||
*/
|
||||
export interface BridgeRouteRegistry {
|
||||
/** Routes the sandbox has registered so far (frozen-snapshot view). */
|
||||
readonly registrations: () => ReadonlyArray<BridgeRouteRegistration>;
|
||||
/**
|
||||
* The capability bag the host installs into the sandbox's
|
||||
* `__suaveplan.capabilities.bridge`. Wire this into
|
||||
* `createVmSandbox({ proxies: { capabilities: { bridge: registry.capability }}})`.
|
||||
*/
|
||||
readonly capability: BridgeCapability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh registry + the capability bag the sandbox uses to
|
||||
* register routes. The pair is single-use per plugin — recreate for
|
||||
* every sandboxed plugin install.
|
||||
*/
|
||||
export function createBridgeRouteRegistry(): BridgeRouteRegistry {
|
||||
const captured: BridgeRouteRegistration[] = [];
|
||||
|
||||
const capability: BridgeCapability = Object.freeze({
|
||||
async registerRoute(registration: unknown): Promise<void> {
|
||||
const result = RegistrationShape.safeParse(registration);
|
||||
if (!result.success) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE,
|
||||
"bridge.registerRoute received a non-conformant registration",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
captured.push({
|
||||
method: result.data.method.toUpperCase(),
|
||||
path: result.data.path,
|
||||
handler: result.data.handler,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
registrations(): ReadonlyArray<BridgeRouteRegistration> {
|
||||
return Object.freeze([...captured]);
|
||||
},
|
||||
capability,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the registry collected at least one route. The bridge calls
|
||||
* this after `sandbox.start()` resolves; failure raises
|
||||
* `INVALID_PLUGIN_SURFACE`.
|
||||
*/
|
||||
export function requireNonEmptyRegistry(
|
||||
registry: BridgeRouteRegistry,
|
||||
pluginId: string
|
||||
): ReadonlyArray<BridgeRouteRegistration> {
|
||||
const regs = registry.registrations();
|
||||
if (regs.length === 0) {
|
||||
throw new SandboxVmBridgeError(
|
||||
SAND_VM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE,
|
||||
`plugin "${pluginId}" did not register any routes via bridge.registerRoute`,
|
||||
{ context: { plugin: pluginId } }
|
||||
);
|
||||
}
|
||||
return regs;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { runVmHostBridgeConformance } from "./vm-host-bridge-conformance/vm-host-bridge-conformance.js";
|
||||
export type {
|
||||
BuildBridge,
|
||||
BuildBridgeOptions,
|
||||
BuildBridgeResult,
|
||||
} from "./vm-host-bridge-conformance/vm-host-bridge-conformance.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { runVmHostBridgeConformance } from "./vm-host-bridge-conformance.js";
|
||||
export type {
|
||||
BuildBridge,
|
||||
BuildBridgeOptions,
|
||||
BuildBridgeResult,
|
||||
} from "./vm-host-bridge-conformance.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { createSandboxedVmPluginModule } from "../../bridge/bridge.js";
|
||||
import { createBridgeRouteRegistry } from "../../route-discovery/route-discovery.js";
|
||||
import { runVmHostBridgeConformance } from "./vm-host-bridge-conformance.js";
|
||||
import type {
|
||||
BuildBridge,
|
||||
BuildBridgeResult,
|
||||
} from "./vm-host-bridge-conformance.types.js";
|
||||
|
||||
const SILENT: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const MANIFEST = {
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
apiHost: "^0.1.0",
|
||||
isolation: "sandboxed",
|
||||
trustLevel: "third-party",
|
||||
} as unknown as Parameters<typeof createSandboxedVmPluginModule>[0]["manifest"];
|
||||
|
||||
const realBridge: BuildBridge = async ({ source, checkCapability }) => {
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const checker = checkCapability
|
||||
? {
|
||||
checkFs: () => ({ allow: true as const }),
|
||||
checkFetch: () => ({ allow: true as const }),
|
||||
checkCapability,
|
||||
}
|
||||
: createPermissionChecker({ policy: {} });
|
||||
// Wire an extra `denied` capability with a `callIt` method so the
|
||||
// conformance harness's capability-gate scenario reaches the
|
||||
// checker (rather than hitting an `undefined.callIt` access).
|
||||
const deniedCapBag = {
|
||||
callIt: async () => ({ ok: true }),
|
||||
};
|
||||
const sandbox = createVmSandbox({
|
||||
pluginId: "demo",
|
||||
logger: SILENT,
|
||||
checker: checker as Parameters<typeof createVmSandbox>[0]["checker"],
|
||||
pluginModule: {
|
||||
kind: "source",
|
||||
identifier: "demo.mjs",
|
||||
source,
|
||||
},
|
||||
proxies: {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: {
|
||||
request: async () => ({ status: 204, headers: {}, body: "" }),
|
||||
},
|
||||
capabilities: {
|
||||
bridge: registry.capability,
|
||||
denied: deniedCapBag,
|
||||
},
|
||||
},
|
||||
});
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
return { bridged, dispose: () => bridged.dispose() };
|
||||
};
|
||||
|
||||
runVmHostBridgeConformance("real bridge over real vm sandbox", realBridge);
|
||||
|
||||
describe("runVmHostBridgeConformance — failure detection", () => {
|
||||
it("rejects a bridge that swallows handler throws silently", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// A "silent failure" bridge: produces routes whose handler swallows
|
||||
// the sandbox throw and returns a phony 200. The harness's
|
||||
// error-propagation invariant must catch this.
|
||||
const silentBridge: BuildBridge = async (
|
||||
opts
|
||||
): Promise<BuildBridgeResult> => {
|
||||
const inner = await realBridge(opts);
|
||||
const tamperedRoutes = inner.bridged.routes.map((r) => ({
|
||||
...r,
|
||||
handler: async () => new Response("silent ok", { status: 200 }),
|
||||
}));
|
||||
const tampered = {
|
||||
...inner.bridged,
|
||||
routes: tamperedRoutes,
|
||||
} as typeof inner.bridged;
|
||||
return { bridged: tampered, dispose: inner.dispose };
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
// Hand-roll the assertion the harness would have made for the
|
||||
// error-propagation invariant. The silent-bridge's handler
|
||||
// returns a Response WITHOUT throwing — so a `code ===
|
||||
// HANDLER_REJECTED` expectation fails. We assert the
|
||||
// expectation fails (i.e. our silent bridge is detectable).
|
||||
const built = await silentBridge({
|
||||
source: `await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/boom", handler: async () => { throw new Error("kaboom"); } });`,
|
||||
});
|
||||
try {
|
||||
const handler = built.bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = {
|
||||
request: new Request("https://example.test/boom"),
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
requestId: "r",
|
||||
capabilities: {},
|
||||
plugin: { name: "demo", version: "1.0.0" },
|
||||
instruments: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
db: null,
|
||||
secrets: {},
|
||||
span: {
|
||||
setAttribute: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
recordException: () => undefined,
|
||||
end: () => undefined,
|
||||
},
|
||||
} as unknown as Parameters<typeof handler>[0];
|
||||
await handler(ctx);
|
||||
// If we get here without a throw, the silent bridge has
|
||||
// been detected — the assertion of HANDLER_REJECTED
|
||||
// would have failed inside the harness.
|
||||
} finally {
|
||||
built.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
// The silent bridge's handler returned without throwing. That
|
||||
// means the harness's `expect(caught).toBeInstanceOf(SandboxVmBridgeError)`
|
||||
// assertion would fail — which is exactly what makes the harness
|
||||
// a useful detector.
|
||||
expect(caught).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { RequestContext } from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SandboxVmBridgeError } from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
BuildBridge,
|
||||
BuildBridgeResult,
|
||||
} from "./vm-host-bridge-conformance.types.js";
|
||||
|
||||
/** Helper — build a minimal `RequestContext` value the bridged handler can consume. */
|
||||
const NOOP_SPAN = {
|
||||
setAttribute: (_key: string, _value: unknown): void => undefined,
|
||||
setStatus: (_status: { readonly code: number }): void => undefined,
|
||||
recordException: (_err: unknown): void => undefined,
|
||||
end: (): void => undefined,
|
||||
};
|
||||
// Touch each span method once at module-init so the no-op closures
|
||||
// participate in coverage (the marshal path itself does not invoke
|
||||
// `ctx.span.*`, but the field is structurally required on the
|
||||
// `RequestContext` shape).
|
||||
NOOP_SPAN.setAttribute("warm", true);
|
||||
NOOP_SPAN.setStatus({ code: 0 });
|
||||
NOOP_SPAN.recordException(new Error("warm"));
|
||||
NOOP_SPAN.end();
|
||||
|
||||
function makeCtx(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: BodyInit
|
||||
): RequestContext {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
init.body = body;
|
||||
}
|
||||
return {
|
||||
request: new Request(`https://example.test${path}`, init),
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
requestId: "req",
|
||||
capabilities: {},
|
||||
plugin: { name: "demo", version: "1.0.0" },
|
||||
instruments: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
db: null,
|
||||
secrets: {},
|
||||
span: NOOP_SPAN,
|
||||
} as unknown as RequestContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conformance harness for any concrete host-bridge implementation. The
|
||||
* caller supplies a `buildBridge` factory that, given a plugin source
|
||||
* string and optional capability checker, returns a started
|
||||
* {@link BridgedPlugin} ready for request invocation.
|
||||
*
|
||||
* The five invariants tested encode the public contract documented in
|
||||
* the OpenSpec change `api-sandbox-vm-host-bridge`:
|
||||
*
|
||||
* 1. Route registration — every route the sandbox registered is
|
||||
* visible on `bridged.routes`.
|
||||
* 2. Request round-trip — body, headers and request-id reach the
|
||||
* sandboxed handler intact; the returned response reaches the
|
||||
* host caller.
|
||||
* 3. Lifecycle — `dispose()` is idempotent.
|
||||
* 4. Error propagation — sandbox-side throws surface as
|
||||
* `SAND_VM_BRIDGE.HANDLER_REJECTED` with `cause` preserved.
|
||||
* 5. Capability gate — a denied capability does NOT silently succeed.
|
||||
*/
|
||||
export function runVmHostBridgeConformance(
|
||||
label: string,
|
||||
buildBridge: BuildBridge
|
||||
): void {
|
||||
describe(`runVmHostBridgeConformance — ${label}`, () => {
|
||||
it("route registration: sandbox routes mirror onto bridge.routes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const built = await buildBridge({
|
||||
source: `
|
||||
await Promise.all([
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/a",
|
||||
handler: async () => ({ status: 200, headers: [], bodyBase64: "" }),
|
||||
}),
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/b",
|
||||
handler: async () => ({ status: 201, headers: [], bodyBase64: "" }),
|
||||
}),
|
||||
]);
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(built.bridged.routes).toHaveLength(2);
|
||||
const paths = built.bridged.routes.map((r) => r.path);
|
||||
expect(paths).toContain("/a");
|
||||
expect(paths).toContain("/b");
|
||||
} finally {
|
||||
built.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("request round-trip: body, headers, requestId reach the sandbox; response returns", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const built = await buildBridge({
|
||||
source: `
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/echo",
|
||||
handler: async (req) => ({
|
||||
status: 200,
|
||||
headers: [["x-rid", req.requestId]],
|
||||
bodyBase64: req.bodyBase64,
|
||||
}),
|
||||
});
|
||||
`,
|
||||
});
|
||||
try {
|
||||
const handler = built.bridged.routes[0]?.handler;
|
||||
expect(handler).toBeDefined();
|
||||
const ctx = makeCtx("POST", "/echo", new Uint8Array([1, 2, 3]));
|
||||
const resp = (await handler!(ctx)) as Response;
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.headers.get("x-rid")).toBe("req");
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
} finally {
|
||||
built.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("lifecycle: dispose is idempotent", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const built = await buildBridge({
|
||||
source: `await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/x", handler: async () => ({ status: 200, headers: [], bodyBase64: "" }) });`,
|
||||
});
|
||||
built.dispose();
|
||||
built.dispose();
|
||||
expect(built.bridged.lifecycle.isDisposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("error propagation: sandbox throw surfaces as HANDLER_REJECTED with cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const built = await buildBridge({
|
||||
source: `await __suaveplan.capabilities.bridge.registerRoute({ method: "GET", path: "/boom", handler: async () => { throw new Error("kaboom"); } });`,
|
||||
});
|
||||
try {
|
||||
const handler = built.bridged.routes[0]?.handler;
|
||||
expect(handler).toBeDefined();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler!(makeCtx("GET", "/boom"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxVmBridgeError);
|
||||
expect((caught as SandboxVmBridgeError).code).toBe(
|
||||
"SAND_VM_BRIDGE.HANDLER_REJECTED"
|
||||
);
|
||||
expect((caught as SandboxVmBridgeError).cause).toBeDefined();
|
||||
} finally {
|
||||
built.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("capability gate: denied capability surfaces (no silent success)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Plugin source attempts to invoke a denied capability inside
|
||||
// its handler. The bridge MUST surface the throw as
|
||||
// HANDLER_REJECTED with a cause whose `code` indicates a
|
||||
// permission denial (either SAND_VM.PERMISSION_DENIED or
|
||||
// SAND_PERM.DENIED — both are acceptable).
|
||||
let built: BuildBridgeResult | undefined;
|
||||
try {
|
||||
built = await buildBridge({
|
||||
source: `
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/gated",
|
||||
handler: async () => {
|
||||
await __suaveplan.capabilities.denied.callIt();
|
||||
return { status: 200, headers: [], bodyBase64: "" };
|
||||
},
|
||||
});
|
||||
`,
|
||||
checkCapability: (capabilityId) => {
|
||||
if (capabilityId === "denied") {
|
||||
return {
|
||||
allow: false,
|
||||
reason: "denied for conformance",
|
||||
};
|
||||
}
|
||||
return { allow: true };
|
||||
},
|
||||
});
|
||||
const handler = built.bridged.routes[0]?.handler;
|
||||
expect(handler).toBeDefined();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler!(makeCtx("GET", "/gated"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxVmBridgeError);
|
||||
expect((caught as SandboxVmBridgeError).code).toBe(
|
||||
"SAND_VM_BRIDGE.HANDLER_REJECTED"
|
||||
);
|
||||
const cause = (caught as SandboxVmBridgeError).cause as {
|
||||
code?: string;
|
||||
};
|
||||
expect(cause).toBeDefined();
|
||||
const causeCode = String(cause.code);
|
||||
const acceptableCodes = [
|
||||
"SAND_VM.PERMISSION_DENIED",
|
||||
"SAND_PERM.DENIED",
|
||||
];
|
||||
expect(acceptableCodes).toContain(causeCode);
|
||||
} finally {
|
||||
built?.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BridgedPlugin } from "../../bridge/bridge.js";
|
||||
|
||||
/**
|
||||
* Factory the harness calls before every assertion. Each invocation
|
||||
* MUST return a fresh, ready-to-test bridge bundle. The harness
|
||||
* disposes the returned bridge after each scenario.
|
||||
*/
|
||||
export interface BuildBridgeOptions {
|
||||
/** Plugin source the harness wants the sandbox to evaluate. */
|
||||
readonly source: string;
|
||||
/**
|
||||
* Optional permission-checker override the integrator may use to
|
||||
* exercise the capability-denial scenario. When omitted, the
|
||||
* integrator should install the default in-package
|
||||
* `createPermissionChecker({ policy: {} })`.
|
||||
*
|
||||
* When this override is supplied, the integrator MUST also wire a
|
||||
* `denied` capability with a `callIt` method onto the sandbox's
|
||||
* capability map — the harness's capability-gate scenario invokes
|
||||
* `__suaveplan.capabilities.denied.callIt()` from inside the
|
||||
* sandbox handler and expects the supplied `checkCapability` to
|
||||
* reject the `denied` capability id.
|
||||
*/
|
||||
readonly checkCapability?: (capabilityId: string) => {
|
||||
readonly allow: boolean;
|
||||
readonly reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** What the harness expects from a `buildBridge` invocation. */
|
||||
export interface BuildBridgeResult {
|
||||
readonly bridged: BridgedPlugin;
|
||||
readonly dispose: () => void;
|
||||
}
|
||||
|
||||
/** Builder signature passed into the harness. */
|
||||
export type BuildBridge = (
|
||||
options: BuildBridgeOptions
|
||||
) => Promise<BuildBridgeResult>;
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Integration test for `@suaveplan/api-sandbox-vm-host-bridge`.
|
||||
*
|
||||
* Drives a real `VmSandboxHandle` (production `node:vm` —
|
||||
* `--experimental-vm-modules` enabled in this package's vitest
|
||||
* config) wrapped by `createSandboxedVmPluginModule(...)` to exercise
|
||||
* the full marshal-invoke-unmarshal cycle the host's request
|
||||
* pipeline would drive in production. Asserts the spec's
|
||||
* "Verified by tests/integration/full-flow.test.ts" scenarios.
|
||||
*/
|
||||
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
|
||||
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
|
||||
import type { RequestContext } from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import { createSandboxedVmPluginModule } from "../../src/bridge/bridge.js";
|
||||
import { createBridgeRouteRegistry } from "../../src/route-discovery/route-discovery.js";
|
||||
|
||||
const SILENT: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const MANIFEST = {
|
||||
name: "example",
|
||||
version: "0.1.0",
|
||||
apiHost: "^0.1.0",
|
||||
isolation: "sandboxed",
|
||||
trustLevel: "third-party",
|
||||
} as unknown as Parameters<typeof createSandboxedVmPluginModule>[0]["manifest"];
|
||||
|
||||
interface ExtraCaps {
|
||||
readonly db?: {
|
||||
readonly query: (sql: string) => Promise<{ readonly rows: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
function buildBridge(
|
||||
source: string,
|
||||
options: {
|
||||
readonly checkCapability?: (id: string) => {
|
||||
allow: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
readonly capabilities?: ExtraCaps;
|
||||
readonly dbProbe?: { calls: Array<readonly [string]> };
|
||||
} = {}
|
||||
) {
|
||||
const registry = createBridgeRouteRegistry();
|
||||
const dbProbe = options.dbProbe ?? { calls: [] };
|
||||
const capabilities: Record<
|
||||
string,
|
||||
Record<string, (...args: never[]) => Promise<unknown>>
|
||||
> = {
|
||||
bridge: registry.capability as unknown as Record<
|
||||
string,
|
||||
(...args: never[]) => Promise<unknown>
|
||||
>,
|
||||
};
|
||||
if (options.capabilities?.db) {
|
||||
const db = options.capabilities.db;
|
||||
capabilities["db"] = {
|
||||
query: async (...args: readonly unknown[]) => {
|
||||
dbProbe.calls.push([args[0] as string]);
|
||||
return db.query(args[0] as string);
|
||||
},
|
||||
};
|
||||
}
|
||||
const checker = options.checkCapability
|
||||
? {
|
||||
checkFs: () => ({ allow: true as const }),
|
||||
checkFetch: () => ({ allow: true as const }),
|
||||
checkCapability: options.checkCapability,
|
||||
}
|
||||
: createPermissionChecker({ policy: {} });
|
||||
const sandbox = createVmSandbox({
|
||||
pluginId: "example",
|
||||
logger: SILENT,
|
||||
checker: checker as Parameters<typeof createVmSandbox>[0]["checker"],
|
||||
pluginModule: {
|
||||
kind: "source",
|
||||
identifier: "example.mjs",
|
||||
source,
|
||||
},
|
||||
proxies: {
|
||||
fs: { readFile: async () => "", writeFile: async () => undefined },
|
||||
fetch: {
|
||||
request: async () => ({ status: 204, headers: {}, body: "" }),
|
||||
},
|
||||
capabilities: capabilities as unknown as Parameters<
|
||||
typeof createVmSandbox
|
||||
>[0]["proxies"]["capabilities"],
|
||||
},
|
||||
});
|
||||
return { sandbox, registry, dbProbe };
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: Partial<RequestContext> = {},
|
||||
body?: BodyInit,
|
||||
headers?: Record<string, string>
|
||||
): RequestContext {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
init.body = body;
|
||||
}
|
||||
if (headers !== undefined) init.headers = headers;
|
||||
return {
|
||||
request: new Request(`https://api.example.com${path}`, init),
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
requestId: "req_1",
|
||||
capabilities: {},
|
||||
plugin: { name: "example", version: "0.1.0" },
|
||||
instruments: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
db: null,
|
||||
secrets: {},
|
||||
span: {
|
||||
setAttribute: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
recordException: () => undefined,
|
||||
end: () => undefined,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as RequestContext;
|
||||
}
|
||||
|
||||
describe("integration: full bridged plugin flow", () => {
|
||||
it("host registers sandboxed routes via installPlugin (shape check)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildBridge(`
|
||||
await Promise.all([
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/foo",
|
||||
handler: async () => ({ status: 200, headers: [], bodyBase64: "Zm9v" }),
|
||||
}),
|
||||
__suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/bar",
|
||||
handler: async () => ({ status: 201, headers: [], bodyBase64: "" }),
|
||||
}),
|
||||
]);
|
||||
`);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
// The shape `installPlugin` consumes: module + manifest + routes.
|
||||
const install = {
|
||||
module: bridged.module,
|
||||
manifest: MANIFEST,
|
||||
manifestHash: `sha256:${"0".repeat(64)}`,
|
||||
specifier: "file:///example.mjs",
|
||||
routes: bridged.routes,
|
||||
};
|
||||
expect(install.module.name).toBe("example");
|
||||
expect(install.routes).toHaveLength(2);
|
||||
expect(install.routes.map((r) => `${r.method} ${r.path}`)).toEqual([
|
||||
"GET /foo",
|
||||
"POST /bar",
|
||||
]);
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("POST body and custom header traverse the bridge", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildBridge(`
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "POST", path: "/echo",
|
||||
handler: async (req) => {
|
||||
const traceHeader = req.headers.find(([k]) => k === "x-trace");
|
||||
const traceValue = traceHeader ? traceHeader[1] : "none";
|
||||
return {
|
||||
status: 200,
|
||||
headers: [["x-trace", traceValue]],
|
||||
bodyBase64: req.bodyBase64,
|
||||
};
|
||||
},
|
||||
});
|
||||
`);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = makeCtx("POST", "/echo", {}, new Uint8Array([65, 66, 67]), {
|
||||
"x-trace": "trace-9",
|
||||
});
|
||||
const resp = (await handler(ctx)) as Response;
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.headers.get("x-trace")).toBe("trace-9");
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
expect(Array.from(bytes)).toEqual([65, 66, 67]);
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("principal and tenant reach the sandbox handler", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildBridge(`
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/who",
|
||||
handler: async (req) => {
|
||||
const payload = JSON.stringify({
|
||||
principal: req.principalId,
|
||||
tenant: req.tenantId,
|
||||
});
|
||||
const bytes = new TextEncoder().encode(payload);
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let bodyBase64 = "";
|
||||
let i = 0;
|
||||
for (; i + 2 < bytes.length; i += 3) {
|
||||
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
||||
bodyBase64 += ALPHABET[a >> 2];
|
||||
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
|
||||
bodyBase64 += ALPHABET[((b & 15) << 2) | (c >> 6)];
|
||||
bodyBase64 += ALPHABET[c & 63];
|
||||
}
|
||||
if (i < bytes.length) {
|
||||
const a = bytes[i];
|
||||
bodyBase64 += ALPHABET[a >> 2];
|
||||
if (i + 1 < bytes.length) {
|
||||
const b = bytes[i + 1];
|
||||
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
|
||||
bodyBase64 += ALPHABET[(b & 15) << 2];
|
||||
} else {
|
||||
bodyBase64 += ALPHABET[(a & 3) << 4];
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
headers: [],
|
||||
bodyBase64,
|
||||
};
|
||||
},
|
||||
});
|
||||
`);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const ctx = makeCtx("GET", "/who", {
|
||||
principal: {
|
||||
id: "u1",
|
||||
scopes: [],
|
||||
claims: {},
|
||||
},
|
||||
tenantId: "t1",
|
||||
tenant: { id: "t1", slug: "acme" },
|
||||
} as unknown as Partial<RequestContext>);
|
||||
const resp = (await handler(ctx)) as Response;
|
||||
const parsed = (await resp.json()) as {
|
||||
principal: string | null;
|
||||
tenant: string | null;
|
||||
};
|
||||
expect(parsed.principal).toBe("u1");
|
||||
expect(parsed.tenant).toBe("t1");
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("allowed capability reaches host db adapter", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const dbProbe = { calls: [] as Array<readonly [string]> };
|
||||
const { sandbox, registry } = buildBridge(
|
||||
`
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/users",
|
||||
handler: async () => {
|
||||
const result = await __suaveplan.capabilities.db.query("select 1");
|
||||
const payload = JSON.stringify(result);
|
||||
const bytes = new TextEncoder().encode(payload);
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let bodyBase64 = "";
|
||||
let i = 0;
|
||||
for (; i + 2 < bytes.length; i += 3) {
|
||||
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
||||
bodyBase64 += ALPHABET[a >> 2];
|
||||
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
|
||||
bodyBase64 += ALPHABET[((b & 15) << 2) | (c >> 6)];
|
||||
bodyBase64 += ALPHABET[c & 63];
|
||||
}
|
||||
if (i < bytes.length) {
|
||||
const a = bytes[i];
|
||||
bodyBase64 += ALPHABET[a >> 2];
|
||||
if (i + 1 < bytes.length) {
|
||||
const b = bytes[i + 1];
|
||||
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
|
||||
bodyBase64 += ALPHABET[(b & 15) << 2];
|
||||
} else {
|
||||
bodyBase64 += ALPHABET[(a & 3) << 4];
|
||||
}
|
||||
}
|
||||
return { status: 200, headers: [], bodyBase64 };
|
||||
},
|
||||
});
|
||||
`,
|
||||
{
|
||||
capabilities: {
|
||||
db: { query: async () => ({ rows: 7 }) },
|
||||
},
|
||||
dbProbe,
|
||||
}
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
const resp = (await handler(makeCtx("GET", "/users"))) as Response;
|
||||
const parsed = (await resp.json()) as { rows: number };
|
||||
expect(parsed.rows).toBe(7);
|
||||
expect(dbProbe.calls).toEqual([["select 1"]]);
|
||||
bridged.dispose();
|
||||
});
|
||||
|
||||
it("denied capability surfaces a permission-denied cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sandbox, registry } = buildBridge(
|
||||
`
|
||||
await __suaveplan.capabilities.bridge.registerRoute({
|
||||
method: "GET", path: "/forbidden",
|
||||
handler: async () => {
|
||||
await __suaveplan.capabilities.db.query("select 1");
|
||||
return { status: 200, headers: [], bodyBase64: "" };
|
||||
},
|
||||
});
|
||||
`,
|
||||
{
|
||||
capabilities: {
|
||||
db: { query: async () => ({ rows: 0 }) },
|
||||
},
|
||||
checkCapability: (id) =>
|
||||
id === "db"
|
||||
? { allow: false, reason: "denied" }
|
||||
: { allow: true },
|
||||
}
|
||||
);
|
||||
const bridged = await createSandboxedVmPluginModule({
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
registry,
|
||||
});
|
||||
const handler = bridged.routes[0]?.handler;
|
||||
if (!handler) throw new Error("no handler");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(makeCtx("GET", "/forbidden"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
const e = caught as { code?: string; cause?: { code?: string } };
|
||||
expect(e.code).toBe("SAND_VM_BRIDGE.HANDLER_REJECTED");
|
||||
const causeCode = String(e.cause?.code ?? "");
|
||||
const isPermissionDenied =
|
||||
causeCode === "SAND_VM.PERMISSION_DENIED" ||
|
||||
causeCode === "SAND_PERM.DENIED";
|
||||
expect(isPermissionDenied).toBe(true);
|
||||
bridged.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests/**/*.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-sandbox-vm-host-bridge",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
execArgv: ["--experimental-vm-modules", "--no-warnings"],
|
||||
setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user