first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# @suaveplan/api-plugin-example - Reference plugin for the @suaveplan/api-host plugin system. Exercises every contract surface reachable through createTestHost: definePlugin spec, REST routes (public, RBAC-gated, Zod-validated, streaming, idempotent, rate-limited, schema-violation), custom PLUGIN.EXAMPLE.* error codes mapped to HTTP statuses, manifest-declared OTel instruments (counter + histogram + gauge), seeded secrets, tenant-scoped DB, event publish + subscribe handlers. Ships its own integration suite that runs end-to-end against api-host/testing.createTestHost so any host-side change that breaks the plugin contract fails in one place.
Reference plugin for the `@suaveplan/api-host` plugin system. The
package is a load-bearing integration target, not a tutorial: every
contract surface the host exposes through `createTestHost` is wired
through one route or one hook here, and the package's own integration
test runs that surface end-to-end. When a host-side change breaks the
plugin contract, this test fails in one place against the in-memory
stack.
The plugin doubles as the canonical onboarding doc. When a plugin
author asks "how do I use capability X?", the answer is "look at how
`api-plugin-example` uses it."
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — The reference plugin owns a small set of stable error codes that the integration suite asserts are surfaced through the…
- [error-codes](./src/error-codes/error-codes.md) — The reference plugin owns a small set of stable error codes that the integration suite asserts are surfaced through the…
- [events](./src/events/events.md) — The reference plugin both publishes and subscribes to events. This module exposes the topic names and the Zod payload s…
- [events](./src/events/events.md) — The reference plugin both publishes and subscribes to events. This module exposes the topic names and the Zod payload s…
- [instruments](./src/instruments/instruments.md) — The reference plugin declares three OTel instruments that mirror the `telemetry.metrics[]` block in `plugin.json`. The…
- [instruments](./src/instruments/instruments.md) — The reference plugin declares three OTel instruments that mirror the `telemetry.metrics[]` block in `plugin.json`. The…
- [plugin](./src/plugin/plugin.md) — The reference plugin's `definePlugin` invocation lives in its own module so the manifest, the integration test, and any…
- [plugin](./src/plugin/plugin.md) — The reference plugin's `definePlugin` invocation lives in its own module so the manifest, the integration test, and any…
- [routes](./src/routes/routes.md) — The route table is the heart of the reference plugin. Every route demonstrates one piece of the §6 11-step request pipe…
- [routes](./src/routes/routes.md) — The route table is the heart of the reference plugin. Every route demonstrates one piece of the §6 11-step request pipe…
- [schemas](./src/schemas/schemas.md) — Every route in the reference plugin is wired against a Zod schema so the §6 input-validation step always has a contract…
- [schemas](./src/schemas/schemas.md) — Every route in the reference plugin is wired against a Zod schema so the §6 input-validation step always has a contract…
<!-- end:auto-docs -->
## Capability map (scope, against `createTestHost`)
| Capability | Surface in this package |
| --------------------------- | -------------------------------------------------------------------------- |
| `definePlugin` lifecycle | `src/plugin/plugin.ts` — install / start / stop / uninstall / health all wired |
| REST — public | `GET /example/public/ping`, `GET /example/health` |
| REST — RBAC-gated | `GET /example/invoices`, `GET /example/invoices/:id`, `POST /example/invoices` |
| REST — Zod-validated body | `POST /example/invoices` against `CreateInvoiceRequest` |
| REST — idempotent flag | `POST /example/invoices` (declared in `plugin.json` — host gate) |
| REST — rate-limited | `GET /example/rate-limited` (declared in `plugin.json` — host gate) |
| REST — streaming | `GET /example/stream/large-report` (chunked NDJSON) |
| REST — custom error codes | `PLUGIN.EXAMPLE.{SUBSCRIPTION_INACTIVE, INVOICE_NOT_FOUND, INVALID_INVOICE_STATE}` mapped in `errors.statusMap` |
| REST — response validation | `GET /_/test/fail-response?failResponse=1` (intentional schema violation) |
| Event publish | `example.invoice.created` on every `POST /example/invoices` happy path |
| Event subscribe | `users.user.deleted` via `makeUserDeletedHandler` |
| Secrets | `EXAMPLE_STRIPE_KEY`, `EXAMPLE_HMAC_SIGNING_KEY`, `EXAMPLE_WEBHOOK_TOKEN` |
| Tenant resolution | Seeded `tenant-int` reached via `X-Tenant-Id` |
| OTel instruments | `example.invoices_finalised_total`, `example.stripe.call_duration_ms`, `example.outstanding_invoice_count` |
## Capability map (declared in `plugin.json` for future Stages)
These surfaces are present in `plugin.json` because the design doc
§13.5 reference plugin SHALL declare every top-level manifest key.
The runtime implementations land alongside the host substrates as
those substrates ship in subsequent changes.
| Capability | Declared in `plugin.json` | Host substrate status |
| --------------------------- | ------------------------------------------------------------------ | ------------------------------------ |
| GraphQL types + subscription | `graphql.queries` / `mutations` / `subscriptions` | `@suaveplan/api-graphql-yoga` lands later |
| WebSocket fan-out (multi-host) | `websockets[]` with topic mapping | `@suaveplan/api-ws-bun` + broker shipped; cross-host federation later |
| SSE with `Last-Event-Id` | `sse[]` with `resume: "last-event-id"` | `@suaveplan/api-sse-bun` shipped; resume wiring later |
| Saga (3-step) + 2PC | `sagas[]` + `twoPhaseCommit` | `@suaveplan/api-saga` + `api-2pc` orchestrators land later |
| Migrations (v1 → v2) | `database.schemaVersion: 2` + `migrationsDir` | `@suaveplan/api-migrations` rollback driver lands later |
| Sandboxed (vm + wasm) | Future: rebuild against `api-sandbox-vm` / `api-sandbox-wasm` | Both shipped; isolation wiring through `createHost` lands later |
| Per-tenant cron + queued jobs | `jobs.cron[]` (incl. `perTenant`) + `jobs.queued[]` | `@suaveplan/job-scheduler` already lives universal; host bridge later |
## Running
```bash
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-plugin-example
```
The integration suite at
`tests/integration/full-flow.integration.test.ts` runs against
`createTestHost` with the seeded principal, tenants, and secrets — no
external services required.
## Files
```text
plugin.json # Manifest declaring every top-level key
src/index.ts # Package barrel
src/plugin/plugin.ts # Frozen definePlugin record
src/routes/routes.ts # REST surface (10 routes)
src/schemas/schemas.ts # Zod request / response schemas
src/events/events.ts # Topic constants + handler factories
src/error-codes/error-codes.ts # PLUGIN.EXAMPLE.* registry + status map
src/instruments/instruments.ts # OTel counter + histogram + gauge-as-counter
tests/integration/full-flow.integration.test.ts
# End-to-end against createTestHost
tests/setup/polyfills.ts # In-memory MeterProvider install
```
## Verification
The acceptance gate runs:
```bash
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-plugin-example
```
100% Istanbul coverage is enforced; the manifest `errors.statusMap`
is verified end-to-end against the runtime by the integration suite.

View File

@@ -0,0 +1,66 @@
# in-memory-store
## Purpose
Deterministic `Map`-backed invoice store consumed by every business
rule in `invoice-rules.ts`. The store performs zero IO and runs
identically on Node, Bun, and Javy/QuickJS — both the trusted-tier
route handlers and the wasm-tier `handleRequest` hold one of these as
their single source of truth for invoice persistence.
The store exposes the minimal vocabulary the rules need: monotonic id
allocation, create/get/list/setState/delete, plus `clear()` for
disposal and `setCounter()` for test pinning. Keeping the surface small
makes the shape-conformance test trivial — both tiers issue the same
sequence of store operations for the same input.
## Features
- Monotonic `inv-N` id allocator (`nextId()`).
- Strict create: throws when the id already exists.
- Get / list / setState / delete primitives.
- Bulk `clear()` for the wasm-tier `dispose()` hook.
- `setCounter(n)` for deterministic id pinning in tests.
## Usage: Basic
```ts
import { createInMemoryStore } from "@suaveplan/api-plugin-example/domain";
const store = createInMemoryStore();
const inv = store.create({ id: store.nextId(), status: "open", amountCents: 1500, currency: "USD" });
console.log(store.list()); // [inv]
```
## Usage: Advanced
```ts
import { createInMemoryStore } from "@suaveplan/api-plugin-example/domain";
const store = createInMemoryStore();
store.setCounter(99); // next id will be inv-100
const seeded = store.create({ id: store.nextId(), status: "open", amountCents: 1, currency: "USD" });
store.setState(seeded.id, "paid");
store.delete(seeded.id);
store.clear();
```
## API Reference
- `createInMemoryStore(): InMemoryStore` — factory.
- `nextId(): string` — `inv-1`, `inv-2`, ...
- `create(invoice): Invoice` — inserts, throws on duplicate id.
- `get(id): Invoice | undefined`.
- `list(): readonly Invoice[]` — insertion-ordered snapshot.
- `setState(id, state): Invoice` — throws when id absent.
- `delete(id): boolean` — `true` when removed.
- `clear(): void` — erases every entry and resets the counter.
- `setCounter(value): void` — pin counter; next `nextId()` returns `inv-${value+1}`.
## Implementation Notes
The store is a closure over a private `Map` and a private counter — no
prototype methods, no `this` binding, no shared global state. Tests
instantiate a fresh store per test for isolation; the wasm-tier holds
a module-singleton instance because the WASI-stdio surface has no
request-scoped context to thread one through.

View File

@@ -0,0 +1,85 @@
# invoice-rules
## Purpose
Pure business rules consumed by both the trusted-tier route handlers
and the wasm-tier `handleRequest`. Every state transition, validation
rule, and lookup goes through one of the functions in this module so
the two surfaces cannot drift in semantics — the shape-conformance
test asserts response equality across both tiers and this is the
shared core that guarantees it.
All rules are synchronous, take a domain value or a store reference,
and either return a new domain value or throw an `ExamplePluginError`
carrying a stable code. Adapter-tier work (Hono `Request` parsing,
OTel metric recording, response envelope construction) lives at the
boundary in each tier.
## Features
- `CREATE_INVOICE_INPUT_SHAPE` — declarative schema for the create-invoice body (no Zod).
- `parseCreateInvoiceInput(raw)` — runs `assertShape` and returns a `safeParse`-shaped discriminated union.
- `createInvoice(store, input)` — allocates an id, inserts as `"open"`.
- `getInvoiceOrThrow(store, id)` — throws `INVOICE_NOT_FOUND` when absent.
- `listInvoices(store)` — snapshot list.
- `finaliseInvoice(store, id)` — FSM transition `open → paid`, rejects re-finalisation.
- `raiseSubscriptionInactive()` — throws the canonical 409.
- `buildFailResponsePayload(fail)` — payload for the response-validation gate.
## Usage: Basic
```ts
import {
createInMemoryStore,
createInvoice,
finaliseInvoice,
} from "@suaveplan/api-plugin-example/domain";
const store = createInMemoryStore();
const opened = createInvoice(store, { customerId: "c-1", amountCents: 1500, currency: "USD" });
const paid = finaliseInvoice(store, opened.id);
console.log(paid.status); // "paid"
```
## Usage: Advanced
```ts
import {
createInMemoryStore,
finaliseInvoice,
parseCreateInvoiceInput,
} from "@suaveplan/api-plugin-example/domain";
import { EXAMPLE_CODES, ExamplePluginError } from "@suaveplan/api-plugin-example";
const store = createInMemoryStore();
const parsed = parseCreateInvoiceInput({ customerId: "c", amountCents: 1, currency: "USD" });
if (parsed.success) {
try {
finaliseInvoice(store, "missing");
} catch (e) {
if (e instanceof ExamplePluginError && e.code === EXAMPLE_CODES.INVOICE_NOT_FOUND) {
// host adapter maps to HTTP 404
}
}
}
```
## API Reference
- `CREATE_INVOICE_INPUT_SHAPE: Schema` — the descriptor consumed by `assertShape`.
- `parseCreateInvoiceInput(raw): { success: true; data } | { success: false; issues }`.
- `createInvoice(store, input): Invoice`.
- `getInvoiceOrThrow(store, id): Invoice` — throws `INVOICE_NOT_FOUND`.
- `listInvoices(store): readonly Invoice[]`.
- `finaliseInvoice(store, id): Invoice` — throws `INVOICE_NOT_FOUND` or `INVALID_INVOICE_STATE`.
- `raiseSubscriptionInactive(): never` — throws `SUBSCRIPTION_INACTIVE`.
- `buildFailResponsePayload(fail): { id; status; amountCents; currency }`.
## Implementation Notes
Errors are thrown via `ExamplePluginError`, which extends the shared
`SuaveplanError`. The status-mapping lives in `error-codes/error-codes.ts`
(`EXAMPLE_STATUS_MAP`); both tier adapters consult that map at the
serialisation boundary so the same code → status mapping applies on
trusted and wasm paths. The rule module itself has zero knowledge of
HTTP status.

View File

@@ -0,0 +1,64 @@
# invoice.types
## Purpose
Defines the pure domain types — `Invoice`, `InvoiceState`,
`CreateInvoiceInput`, `FinaliseInvoiceResult` — shared between the
trusted-tier and wasm-tier surfaces of `@suaveplan/api-plugin-example`.
Pure means no IO, no Hono types, no `Request`/`Response` references,
no OTel SDK imports — these types round-trip through JSON cleanly so
the wasm-tier can marshal them through `SerializedRequest` /
`SerializedResponse` bodies with no additional reshaping.
The types live in `src/domain/` so the dependency-graph lint
(`scripts/lint/check-wasm-subpath-deps.ts`) can confirm the wasm
subpath's import closure contains zero references to Hono, the OTel
SDK, or `node:*` specifiers. Both tiers import the same names from this
file and the two surfaces stay byte-compatible.
## Features
- `InvoiceState` — the lifecycle FSM symbols (`draft`/`open`/`paid`).
- `Invoice` — the canonical persisted record (id, status, amount, currency).
- `CreateInvoiceInput` — accepted shape on `POST /example/invoices`.
- `FinaliseInvoiceResult` — alias for `Invoice` returned by `finaliseInvoice`.
## Usage: Basic
```ts
import type { Invoice, InvoiceState } from "@suaveplan/api-plugin-example/domain";
const inv: Invoice = {
id: "inv-1",
status: "open",
amountCents: 1500,
currency: "USD",
};
const next: InvoiceState = "paid";
```
## Usage: Advanced
```ts
import type {
CreateInvoiceInput,
Invoice,
} from "@suaveplan/api-plugin-example/domain";
function asInvoice(input: CreateInvoiceInput, id: string): Invoice {
return { id, status: "open", amountCents: input.amountCents, currency: input.currency };
}
```
## API Reference
- `type InvoiceState = "draft" | "open" | "paid"`.
- `interface Invoice` — `id`, `status`, `amountCents`, `currency`.
- `interface CreateInvoiceInput` — `customerId`, `amountCents`, `currency`.
- `type FinaliseInvoiceResult = Invoice`.
## Implementation Notes
All four type symbols are pure type aliases — they do not survive
emission. The runtime store enforces the FSM via the rule functions in
`invoice-rules.ts`; this file is purely the type contract.

View File

@@ -0,0 +1,94 @@
# assert-shape
## Purpose
Hand-rolled validator that walks an unknown value against a `Schema`
descriptor and returns a `safeParse`-shaped discriminated union. This
exists so the wasm-tier surface of `@suaveplan/api-plugin-example` can
validate inbound request bodies WITHOUT importing Zod — Zod's runtime
writes `globalThis.__zod_globalConfig` at module-evaluation time, which
the `@suaveplan/api-sandbox-vm` host's frozen-globals Proxy guard
rejects with `SAND_VM.EVAL_REJECTED`.
The checker is exhaustive over the descriptor vocabulary declared in
`./schema.types.ts` — adding a new descriptor kind to the union without
also adding a case here is a TypeScript compile error, which keeps the
parity between the descriptor surface and the runtime checker mechanical.
## Features
- `assertShape<T>(value, schema)` → `{ success: true; data } | { success: false; issues }`.
- Issue records carry `path` (root-to-leaf), `code` (stable kebab-case),
and `message` (human-readable) — Zod-compatible shape.
- Composes objects, arrays, optional fields, enum strings, length/range
constraints, and positive-integer numbers.
- Zero closures over outer bindings, zero `globalThis` writes — bundle-safe
for both the wasm tier and the vm tier.
## Usage: Basic
```ts
import { assertShape } from "@suaveplan/api-plugin-example/domain/validators/assert-shape";
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
const Shape: Schema = {
kind: "object",
fields: { name: { kind: "string", min: 1 } },
};
const result = assertShape<{ name: string }>({ name: "ok" }, Shape);
if (result.success) console.log(result.data.name);
```
## Usage: Advanced
```ts
import { assertShape } from "@suaveplan/api-plugin-example/domain/validators/assert-shape";
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
interface Bulk {
readonly rows: ReadonlyArray<{ type: "create" | "delete" }>;
}
const Shape: Schema = {
kind: "object",
fields: {
rows: {
kind: "array",
items: {
kind: "object",
fields: {
type: { kind: "oneOf", values: ["create", "delete"] },
},
},
},
},
};
const result = assertShape<Bulk>({ rows: [{ type: "wat" }] }, Shape);
if (!result.success) {
for (const issue of result.issues) {
console.error(issue.path.join("."), issue.code, issue.message);
}
}
```
## API Reference
- `assertShape<T>(value: unknown, schema: Schema): AssertShapeResult<T>`
- `AssertShapeResult<T> = { success: true; data: T } | { success: false; issues: ReadonlyArray<ValidationIssue> }`
- Issue codes emitted: `invalid_type`, `too_small`, `invalid_length`,
`invalid_enum_value`, `not_integer`, `required`.
## Implementation Notes
The walker collects every failing leaf rather than short-circuiting on
the first error — this matches Zod's default `safeParse` behaviour and
keeps the response payload predictable for the §6 step-4 input
validation gate which renders the full issue list back to the caller.
The checker does not call `JSON.parse` — callers are expected to feed it
already-parsed JS values (`parseRequestBody` in `wasm/handle-request.ts`
performs the parse one layer up). This keeps the checker pure data
manipulation, which in turn keeps it bundle-stable across the wasm and
vm tiers.

View File

@@ -0,0 +1,90 @@
# schema.types
## Purpose
Declarative descriptor vocabulary the wasm-tier handler uses to validate
inbound request bodies WITHOUT pulling Zod into its dependency graph. The
`@suaveplan/api-sandbox-vm` host rejects any module that mutates
`globalThis` at evaluation time, and Zod's runtime installs
`globalThis.__zod_globalConfig` on import — which means the same
`api-plugin-example/src/wasm/index.ts` source could not be deployed in
both the wasm tier and the vm tier as long as Zod was reachable from the
import closure. This module is the typed surface; the matching runtime
checker lives in `./assert-shape.ts`.
The descriptors compose: an `object` whose field is an `array` whose
item is an `object` with a `string` field is a valid `Schema`. The
checker walks the descriptor tree breadth-first and emits one
`ValidationIssue` per failing leaf, so error reporting is shape-equivalent
to Zod's `safeParse(...).error.issues` for the small subset of validators
the example plugin needs (`string`, `string.min`, `string.length`,
`number.int.positive`, `oneOf`, `array`, `object`, `optional`).
## Features
- `PrimitiveType` — atomic `"string" | "number" | "integer" | "boolean"`.
- `MinLengthString` / `ExactLengthString` / `OneOfString` — string refinements.
- `PositiveInteger` — `number.int.positive` shorthand.
- `ArrayOf` / `ObjectOf` — composite descriptors.
- `OptionalField` — wrapper marking a field as optional.
- `Schema` — discriminated union of every descriptor atom.
- `ValidationIssue` — emitted failure record (path + code + message).
## Usage: Basic
```ts
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
const CreateInvoiceShape: Schema = {
kind: "object",
fields: {
customerId: { kind: "string", min: 1 },
amountCents: { kind: "positiveInteger" },
currency: { kind: "length", length: 3 },
},
};
```
## Usage: Advanced
```ts
import type { Schema, ValidationIssue } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
const BulkShape: Schema = {
kind: "object",
fields: {
rows: {
kind: "array",
items: {
kind: "object",
fields: {
type: { kind: "oneOf", values: ["create", "delete"] },
note: { kind: "optional", inner: "string" },
},
},
},
},
};
function render(issues: ReadonlyArray<ValidationIssue>): string {
return issues
.map((i) => `${i.path.join(".")}: ${i.code} (${i.message})`)
.join("\n");
}
```
## API Reference
- `Schema` — the discriminated-union descriptor.
- `ValidationIssue { path; code; message }` — emitted failure record.
- Atom kinds: `string` (with `min`), `oneOf`, `length`, `positiveInteger`,
`array`, `object`, `optional`, plus bare primitive strings.
## Implementation Notes
The vocabulary is intentionally narrow — only the constructs reachable
from `parseCreateInvoiceInput` (and any future input the example plugin
adds). When a new validator atom is needed (e.g. `email` format), add
the descriptor here AND the matching case in `assert-shape.ts`; the
plugin's tests will fail closed for any missing case via the
`exhaustiveness` assertion in the checker.

View File

@@ -0,0 +1,75 @@
# `error-codes` — `PLUGIN.EXAMPLE.*` registry + status map
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The reference plugin owns a small set of stable error codes that the
integration suite asserts are surfaced through the host's error
boundary with the manifest-declared HTTP statuses. The registry lives
in a single file so the runtime, the manifest, and the integration
tests share one source of truth.
The plugin throws an {@link ExamplePluginError} carrying a
{@link ExamplePluginCode}. The host error boundary consults
{@link EXAMPLE_STATUS_MAP} to compute the response status. The map is
mirrored verbatim in `plugin.json` under `errors.statusMap` so a code
appearing on one side and missing on the other fails the
`validate-error-status-map-shape` manifest validator.
## Features
- Discriminated `ExamplePluginCode` union — every code one literal.
- Frozen `EXAMPLE_CODES` constant for index-by-name access.
- `EXAMPLE_STATUS_MAP` aligned with `plugin.json`.
- Plain `Error` subclass — no heavyweight `SuaveplanError` import.
## Usage: Basic
```ts
import {
EXAMPLE_CODES,
ExamplePluginError,
} from "@suaveplan/api-plugin-example/error-codes/error-codes";
if (invoice === null) {
throw new ExamplePluginError(
EXAMPLE_CODES.INVOICE_NOT_FOUND,
`invoice ${id} not found`
);
}
```
## Usage: Advanced
The integration test reads the registry directly to assert every code
is raised by at least one production path:
```ts
import {
EXAMPLE_CODES,
EXAMPLE_STATUS_MAP,
} from "@suaveplan/api-plugin-example/error-codes/error-codes";
for (const code of Object.values(EXAMPLE_CODES)) {
expect(EXAMPLE_STATUS_MAP[code]).toBeDefined();
}
```
## API Reference
- `EXAMPLE_CODES` — Frozen `Record<string, ExamplePluginCode>`.
- `EXAMPLE_STATUS_MAP` — Frozen `Record<ExamplePluginCode, number>`.
- `ExamplePluginError(code, message)` — Plain `Error` subclass with
`code` field.
## Implementation Notes
`ExamplePluginCode` is a string-literal union, not a `enum`, so the
runtime cost is zero and the values appear unchanged in stack traces.
The registry is `Object.freeze`d to prevent silent drift between the
manifest and runtime. The Suaveplan convention is to reuse `Error`
rather than `SuaveplanError` for leaf plugins — the host's boundary
only reads `code`, so any subclass works.

View File

@@ -0,0 +1,70 @@
# `instruments` — OpenTelemetry instruments for `api-plugin-example`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The reference plugin declares three OTel instruments that mirror the
`telemetry.metrics[]` block in `plugin.json`. The instruments are
lazily materialised against the global `MeterProvider` so the test
setup can install an `InMemoryMetricExporter` before the first call.
The integration suite reads the in-memory exporter to assert each
manifest-declared instrument emits values during the run.
## Features
- `example.invoices_finalised_total` — counter, no unit.
- `example.stripe.call_duration_ms` — histogram, `unit: "ms"`.
- `example.outstanding_invoice_count` — counter modelling a gauge.
- Lazy single-shot resolution — second call returns the cached object.
- `resetExampleInstrumentsForTest` — clears the cache between suites.
## Usage: Basic
```ts
import { getExampleInstruments } from "@suaveplan/api-plugin-example/instruments/instruments";
const m = getExampleInstruments();
m.invoicesFinalisedTotal.add(1, { tenant_id: ctx.tenantId ?? "none" });
```
## Usage: Advanced
The histogram is recorded around outbound Stripe calls. Wrap the
network call to capture duration even on error:
```ts
const start = performance.now();
try {
await stripe.charges.create({ amount, currency });
} finally {
m.stripeCallDurationMs.record(performance.now() - start);
}
```
## API Reference
- `getExampleInstruments(): ExampleInstruments` — Lazy accessor.
- `resetExampleInstrumentsForTest(): void` — Cache reset.
- `ExampleInstruments` — `{ invoicesFinalisedTotal, stripeCallDurationMs, outstandingInvoiceCount }`.
## Implementation Notes
The instruments are materialised against `metrics.getMeter(METER_NAME)`
rather than a passed-in provider so plugin code does not need to
plumb a meter through every handler. The provider is installed once
in `tests/setup/polyfills.ts` and the cached registry binds to it on
first call. The cache is intentionally process-wide rather than
per-`MeterProvider` because plugin authors typically install a global
provider at boot and never swap it; tests that DO swap providers call
`resetExampleInstrumentsForTest` between cases so the second call
re-materialises against the new meter. The OTel SDK's
`InMemoryMetricExporter` is the only test substrate that needs the
cache reset — production paths bind to the global provider for the
lifetime of the host process and any datapoint emitted goes through
the same instrument instance. The histogram declares `unit: "ms"` so
downstream Prometheus / OTLP collectors expose the unit in their
metric metadata. The counter and the gauge-modelling counter both
omit the `unit` field because they are dimensionless.

View File

@@ -0,0 +1,66 @@
# `schemas` — Zod request/response schemas owned by `api-plugin-example`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every route in the reference plugin is wired against a Zod schema so
the §6 input-validation step always has a contract to consult. The
schemas live in a single module so the plugin manifest can resolve
them from a stable specifier and so the integration tests can
hand-craft both valid and malformed payloads against the same source.
The schemas are also the canonical reference for the response-
validation route — `/_/test/fail-response?failResponse=1` returns a
payload that intentionally violates `CreateInvoiceResponse` so the
host's `responseValidation` toggle exercises both `enforce` (500) and
`warn` (200 + counter increment) modes.
## Features
- `CreateInvoiceRequest` / `CreateInvoiceResponse` — paired schemas.
- `InvoiceResponse` — alias for the GET-one response.
- `PingResponse` — public ping shape.
- `StreamChunk` — chunked-transfer envelope.
- Re-exported `z.infer<...>` type aliases for handler-side use.
## Usage: Basic
```ts
import { CreateInvoiceRequest } from "@suaveplan/api-plugin-example/schemas/schemas";
const parsed = CreateInvoiceRequest.safeParse(ctx.body);
if (!parsed.success) {
return new Response(JSON.stringify(parsed.error), { status: 400 });
}
```
## Usage: Advanced
The response-validation route emits a payload that violates
`CreateInvoiceResponse` — the schema is the canonical reference the
test asserts against:
```ts
import { CreateInvoiceResponse } from "@suaveplan/api-plugin-example/schemas/schemas";
const body = { id: "x", status: "invalid", amountCents: -1, currency: "ZZ" };
expect(CreateInvoiceResponse.safeParse(body).success).toBe(false);
```
## API Reference
- `CreateInvoiceRequest: ZodObject` — POST body schema.
- `CreateInvoiceResponse: ZodObject` — 200 response schema.
- `InvoiceResponse` — alias for `CreateInvoiceResponse`.
- `PingResponse` — `{ ok: true, pluginName: string }`.
- `StreamChunk` — `{ seq: number, payload: string }`.
## Implementation Notes
The default-import form (`import z from "zod"`) is the only Bun-safe
form per `feedback_zod_import.md`. Inferred types are re-exported via
`type` so consumers can opt out of paying the runtime cost when they
only need typing.

View File

@@ -0,0 +1,69 @@
# `events` — topics + payload schemas published and subscribed
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The reference plugin both publishes and subscribes to events. This
module exposes the topic names and the Zod payload schemas so the
manifest, the runtime, and the integration test all agree on the
shape. The integration test publishes a `users.user.deleted` payload
through the in-memory bus and asserts the subscriber handler ran
exactly once with the parsed payload.
The publication side mirrors that — the plugin's `POST /example/invoices`
handler emits `example.invoice.created` after a successful insert and
the test asserts the payload landed on the bus via
`host.waitForEvent`.
## Features
- `EXAMPLE_INVOICE_CREATED_TOPIC` / `USERS_USER_DELETED_TOPIC` literals.
- `ExampleInvoiceCreatedPayload` / `UsersUserDeletedPayload` Zod schemas.
- `makeUserDeletedHandler({ seen })` — factory returning a parsed-payload
handler that records into a caller-owned sink.
## Usage: Basic
```ts
import {
EXAMPLE_INVOICE_CREATED_TOPIC,
ExampleInvoiceCreatedPayload,
} from "@suaveplan/api-plugin-example/events/events";
host.adapters.eventBus.publish(EXAMPLE_INVOICE_CREATED_TOPIC, {
id: "i-1",
customerId: "c-1",
amountCents: 100,
currency: "USD",
});
```
## Usage: Advanced
The subscriber-side factory accepts a sink so tests can observe
delivery without mutating module state:
```ts
import { makeUserDeletedHandler } from "@suaveplan/api-plugin-example/events/events";
const sink = { seen: new Set<string>() };
host.adapters.eventBus.on("users.user.deleted", makeUserDeletedHandler(sink));
```
## API Reference
- `EXAMPLE_INVOICE_CREATED_TOPIC: "example.invoice.created"`
- `USERS_USER_DELETED_TOPIC: "users.user.deleted"`
- `ExampleInvoiceCreatedPayload: ZodObject` — published payload.
- `UsersUserDeletedPayload: ZodObject` — subscribed payload.
- `makeUserDeletedHandler({ seen: Set<string> }): (raw: unknown) => void`.
## Implementation Notes
`makeUserDeletedHandler` swallows parse failures rather than throwing
because the in-memory event bus delivers FIFO with `Promise.resolve`
chaining — a throwing handler would terminate the chain for every
downstream subscriber. The integration test asserts a valid payload
populates the sink; a malformed payload simply doesn't.

View File

@@ -0,0 +1,63 @@
# `plugin` — frozen `definePlugin` record
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The reference plugin's `definePlugin` invocation lives in its own
module so the manifest, the integration test, and any host-side
loader read the same frozen `PluginModule` value. Every lifecycle
hook is wired but performs only the work the integration suite
asserts against — `install` / `start` / `stop` / `uninstall` /
`health` are present so the four-phase FSM exercises each one even
though the bodies are no-ops.
The `requires` array is empty because the routes consume capabilities
through the per-request context shape (`ctx.principal`, `ctx.tenant`,
`ctx.secrets`, `ctx.db`) rather than the typed capability registry.
As the registry matures, the literal list will tighten to the exact
set the routes need.
## Features
- Frozen `PluginModule<readonly []>` value.
- Five lifecycle hooks wired so the four-phase FSM walks each.
- `isolation: "trusted"` / `trustLevel: "first-party"`.
## Usage: Basic
```ts
import { examplePluginModule } from "@suaveplan/api-plugin-example/plugin/plugin";
await host.installFixturePlugin({
spec: examplePluginModule,
routes: buildRoutes({ ledger, publisher }),
});
```
## Usage: Advanced
The module is `Object.freeze`d by `definePlugin`, so the host can
hash a stable representation for the rollback journal:
```ts
const hash = crypto
.createHash("sha256")
.update(JSON.stringify(examplePluginModule))
.digest("hex");
```
## API Reference
- `examplePluginModule: PluginModule<readonly []>` — Frozen module.
## Implementation Notes
The runtime body of every hook is a no-op because the host's
lifecycle FSM is the focus — the example exists to exercise each
transition, not to encode business logic in the hooks. The
integration test installs the module through
`host.installFixturePlugin` so the routes table is registered
alongside the module rather than threaded through `start`.

View File

@@ -0,0 +1,75 @@
# `routes` — REST surface for `api-plugin-example`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The route table is the heart of the reference plugin. Every route
demonstrates one piece of the §6 11-step request pipeline: public
(no auth), RBAC-gated, Zod-validated body, streaming response,
custom error code → manifest-mapped HTTP status, and the intentional
schema-violation route that exercises the response-validation
toggle. The factory takes an injected ledger and event publisher so
the integration test materialises a fresh isolation per suite.
The routes throw `ExamplePluginError` for plugin-domain failures
(`PLUGIN.EXAMPLE.*`) and a raw `Response` for host-domain concerns
like missing scope, which keeps the plugin's error namespace narrow.
## Features
- 10 routes covering every accessible §6 pipeline outcome.
- Injected ledger + publisher — zero module state, suites are
independent.
- Manifest-aligned `statusMap` on every error-emitting registration.
- `createInMemoryLedger()` factory the tests reuse.
## Usage: Basic
```ts
import {
buildRoutes,
createInMemoryLedger,
} from "@suaveplan/api-plugin-example/routes/routes";
const ledger = createInMemoryLedger();
const routes = buildRoutes({
ledger,
publisher: { publish: (t, p) => host.adapters.eventBus.publish(t, p) },
});
await host.installFixturePlugin({ spec, routes });
```
## Usage: Advanced
The `requireScope` helper throws a raw `Response` so the host's
error boundary surfaces a 403 without the example's own error
namespace getting involved. The pattern keeps plugin-domain errors
(`PLUGIN.EXAMPLE.*`) separate from host-domain RBAC. To assert the
RBAC envelope in tests:
```ts
const res = await host.fetchAs({ id: "p", scopes: [] }, ping);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("RBAC_MISSING_SCOPE");
```
## API Reference
- `buildRoutes({ ledger, publisher }): ExampleRoute[]` — Factory.
- `createInMemoryLedger(): InvoiceLedger` — Test ledger.
- `InvoiceLedger` — `{ invoices: Map, nextId(): string }`.
- `RoutePublisher` — `{ publish(topic, payload): void }`.
- `ExampleRoute` — `{ method, path, handler, statusMap? }`.
## Implementation Notes
The streaming route enqueues NDJSON chunks through a `ReadableStream`
so the host pipeline observes `transfer-encoding: chunked`. The body
schema validation uses `safeParse` and returns a 400 directly rather
than throwing — the §6 step-4 validator is the host's concern; this
plugin is showing how a plugin author defends against malformed
input ahead of the validator landing.

View File

@@ -0,0 +1,49 @@
# wasm/dispose
## Purpose
Lifecycle hook invoked when the wasm-tier host bridge tears down the
plugin. Clears the module-singleton `InMemoryStore` so the next
`start()` begins from a deterministic empty state. The hook is
idempotent — repeated calls do not throw and leave the store empty.
`dispose()` is paired with `start()` to provide a deterministic
warm-restart cycle for the host bridge. Calling
`dispose() → start()` is the canonical "reset the plugin" sequence
the bridge invokes on a config change or hot-reload.
## Features
- Idempotent: repeated calls leave the store empty.
- Pairs with `start()` for warm-restart cycles.
- Clears every persisted invoice and resets the id counter.
## Usage: Basic
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
plugin.dispose();
```
## Usage: Advanced
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
// Warm-restart cycle the bridge uses on hot-reload.
plugin.dispose();
plugin.start();
```
## API Reference
- `dispose(): void` — clears the store; idempotent.
## Implementation Notes
`dispose()` calls `getStore().clear()`, which empties the underlying
`Map` and resets the id counter. The store reference itself is
preserved (the singleton swap is reserved for `resetState()` in
tests). The bridge consumes `dispose()` synchronously — no async
work is performed.

View File

@@ -0,0 +1,87 @@
# wasm/handle-request
## Purpose
Entry point for the wasm-tier surface. Receives a `SerializedRequest`
(the WASI-stdio envelope payload), routes by method + URL pathname,
and dispatches to a per-route handler that delegates every business
decision to `src/domain/invoice-rules.ts`. The result is a
`SerializedResponse` whose body is base64-url-no-pad encoded.
The function is deliberately small — routing is a flat `if`-chain
rather than a generic radix-tree matcher because the route set is
stable and tiny. The shape-conformance test compares byte-for-byte
against the trusted-tier handler for every overlapping route; the
flat dispatch keeps the parity contract auditable at a glance.
## Features
- Synchronous-style routing — no router dependency.
- Domain delegation — every business decision goes through `domain/invoice-rules.ts`.
- Base64-url-no-pad body encoding (JSON-clean wire shape).
- `SuaveplanError` throws propagate to the ABI shim (NOT caught here).
- RBAC via `SerializedRequest.capabilities`.
## Usage: Basic
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
const res = await plugin.handleRequest({
url: "http://t/example/health",
method: "GET",
headers: [],
bodyBase64: "",
principalId: null,
tenantId: null,
capabilities: [],
requestId: "r-1",
timestamp: Date.now(),
});
console.log(res.status); // 200
```
## Usage: Advanced
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
function encode(body: object): string {
const bytes = new TextEncoder().encode(JSON.stringify(body));
let binary = "";
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
const res = await plugin.handleRequest({
url: "http://t/example/invoices",
method: "POST",
headers: [["content-type", "application/json"]],
bodyBase64: encode({ customerId: "c", amountCents: 1, currency: "USD" }),
principalId: null,
tenantId: null,
capabilities: ["example:write"],
requestId: "r-2",
timestamp: Date.now(),
});
```
## API Reference
- `handleRequest(req: SerializedRequest): Promise<SerializedResponse>` — entry point.
- Dispatches to: `/example/public/ping`, `/example/health`, `/example/invoices`
(GET list, GET by id, POST create), `/example/invoices/:id/finalise`,
`/example/rate-limited`, `/example/subscription-required`,
`/_/test/fail-response`. Unknown routes return 404 with `code: ROUTE_NOT_FOUND`.
## Implementation Notes
The `await Promise.resolve()` inside `finaliseInvoice` mirrors the
trusted tier's microtask yield. The trusted tier uses it to keep its
`example.stripe.call_duration_ms` histogram observation above zero;
the wasm tier has no histogram to record but keeps the yield so
control-flow timing remains identical for the parity test.
Base64-url-no-pad encoding uses `btoa` / `atob` — the wasm-tier
runtime (Javy/QuickJS) ships both. The encoder replaces `+` with
`-`, `/` with `_`, and strips trailing `=` padding.

View File

@@ -0,0 +1,51 @@
# wasm/routes
## Purpose
Declares the static `SerializedRoute[]` the wasm-tier surface exposes
to the host bridge. The list is the contract the
`api-sandbox-wasm-host-bridge` reads at boot time to register a
matching set of `PipelineRoute` entries on the host's router.
The route list deliberately omits the trusted-tier
`/example/stream/large-report` endpoint. Chunked `ReadableStream`
responses cannot be expressed under the synchronous WASI-stdio
`(SerializedRequest) => SerializedResponse` contract — Javy's
QuickJS lacks a usable `ReadableStream` and the WASI-stdio envelope
has no streaming response shape. Use pagination at the trusted tier
when the wasm tier needs equivalent throughput.
## Features
- `routes: readonly SerializedRoute[]` — frozen at module init.
- Identical order to the trusted-tier route table.
- Streaming endpoints omitted by design.
## Usage: Basic
```ts
import { routes } from "@suaveplan/api-plugin-example/wasm";
console.log(routes.length); // 9
```
## Usage: Advanced
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
for (const r of plugin.routes) {
console.log(`${r.method} ${r.path}`);
}
```
## API Reference
- `routes: readonly SerializedRoute[]` — every wasm-tier endpoint.
## Implementation Notes
`SerializedRoute` is imported as a type-only specifier from
`@suaveplan/api-types/sandbox-bridge`. The runtime shape is a plain
`[{ method, path }, ...]` array — no runtime dependency on
`api-types`.

View File

@@ -0,0 +1,52 @@
# wasm/start
## Purpose
Lifecycle hook invoked when the wasm-tier host bridge instantiates the
plugin. Primes the module-singleton `InMemoryStore` with the same
seed data the trusted-tier `install` hook uses. The reference
plugin's seed is empty (so `start()` is effectively a no-op), but
the hook is kept for symmetry with the trusted tier and so future
forks have a documented place to add their own seed records.
The function is idempotent — repeated calls do not throw and do not
duplicate any seed records. The shape-conformance test asserts that
`dispose()` followed by `start()` re-primes the store such that
`GET /example/invoices` returns the seeded list.
## Features
- Idempotent: repeated calls are safe.
- Touches the store singleton (no-op materialise).
- Documented insertion point for plugin forks that need real seed data.
## Usage: Basic
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
plugin.start();
```
## Usage: Advanced
```ts
import { plugin } from "@suaveplan/api-plugin-example/wasm";
// Lifecycle round-trip from the host bridge.
plugin.start();
const res = await plugin.handleRequest({ /* ... */ } as never);
plugin.dispose();
plugin.start();
```
## API Reference
- `start(): void` — idempotent no-op (in the reference plugin).
## Implementation Notes
The reference plugin's `start()` deliberately performs no work; the
empty body is correct because the trusted-tier `install` hook is
also empty. Forks SHOULD insert their seed records here by calling
`getStore().create(...)`.

View File

@@ -0,0 +1,52 @@
# wasm-state
## Purpose
Holds the module-singleton `InMemoryStore` consumed by the wasm-tier
`handleRequest`. The WASI-stdio contract surfaces a synchronous
`(SerializedRequest) => SerializedResponse` shape with no
per-request context the host could thread a store reference through —
so the wasm tier keeps exactly one store at module scope.
`resetState()` is the test-isolation primitive. The shape-conformance
test calls it before each parity assertion so every iteration starts
with `inv-1` as the next allocated id and an empty invoice list.
Production deploys never call `resetState()` directly — `dispose()`
clears the singleton's contents in place rather than swapping it out.
## Features
- `getStore()` — read the current module-scoped store.
- `resetState()` — swap in a fresh empty store (test isolation).
## Usage: Basic
```ts
import { getStore } from "@suaveplan/api-plugin-example/wasm";
const store = getStore();
console.log(store.list());
```
## Usage: Advanced
```ts
import { resetState } from "@suaveplan/api-plugin-example/wasm";
// Inside a test
beforeEach(() => {
resetState();
});
```
## API Reference
- `getStore(): InMemoryStore`.
- `resetState(): void`.
## Implementation Notes
The module-singleton pattern is unusual in this project (most packages
prefer dependency injection), but the WASI-stdio ABI has no place to
thread state. The escape hatch is `resetState()` for tests and
`plugin.dispose()` / `plugin.start()` for production lifecycle reset.