Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,111 @@
{
"name": "@suaveplan/api-plugin-example",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Reference plugin for the @suaveplan/api-host plugin foundation. 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"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"
}
},
"./wasm": {
"import": {
"types": "./dist/wasm/index.d.ts",
"default": "./dist/wasm/index.js"
}
},
"./domain": {
"import": {
"types": "./dist/domain/index.d.ts",
"default": "./dist/domain/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/trusted/plugin/plugin.d.ts",
"default": "./dist/trusted/plugin/plugin.js"
}
},
"./routes/routes": {
"import": {
"types": "./dist/trusted/routes/routes.d.ts",
"default": "./dist/trusted/routes/routes.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./instruments/instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./events/events": {
"import": {
"types": "./dist/trusted/events/events.d.ts",
"default": "./dist/trusted/events/events.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"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-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/api-events-in-memory": "workspace:^",
"@suaveplan/api-router-in-memory": "workspace:^",
"@suaveplan/api-db-sqlite-memory": "workspace:^",
"@suaveplan/testing": "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", "plugin.json"]
}

View File

@@ -0,0 +1,110 @@
{
"name": "@suaveplan/api-plugin-example",
"version": "0.1.0",
"apiHost": "^0.1.0",
"isolation": "trusted",
"trustLevel": "first-party",
"requiredCapabilities": [],
"optionalCapabilities": [
"db",
"events",
"secrets",
"auth.session",
"tenant"
],
"permissions": {
"fs": [],
"fetch": ["https://api.stripe.com"],
"env": [
"EXAMPLE_STRIPE_KEY",
"EXAMPLE_HMAC_SIGNING_KEY",
"EXAMPLE_WEBHOOK_TOKEN"
]
},
"dependsOn": [],
"routes": [
{ "method": "GET", "path": "/example/public/ping", "auth": "none" },
{ "method": "GET", "path": "/example/health", "auth": "none" },
{ "method": "GET", "path": "/example/invoices", "scopes": ["example:read"] },
{ "method": "GET", "path": "/example/invoices/:id", "scopes": ["example:read"] },
{ "method": "POST", "path": "/example/invoices", "scopes": ["example:write"], "idempotent": true },
{ "method": "POST", "path": "/example/invoices/:id/finalise", "scopes": ["example:write"] },
{ "method": "GET", "path": "/example/stream/large-report", "streaming": true },
{ "method": "GET", "path": "/example/rate-limited", "rateLimit": { "rps": 10, "burst": 20 } },
{ "method": "GET", "path": "/example/subscription-required" },
{ "method": "GET", "path": "/_/test/fail-response", "responseValidation": "enforce" }
],
"graphql": {
"types": ["ExampleInvoice"],
"queries": ["exampleInvoice"],
"mutations": ["createExampleInvoice"],
"subscriptions": ["exampleInvoiceCreated"]
},
"websockets": [
{ "path": "/example/ws", "topics": ["example.invoice.created"] }
],
"sse": [
{ "path": "/example/sse", "topics": ["example.invoice.created"], "resume": "last-event-id" }
],
"sagas": [
{
"name": "example-invoice-billing",
"steps": ["reserve", "charge", "settle"],
"owns": true
}
],
"twoPhaseCommit": {
"participant": true,
"voteTimeoutMs": 5000
},
"database": {
"schemaVersion": 2,
"migrationsDir": "migrations/postgres",
"dialect": "postgres"
},
"jobs": {
"cron": [
{ "name": "reconcile-invoices", "schedule": "0 * * * *" },
{ "name": "send-monthly-statements", "schedule": "0 0 1 * *", "perTenant": true }
],
"queued": [
{ "name": "send-receipt", "maxAttempts": 5, "backoff": "exponential" }
]
},
"events": {
"publishes": ["example.invoice.created"],
"subscribes": ["users.user.deleted"]
},
"secrets": [
{ "name": "EXAMPLE_STRIPE_KEY", "required": true },
{ "name": "EXAMPLE_HMAC_SIGNING_KEY", "required": true },
{ "name": "EXAMPLE_WEBHOOK_TOKEN", "required": false }
],
"config": {
"schemaRef": "./src/schemas/schemas.ts"
},
"scopes": ["example:read", "example:write"],
"telemetry": {
"metrics": [
{ "name": "example.invoices_finalised_total", "kind": "counter" },
{ "name": "example.stripe.call_duration_ms", "kind": "histogram", "unit": "ms" },
{ "name": "example.outstanding_invoice_count", "kind": "counter" }
]
},
"openapi": {
"version": "3.1.0",
"title": "@suaveplan/api-plugin-example",
"ui": "scalar"
},
"errors": {
"statusMap": {
"PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE": 409,
"PLUGIN.EXAMPLE.INVOICE_NOT_FOUND": 404,
"PLUGIN.EXAMPLE.INVALID_INVOICE_STATE": 422,
"PLUGIN.EXAMPLE.RESPONSE_VALIDATION_TRIGGER": 200
}
},
"uninstall": {
"dataPolicy": "drop"
}
}

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,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryStore } from "./in-memory-store.js";
import type { Invoice } from "./invoice.types.js";
function fresh(): Invoice {
return { id: "x", status: "open", amountCents: 1, currency: "USD" };
}
describe("in-memory-store", () => {
it("allocates monotonic ids", { timeout: 5000 }, () => {
const store = createInMemoryStore();
expect(store.nextId()).toBe("inv-1");
expect(store.nextId()).toBe("inv-2");
});
it("create + get round-trip", { timeout: 5000 }, () => {
const store = createInMemoryStore();
const created = store.create(fresh());
expect(store.get("x")).toEqual(created);
});
it("create rejects duplicate id", { timeout: 5000 }, () => {
const store = createInMemoryStore();
store.create(fresh());
expect(() => store.create(fresh())).toThrow(/already exists/);
});
it("get returns undefined for missing id", { timeout: 5000 }, () => {
const store = createInMemoryStore();
expect(store.get("missing")).toBeUndefined();
});
it("list preserves insertion order", { timeout: 5000 }, () => {
const store = createInMemoryStore();
store.create({ ...fresh(), id: "a" });
store.create({ ...fresh(), id: "b" });
expect(store.list().map((i) => i.id)).toEqual(["a", "b"]);
});
it("setState transitions invoice", { timeout: 5000 }, () => {
const store = createInMemoryStore();
store.create(fresh());
expect(store.setState("x", "paid").status).toBe("paid");
expect(store.get("x")?.status).toBe("paid");
});
it("setState throws on missing id", { timeout: 5000 }, () => {
const store = createInMemoryStore();
expect(() => store.setState("missing", "paid")).toThrow(/not found/);
});
it("delete returns true when present, false otherwise", {
timeout: 5000,
}, () => {
const store = createInMemoryStore();
store.create(fresh());
expect(store.delete("x")).toBe(true);
expect(store.delete("x")).toBe(false);
});
it("clear empties and resets counter", { timeout: 5000 }, () => {
const store = createInMemoryStore();
store.nextId();
store.create(fresh());
store.clear();
expect(store.list()).toEqual([]);
expect(store.nextId()).toBe("inv-1");
});
it("setCounter pins counter", { timeout: 5000 }, () => {
const store = createInMemoryStore();
store.setCounter(99);
expect(store.nextId()).toBe("inv-100");
});
});

View File

@@ -0,0 +1,76 @@
import type { Invoice, InvoiceState } from "./invoice.types.js";
/**
* Deterministic Map-backed invoice store. The store holds no IO and runs
* identically on Node, Bun, and Javy/QuickJS — both the trusted-tier
* route handlers and the wasm-tier `handleRequest` instance hold one of
* these as the single source of truth for invoice persistence.
*
* The store is intentionally tiny: it exposes only the five operations
* the example plugin's business rules need (`create`, `get`, `list`,
* `setState`, `delete`). `nextId()` is monotonic and produces stable
* `inv-N` identifiers so the shape-conformance test can compare bodies
* byte-for-byte across surfaces.
*/
export interface InMemoryStore {
/** Allocate the next monotonic id (`inv-1`, `inv-2`, ...). */
nextId(): string;
/** Insert a brand-new invoice. Throws if the id already exists. */
create(invoice: Invoice): Invoice;
/** Fetch by id, or `undefined` when absent. */
get(id: string): Invoice | undefined;
/** Snapshot list — order matches insertion. */
list(): readonly Invoice[];
/** Transition an existing invoice to a new state. */
setState(id: string, state: InvoiceState): Invoice;
/** Delete an invoice by id. Returns `true` when present. */
delete(id: string): boolean;
/** Erase every entry — used by `dispose()` and test isolation. */
clear(): void;
/** Pre-seed counter; tests pin this for deterministic ids. */
setCounter(counter: number): void;
}
/** Construct a fresh, empty store. */
export function createInMemoryStore(): InMemoryStore {
const invoices = new Map<string, Invoice>();
let counter = 0;
return {
nextId() {
counter += 1;
return `inv-${counter}`;
},
create(invoice) {
if (invoices.has(invoice.id)) {
throw new Error(`invoice ${invoice.id} already exists`);
}
invoices.set(invoice.id, invoice);
return invoice;
},
get(id) {
return invoices.get(id);
},
list() {
return Array.from(invoices.values());
},
setState(id, state) {
const existing = invoices.get(id);
if (existing === undefined) {
throw new Error(`invoice ${id} not found`);
}
const updated: Invoice = { ...existing, status: state };
invoices.set(id, updated);
return updated;
},
delete(id) {
return invoices.delete(id);
},
clear() {
invoices.clear();
counter = 0;
},
setCounter(value) {
counter = value;
},
};
}

View File

@@ -0,0 +1,18 @@
export type { InMemoryStore } from "./in-memory-store.js";
export { createInMemoryStore } from "./in-memory-store.js";
export type {
CreateInvoiceInput,
FinaliseInvoiceResult,
Invoice,
InvoiceState,
} from "./invoice.types.js";
export {
buildFailResponsePayload,
CreateInvoiceInputSchema,
createInvoice,
finaliseInvoice,
getInvoiceOrThrow,
listInvoices,
parseCreateInvoiceInput,
raiseSubscriptionInactive,
} from "./invoice-rules.js";

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
- `CreateInvoiceInputSchema` — Zod schema for the create-invoice body.
- `parseCreateInvoiceInput(raw)` — wraps `safeParse` in a 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
- `CreateInvoiceInputSchema: z.ZodObject<...>`.
- `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,147 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXAMPLE_CODES,
ExamplePluginError,
} from "../error-codes/error-codes.js";
import { createInMemoryStore } from "./in-memory-store.js";
import {
buildFailResponsePayload,
createInvoice,
finaliseInvoice,
getInvoiceOrThrow,
listInvoices,
parseCreateInvoiceInput,
raiseSubscriptionInactive,
} from "./invoice-rules.js";
describe("invoice-rules", () => {
it("parseCreateInvoiceInput accepts valid body", { timeout: 5000 }, () => {
const out = parseCreateInvoiceInput({
customerId: "c",
amountCents: 1,
currency: "USD",
});
expect(out.success).toBe(true);
if (out.success) expect(out.data.amountCents).toBe(1);
});
it("parseCreateInvoiceInput rejects bad body", { timeout: 5000 }, () => {
const out = parseCreateInvoiceInput({ customerId: "" });
expect(out.success).toBe(false);
if (!out.success) expect(out.issues.length).toBeGreaterThan(0);
});
it("createInvoice persists with status=open", { timeout: 5000 }, () => {
const store = createInMemoryStore();
const inv = createInvoice(store, {
customerId: "c",
amountCents: 100,
currency: "USD",
});
expect(inv.status).toBe("open");
expect(inv.id).toBe("inv-1");
expect(store.get("inv-1")).toEqual(inv);
});
it("getInvoiceOrThrow returns persisted record", { timeout: 5000 }, () => {
const store = createInMemoryStore();
const inv = createInvoice(store, {
customerId: "c",
amountCents: 1,
currency: "USD",
});
expect(getInvoiceOrThrow(store, inv.id)).toEqual(inv);
});
it("getInvoiceOrThrow throws INVOICE_NOT_FOUND", { timeout: 5000 }, () => {
const store = createInMemoryStore();
try {
getInvoiceOrThrow(store, "missing");
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ExamplePluginError);
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVOICE_NOT_FOUND
);
}
});
it("listInvoices returns store contents", { timeout: 5000 }, () => {
const store = createInMemoryStore();
createInvoice(store, {
customerId: "c",
amountCents: 1,
currency: "USD",
});
expect(listInvoices(store)).toHaveLength(1);
});
it("finaliseInvoice transitions open → paid", { timeout: 5000 }, () => {
const store = createInMemoryStore();
const inv = createInvoice(store, {
customerId: "c",
amountCents: 1,
currency: "USD",
});
expect(finaliseInvoice(store, inv.id).status).toBe("paid");
});
it("finaliseInvoice throws INVOICE_NOT_FOUND", { timeout: 5000 }, () => {
const store = createInMemoryStore();
try {
finaliseInvoice(store, "missing");
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVOICE_NOT_FOUND
);
}
});
it("finaliseInvoice rejects already-paid", { timeout: 5000 }, () => {
const store = createInMemoryStore();
const inv = createInvoice(store, {
customerId: "c",
amountCents: 1,
currency: "USD",
});
finaliseInvoice(store, inv.id);
try {
finaliseInvoice(store, inv.id);
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVALID_INVOICE_STATE
);
}
});
it("raiseSubscriptionInactive throws stable code", {
timeout: 5000,
}, () => {
try {
raiseSubscriptionInactive();
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.SUBSCRIPTION_INACTIVE
);
}
});
it("buildFailResponsePayload returns valid body when fail=false", {
timeout: 5000,
}, () => {
const out = buildFailResponsePayload(false);
expect(out.status).toBe("open");
expect(out.currency).toBe("USD");
});
it("buildFailResponsePayload returns violating body when fail=true", {
timeout: 5000,
}, () => {
const out = buildFailResponsePayload(true);
expect(out.status).toBe("exploded");
expect(out.amountCents).toBe(-1);
});
});

View File

@@ -0,0 +1,114 @@
import z from "zod";
import {
EXAMPLE_CODES,
ExamplePluginError,
} from "../error-codes/error-codes.js";
import type { InMemoryStore } from "./in-memory-store.js";
import type { CreateInvoiceInput, Invoice } from "./invoice.types.js";
/**
* Pure business rules consumed by both the trusted-tier (`src/trusted/`)
* route handlers and the wasm-tier (`src/wasm/`) handler. Every state
* transition, validation rule, and lookup goes through this module so
* the two surfaces cannot drift.
*
* No Hono, no Request/Response, no OTel SDK — every function takes a
* domain value or store reference and returns a domain value or throws
* an `ExamplePluginError`. The status-mapping happens at the outer
* adapter boundary in each tier.
*/
/** Zod schema for the create-invoice input. */
export const CreateInvoiceInputSchema = z.object({
customerId: z.string().min(1),
amountCents: z.number().int().positive(),
currency: z.string().length(3),
});
/** Zod-validate user input into a strict `CreateInvoiceInput`. */
export function parseCreateInvoiceInput(
raw: unknown
):
| { success: true; data: CreateInvoiceInput }
| { success: false; issues: ReadonlyArray<z.core.$ZodIssue> } {
const parsed = CreateInvoiceInputSchema.safeParse(raw);
if (parsed.success) {
return { success: true, data: parsed.data };
}
return { success: false, issues: parsed.error.issues };
}
/** Insert a new invoice into the store. Initial state is `"open"`. */
export function createInvoice(
store: InMemoryStore,
input: CreateInvoiceInput
): Invoice {
const id = store.nextId();
return store.create({
id,
status: "open",
amountCents: input.amountCents,
currency: input.currency,
});
}
/**
* Look up an invoice by id. Throws `INVOICE_NOT_FOUND` when missing so
* the host error boundary maps the code to a 404 status.
*/
export function getInvoiceOrThrow(store: InMemoryStore, id: string): Invoice {
const found = store.get(id);
if (found === undefined) {
throw new ExamplePluginError(
EXAMPLE_CODES.INVOICE_NOT_FOUND,
`invoice ${id} not found`
);
}
return found;
}
/** List every invoice — stable insertion order. */
export function listInvoices(store: InMemoryStore): readonly Invoice[] {
return store.list();
}
/**
* Transition an invoice to `paid`. Throws `INVOICE_NOT_FOUND` when the
* id is absent and `INVALID_INVOICE_STATE` when the invoice is already
* paid (the FSM disallows re-finalisation).
*/
export function finaliseInvoice(store: InMemoryStore, id: string): Invoice {
const existing = getInvoiceOrThrow(store, id);
if (existing.status === "paid") {
throw new ExamplePluginError(
EXAMPLE_CODES.INVALID_INVOICE_STATE,
`invoice ${id} already finalised`
);
}
return store.setState(id, "paid");
}
/** Raise the canonical `SUBSCRIPTION_INACTIVE` error. */
export function raiseSubscriptionInactive(): never {
throw new ExamplePluginError(
EXAMPLE_CODES.SUBSCRIPTION_INACTIVE,
"tenant subscription is inactive"
);
}
/**
* Build the deliberately-violating payload used by the response-validation
* gate test. The toggle controls the variant returned so a single handler
* can serve both the schema-conforming and schema-violating paths.
*/
export function buildFailResponsePayload(fail: boolean): {
id: string;
status: string;
amountCents: number;
currency: string;
} {
if (fail) {
return { id: "x", status: "exploded", amountCents: -1, currency: "ZZ" };
}
return { id: "ok", status: "open", amountCents: 0, currency: "USD" };
}

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,29 @@
/**
* Pure domain types shared between the trusted-tier (`src/trusted/`) and
* wasm-tier (`src/wasm/`) surfaces of `@suaveplan/api-plugin-example`. No
* IO, no Hono, no OTel SDK, no Request/Response — these types are
* deliberately JSON-clean so the wasm-tier surface can marshal them
* through `SerializedRequest` / `SerializedResponse` bodies with no
* additional reshaping.
*/
/** Permitted invoice state — drives the lifecycle FSM in `invoice-rules.ts`. */
export type InvoiceState = "draft" | "open" | "paid";
/** Canonical invoice record persisted by both tier-stores. */
export interface Invoice {
readonly id: string;
readonly status: InvoiceState;
readonly amountCents: number;
readonly currency: string;
}
/** Inputs accepted by `createInvoice`. */
export interface CreateInvoiceInput {
readonly customerId: string;
readonly amountCents: number;
readonly currency: string;
}
/** Result of `finaliseInvoice` — the new persisted record. */
export type FinaliseInvoiceResult = Invoice;

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,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXAMPLE_CODES,
EXAMPLE_STATUS_MAP,
ExamplePluginError,
} from "./error-codes.js";
describe("error-codes", () => {
it("freezes EXAMPLE_CODES", { timeout: 5000 }, () => {
expect(Object.isFrozen(EXAMPLE_CODES)).toBe(true);
});
it("freezes EXAMPLE_STATUS_MAP", { timeout: 5000 }, () => {
expect(Object.isFrozen(EXAMPLE_STATUS_MAP)).toBe(true);
});
it("maps every code to a numeric HTTP status", { timeout: 5000 }, () => {
for (const code of Object.values(EXAMPLE_CODES)) {
const status = EXAMPLE_STATUS_MAP[code];
expect(typeof status).toBe("number");
expect(status).toBeGreaterThanOrEqual(200);
expect(status).toBeLessThan(600);
}
});
it("ExamplePluginError carries code + message + name", {
timeout: 5000,
}, () => {
const e = new ExamplePluginError(
EXAMPLE_CODES.SUBSCRIPTION_INACTIVE,
"test"
);
expect(e.code).toBe("PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE");
expect(e.message).toBe("test");
expect(e.name).toBe("ExamplePluginError");
expect(e).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,72 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-example`. Every
* code is namespaced under `PLUGIN.EXAMPLE.*` per design doc §13.5 and
* mapped to a concrete HTTP status in {@link EXAMPLE_STATUS_MAP}. The
* status map is the canonical reference that the plugin's `plugin.json`
* `errors.statusMap` block mirrors at install time.
*
* Plugin runtime code MUST throw {@link ExamplePluginError} so the host's
* error boundary observes the stable code rather than a generic `Error`
* instance. The integration suite asserts every code listed here is
* raised by at least one production path AND surfaces with the manifest-
* mapped status.
*/
/**
* Frozen registry — consumers index by name rather than typoing string
* literals. Built via `defineCodes` so each value is a literal-typed
* `"PLUGIN.EXAMPLE.<NAME>"` string, identical to the pattern used by
* `TWOPC_CODES`, `RATE_LIMIT_PLUGIN_CODES`, and friends.
*/
export const EXAMPLE_CODES = defineCodes("PLUGIN.EXAMPLE", [
"SUBSCRIPTION_INACTIVE",
"INVOICE_NOT_FOUND",
"INVALID_INVOICE_STATE",
"RESPONSE_VALIDATION_TRIGGER",
] as const);
/** Discriminated union of every stable code emitted by the example plugin. */
export type ExamplePluginCode =
(typeof EXAMPLE_CODES)[keyof typeof EXAMPLE_CODES];
/**
* Code → HTTP status. The host's error boundary reads this map when the
* plugin throws an {@link ExamplePluginError} to compute the response
* status. The same map is embedded in `plugin.json` so the manifest and
* runtime cannot drift.
*/
export const EXAMPLE_STATUS_MAP: Readonly<Record<ExamplePluginCode, number>> =
Object.freeze({
[EXAMPLE_CODES.SUBSCRIPTION_INACTIVE]: 409,
[EXAMPLE_CODES.INVOICE_NOT_FOUND]: 404,
[EXAMPLE_CODES.INVALID_INVOICE_STATE]: 422,
[EXAMPLE_CODES.RESPONSE_VALIDATION_TRIGGER]: 200,
});
/**
* Concrete error class for the example plugin. Extends
* {@link SuaveplanError} so the host's error boundary observes the
* stable `code` field and consults `manifest.errors.statusMap` to
* compute the HTTP status. `instanceof ExamplePluginError` narrows the
* `code` field to {@link ExamplePluginCode} at the type level.
*/
export class ExamplePluginError extends SuaveplanError<ExamplePluginCode> {
public constructor(
code: ExamplePluginCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
// Pin the prototype so `instanceof` works across realm boundaries
// (e.g. when the wasm-tier ABI shim re-throws across the
// sandbox/host divide). Tree-shake-safe: Object.setPrototypeOf is
// a runtime call biome cannot statically reduce away.
Object.setPrototypeOf(this, ExamplePluginError.prototype);
}
}

View File

@@ -0,0 +1,6 @@
export type { ExamplePluginCode } from "./error-codes.js";
export {
EXAMPLE_CODES,
EXAMPLE_STATUS_MAP,
ExamplePluginError,
} from "./error-codes.js";

View File

@@ -0,0 +1,47 @@
export type { ExamplePluginCode } from "./error-codes/error-codes.js";
export {
EXAMPLE_CODES,
EXAMPLE_STATUS_MAP,
ExamplePluginError,
} from "./error-codes/error-codes.js";
export type { ExampleInstruments } from "./instruments/instruments.js";
export {
getExampleInstruments,
resetExampleInstrumentsForTest,
} from "./instruments/instruments.js";
export type {
CreateInvoiceRequestT,
CreateInvoiceResponseT,
InvoiceResponseT,
PingResponseT,
StreamChunkT,
} from "./schemas/schemas.js";
export {
CreateInvoiceRequest,
CreateInvoiceResponse,
InvoiceResponse,
PingResponse,
StreamChunk,
} from "./schemas/schemas.js";
export type {
ExampleInvoiceCreatedPayloadT,
UsersUserDeletedPayloadT,
} from "./trusted/events/events.js";
export {
EXAMPLE_INVOICE_CREATED_TOPIC,
ExampleInvoiceCreatedPayload,
makeUserDeletedHandler,
USERS_USER_DELETED_TOPIC,
UsersUserDeletedPayload,
} from "./trusted/events/events.js";
export { examplePluginModule } from "./trusted/plugin/plugin.js";
export type {
BuildRoutesArgs,
ExampleRoute,
InvoiceLedger,
RoutePublisher,
} from "./trusted/routes/routes.js";
export {
buildRoutes,
createInMemoryLedger,
} from "./trusted/routes/routes.js";

View File

@@ -0,0 +1,5 @@
export type { ExampleInstruments } from "./instruments.js";
export {
getExampleInstruments,
resetExampleInstrumentsForTest,
} from "./instruments.js";

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,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getExampleInstruments,
resetExampleInstrumentsForTest,
} from "./instruments.js";
describe("instruments", () => {
it("caches the materialised registry across calls", {
timeout: 5000,
}, () => {
resetExampleInstrumentsForTest();
const a = getExampleInstruments();
const b = getExampleInstruments();
expect(a).toBe(b);
});
it("resetExampleInstrumentsForTest clears the cache", {
timeout: 5000,
}, () => {
const a = getExampleInstruments();
resetExampleInstrumentsForTest();
const b = getExampleInstruments();
expect(a).not.toBe(b);
});
it("exposes the three manifest-declared instruments", {
timeout: 5000,
}, () => {
const m = getExampleInstruments();
expect(m.invoicesFinalisedTotal).toBeDefined();
expect(m.stripeCallDurationMs).toBeDefined();
expect(m.outstandingInvoiceCount).toBeDefined();
// Smoke — emit a datapoint into each.
m.invoicesFinalisedTotal.add(1);
m.stripeCallDurationMs.record(12.3);
m.outstandingInvoiceCount.add(1);
});
});

View File

@@ -0,0 +1,63 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OpenTelemetry instruments declared by `api-plugin-example`. Each name
* mirrors the `telemetry.metrics[]` block in `plugin.json` — manifest
* and runtime cannot drift because the integration test reads both.
*
* The meter is resolved through the global provider so the test setup
* (`tests/setup/polyfills.ts`) can install an `InMemoryMetricExporter`
* before any handler runs and assert the emitted datapoints.
*/
const METER_NAME = "@suaveplan/api-plugin-example";
/** Lazily-resolved registry — first call materialises every instrument. */
export interface ExampleInstruments {
readonly invoicesFinalisedTotal: Counter;
readonly stripeCallDurationMs: Histogram;
/**
* Observable gauge synthesised through an in-process counter whose
* value is set + read via `observe` and `read` helpers. The
* MeterProvider exposes an `UpDownCounter` shape for the same
* semantics; we model it on the Counter API so the test surface
* stays uniform across the three instruments.
*/
readonly outstandingInvoiceCount: Counter;
}
let cached: ExampleInstruments | null = null;
/** Materialise every instrument against the global MeterProvider. */
export function getExampleInstruments(): ExampleInstruments {
if (cached !== null) return cached;
const meter = metrics.getMeter(METER_NAME);
cached = {
invoicesFinalisedTotal: meter.createCounter(
"example.invoices_finalised_total",
{
description:
"Total invoices transitioned to the finalised state.",
}
),
stripeCallDurationMs: meter.createHistogram(
"example.stripe.call_duration_ms",
{
description: "Outbound Stripe call duration in milliseconds.",
unit: "ms",
}
),
outstandingInvoiceCount: meter.createCounter(
"example.outstanding_invoice_count",
{
description:
"Net count of invoices still in the open state. Increment on open, decrement on finalise.",
}
),
};
return cached;
}
/** Reset the cached instruments — tests that swap the MeterProvider call this. */
export function resetExampleInstrumentsForTest(): void {
cached = null;
}

View File

@@ -0,0 +1,14 @@
export type {
CreateInvoiceRequestT,
CreateInvoiceResponseT,
InvoiceResponseT,
PingResponseT,
StreamChunkT,
} from "./schemas.js";
export {
CreateInvoiceRequest,
CreateInvoiceResponse,
InvoiceResponse,
PingResponse,
StreamChunk,
} from "./schemas.js";

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,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateInvoiceRequest,
CreateInvoiceResponse,
InvoiceResponse,
PingResponse,
StreamChunk,
} from "./schemas.js";
describe("schemas", () => {
it("CreateInvoiceRequest accepts valid input", { timeout: 5000 }, () => {
const r = CreateInvoiceRequest.safeParse({
customerId: "c-1",
amountCents: 1500,
currency: "USD",
});
expect(r.success).toBe(true);
});
it("CreateInvoiceRequest rejects missing fields", { timeout: 5000 }, () => {
const r = CreateInvoiceRequest.safeParse({
customerId: "c-1",
currency: "USD",
});
expect(r.success).toBe(false);
});
it("CreateInvoiceRequest rejects non-positive amount", {
timeout: 5000,
}, () => {
const r = CreateInvoiceRequest.safeParse({
customerId: "c-1",
amountCents: 0,
currency: "USD",
});
expect(r.success).toBe(false);
});
it("CreateInvoiceResponse rejects bad status enum", {
timeout: 5000,
}, () => {
const r = CreateInvoiceResponse.safeParse({
id: "i-1",
status: "exploded",
amountCents: 1,
currency: "USD",
});
expect(r.success).toBe(false);
});
it("InvoiceResponse mirrors CreateInvoiceResponse", {
timeout: 5000,
}, () => {
expect(InvoiceResponse).toBe(CreateInvoiceResponse);
});
it("PingResponse accepts the canonical shape", { timeout: 5000 }, () => {
const r = PingResponse.safeParse({
ok: true,
pluginName: "example",
});
expect(r.success).toBe(true);
});
it("StreamChunk requires nonnegative seq", { timeout: 5000 }, () => {
expect(StreamChunk.safeParse({ seq: 0, payload: "" }).success).toBe(
true
);
expect(StreamChunk.safeParse({ seq: -1, payload: "" }).success).toBe(
false
);
});
});

View File

@@ -0,0 +1,48 @@
import z from "zod";
/**
* Request + response Zod schemas owned by the example plugin. Every
* route in `routes/routes.ts` is wired against one of these so the
* §6 step 4 (input validation) and step 9 (response validation) gates
* always have a real schema to consult.
*
* Schemas live in this single module so the plugin manifest's
* `openapi` block can re-resolve them from a stable specifier.
*/
/** `POST /example/invoices` body. */
export const CreateInvoiceRequest = z.object({
customerId: z.string().min(1),
amountCents: z.number().int().positive(),
currency: z.string().length(3),
});
/** Created-invoice response. */
export const CreateInvoiceResponse = z.object({
id: z.string(),
status: z.enum(["draft", "open", "paid"]),
amountCents: z.number().int(),
currency: z.string().length(3),
});
/** `GET /example/invoices/:id` response. */
export const InvoiceResponse = CreateInvoiceResponse;
/** Public ping response. */
export const PingResponse = z.object({
ok: z.literal(true),
pluginName: z.string(),
});
/** Streaming chunk envelope. */
export const StreamChunk = z.object({
seq: z.number().int().nonnegative(),
payload: z.string(),
});
/** Inferred TypeScript types — the runtime parses, code consumes. */
export type CreateInvoiceRequestT = z.infer<typeof CreateInvoiceRequest>;
export type CreateInvoiceResponseT = z.infer<typeof CreateInvoiceResponse>;
export type InvoiceResponseT = z.infer<typeof InvoiceResponse>;
export type PingResponseT = z.infer<typeof PingResponse>;
export type StreamChunkT = z.infer<typeof StreamChunk>;

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,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXAMPLE_INVOICE_CREATED_TOPIC,
ExampleInvoiceCreatedPayload,
makeUserDeletedHandler,
USERS_USER_DELETED_TOPIC,
UsersUserDeletedPayload,
} from "./events.js";
describe("events", () => {
it("exposes the canonical topic names", { timeout: 5000 }, () => {
expect(EXAMPLE_INVOICE_CREATED_TOPIC).toBe("example.invoice.created");
expect(USERS_USER_DELETED_TOPIC).toBe("users.user.deleted");
});
it("validates ExampleInvoiceCreatedPayload", { timeout: 5000 }, () => {
expect(
ExampleInvoiceCreatedPayload.safeParse({
id: "i-1",
customerId: "c-1",
amountCents: 1,
currency: "USD",
}).success
).toBe(true);
expect(ExampleInvoiceCreatedPayload.safeParse({}).success).toBe(false);
});
it("validates UsersUserDeletedPayload", { timeout: 5000 }, () => {
expect(
UsersUserDeletedPayload.safeParse({ userId: "u", tenantId: null })
.success
).toBe(true);
expect(
UsersUserDeletedPayload.safeParse({ userId: 1, tenantId: null })
.success
).toBe(false);
});
it("makeUserDeletedHandler records valid payloads into sink", {
timeout: 5000,
}, () => {
const sink = { seen: new Set<string>() };
const h = makeUserDeletedHandler(sink);
h({ userId: "u-1", tenantId: null });
h({ userId: "u-2", tenantId: "t" });
expect([...sink.seen].sort()).toEqual(["u-1", "u-2"]);
});
it("makeUserDeletedHandler swallows malformed payloads", {
timeout: 5000,
}, () => {
const sink = { seen: new Set<string>() };
const h = makeUserDeletedHandler(sink);
h({ wrong: "shape" });
h(null);
h("not-an-object");
expect(sink.seen.size).toBe(0);
});
});

View File

@@ -0,0 +1,52 @@
import z from "zod";
/**
* Event topics owned + consumed by `api-plugin-example`. The plugin
* publishes `example.invoice.created` and subscribes to
* `users.user.deleted`. Both topics and their payload schemas live
* here so the plugin manifest can resolve them from a stable specifier
* and the integration test can hand-craft payloads against the same
* source.
*/
/** Topic published when an invoice is created. */
export const EXAMPLE_INVOICE_CREATED_TOPIC = "example.invoice.created" as const;
/** Topic subscribed for upstream user-deletion fan-out. */
export const USERS_USER_DELETED_TOPIC = "users.user.deleted" as const;
/** Payload for `example.invoice.created`. */
export const ExampleInvoiceCreatedPayload = z.object({
id: z.string(),
customerId: z.string(),
amountCents: z.number().int(),
currency: z.string().length(3),
});
/** Payload accepted on `users.user.deleted`. */
export const UsersUserDeletedPayload = z.object({
userId: z.string(),
tenantId: z.string().nullable(),
});
export type ExampleInvoiceCreatedPayloadT = z.infer<
typeof ExampleInvoiceCreatedPayload
>;
export type UsersUserDeletedPayloadT = z.infer<typeof UsersUserDeletedPayload>;
/**
* Handler invoked when the plugin's `users.user.deleted` subscription
* fires. The example implementation records the seen user-id into the
* caller-supplied sink so integration tests can assert delivery.
*/
export function makeUserDeletedHandler(sink: {
readonly seen: Set<string>;
}): (raw: unknown) => void {
return (raw) => {
const parsed = UsersUserDeletedPayload.safeParse(raw);
if (!parsed.success) {
return;
}
sink.seen.add(parsed.data.userId);
};
}

View File

@@ -0,0 +1,11 @@
export type {
ExampleInvoiceCreatedPayloadT,
UsersUserDeletedPayloadT,
} from "./events.js";
export {
EXAMPLE_INVOICE_CREATED_TOPIC,
ExampleInvoiceCreatedPayload,
makeUserDeletedHandler,
USERS_USER_DELETED_TOPIC,
UsersUserDeletedPayload,
} from "./events.js";

View File

@@ -0,0 +1,19 @@
export type {
ExampleInvoiceCreatedPayloadT,
UsersUserDeletedPayloadT,
} from "./events/events.js";
export {
EXAMPLE_INVOICE_CREATED_TOPIC,
ExampleInvoiceCreatedPayload,
makeUserDeletedHandler,
USERS_USER_DELETED_TOPIC,
UsersUserDeletedPayload,
} from "./events/events.js";
export { examplePluginModule } from "./plugin/plugin.js";
export type {
BuildRoutesArgs,
ExampleRoute,
InvoiceLedger,
RoutePublisher,
} from "./routes/routes.js";
export { buildRoutes, createInMemoryLedger } from "./routes/routes.js";

View File

@@ -0,0 +1 @@
export { examplePluginModule } from "./plugin.js";

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,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { examplePluginModule } from "./plugin.js";
describe("plugin", () => {
it("freezes the module record", { timeout: 5000 }, () => {
expect(Object.isFrozen(examplePluginModule)).toBe(true);
});
it("declares the canonical name + version", { timeout: 5000 }, () => {
expect(examplePluginModule.name).toBe("@suaveplan/api-plugin-example");
expect(examplePluginModule.version).toBe("0.1.0");
});
it("isolation + trustLevel are first-party + trusted", {
timeout: 5000,
}, () => {
expect(examplePluginModule.isolation).toBe("trusted");
expect(examplePluginModule.trustLevel).toBe("first-party");
});
it("every lifecycle hook is callable", { timeout: 5000 }, async () => {
const ctx = {} as never;
await examplePluginModule.install?.(ctx);
await examplePluginModule.start(ctx);
await examplePluginModule.stop?.(ctx);
await examplePluginModule.uninstall?.(ctx);
await examplePluginModule.health?.(ctx);
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import type { PluginModule } from "@suaveplan/api-host/define-plugin";
import { definePlugin } from "@suaveplan/api-host/define-plugin";
/**
* The frozen plugin module record consumed by the host's loader. The
* runtime side of the reference plugin is intentionally minimal — every
* lifecycle hook is exercised but performs only the observable work the
* integration tests assert against.
*
* The `requires` list is empty because every capability the example
* exercises (router, auth, tenant, secrets, db, events) is supplied by
* the host via the per-request context shape, not requested through
* the typed capability registry. As the capability registry matures,
* this list will tighten to the literal subset the routes consume.
*/
export const examplePluginModule: PluginModule<readonly []> = definePlugin({
name: "@suaveplan/api-plugin-example",
version: "0.1.0",
requires: [] as const,
isolation: "trusted" as const,
trustLevel: "first-party" as const,
install: () => undefined,
start: () => undefined,
stop: () => undefined,
uninstall: () => undefined,
health: () => undefined,
});

View File

@@ -0,0 +1,7 @@
export type {
BuildRoutesArgs,
ExampleRoute,
InvoiceLedger,
RoutePublisher,
} from "./routes.js";
export { buildRoutes, createInMemoryLedger } from "./routes.js";

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,463 @@
import type { Principal } from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXAMPLE_CODES,
ExamplePluginError,
} from "../../error-codes/error-codes.js";
import {
asStore,
buildRoutes,
createInMemoryLedger,
type ExampleRoute,
} from "./routes.js";
function buildTable(): {
table: ReadonlyArray<ExampleRoute>;
published: Array<{ topic: string; payload: unknown }>;
} {
const ledger = createInMemoryLedger();
const published: Array<{ topic: string; payload: unknown }> = [];
const table = buildRoutes({
ledger,
publisher: {
publish(topic, payload) {
published.push({ topic, payload });
},
},
});
return { table, published };
}
function find(
table: ReadonlyArray<ExampleRoute>,
method: string,
path: string
): ExampleRoute {
const r = table.find((x) => x.method === method && x.path === path);
if (r === undefined) throw new Error(`route not found: ${method} ${path}`);
return r;
}
const adminPrincipal: Principal = {
id: "p",
scopes: ["example:read", "example:write"] as unknown as Principal["scopes"],
claims: {},
};
describe("routes", () => {
it("public ping is unauthenticated", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/public/ping");
const out = (await r.handler({})) as { ok: boolean };
expect(out.ok).toBe(true);
});
it("health is unauthenticated", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/health");
const out = (await r.handler({})) as { ok: boolean };
expect(out.ok).toBe(true);
});
it("listInvoices returns RBAC Response when missing scope", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/invoices");
const res = (await r.handler({ principal: null })) as Response;
expect(res).toBeInstanceOf(Response);
expect(res.status).toBe(403);
});
it("listInvoices returns array when scope granted", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/invoices");
const out = (await r.handler({ principal: adminPrincipal })) as {
invoices: ReadonlyArray<unknown>;
};
expect(out.invoices).toEqual([]);
});
it("createInvoice rejects malformed body with 400", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const res = (await r.handler({
principal: adminPrincipal,
body: { customerId: "c-1" },
})) as Response;
expect(res.status).toBe(400);
});
it("createInvoice persists + publishes on happy path", {
timeout: 5000,
}, async () => {
const { table, published } = buildTable();
const r = find(table, "POST", "/example/invoices");
const out = (await r.handler({
principal: adminPrincipal,
body: { customerId: "c-1", amountCents: 100, currency: "USD" },
})) as { id: string; status: string };
expect(out.id).toBe("inv-1");
expect(out.status).toBe("open");
expect(published).toHaveLength(1);
expect(published[0]?.topic).toBe("example.invoice.created");
});
it("getInvoice 404s unknown id", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/invoices/:id");
try {
await r.handler({
principal: adminPrincipal,
params: { id: "missing" },
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(ExamplePluginError);
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVOICE_NOT_FOUND
);
}
});
it("getInvoice returns the persisted record", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
await find(table, "POST", "/example/invoices").handler({
principal: adminPrincipal,
body: { customerId: "c", amountCents: 1, currency: "USD" },
});
const r = find(table, "GET", "/example/invoices/:id");
const out = (await r.handler({
principal: adminPrincipal,
params: { id: "inv-1" },
})) as { id: string };
expect(out.id).toBe("inv-1");
});
it("finaliseInvoice transitions to paid + records metrics", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
await find(table, "POST", "/example/invoices").handler({
principal: adminPrincipal,
body: { customerId: "c", amountCents: 1, currency: "USD" },
});
const r = find(table, "POST", "/example/invoices/:id/finalise");
const out = (await r.handler({
principal: adminPrincipal,
params: { id: "inv-1" },
})) as { status: string };
expect(out.status).toBe("paid");
});
it("finaliseInvoice 404s unknown id", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices/:id/finalise");
try {
await r.handler({
principal: adminPrincipal,
params: { id: "missing" },
});
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVOICE_NOT_FOUND
);
}
});
it("finaliseInvoice rejects already-paid", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
await find(table, "POST", "/example/invoices").handler({
principal: adminPrincipal,
body: { customerId: "c", amountCents: 1, currency: "USD" },
});
await find(table, "POST", "/example/invoices/:id/finalise").handler({
principal: adminPrincipal,
params: { id: "inv-1" },
});
try {
await find(table, "POST", "/example/invoices/:id/finalise").handler(
{
principal: adminPrincipal,
params: { id: "inv-1" },
}
);
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVALID_INVOICE_STATE
);
}
});
it("finaliseInvoice rejects missing scope", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices/:id/finalise");
const res = (await r.handler({
principal: null,
params: { id: "x" },
})) as Response;
expect(res.status).toBe(403);
});
it("createInvoice rejects missing scope", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const res = (await r.handler({
principal: null,
body: { customerId: "c", amountCents: 1, currency: "USD" },
})) as Response;
expect(res.status).toBe(403);
});
it("getInvoice rejects missing scope", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/invoices/:id");
const res = (await r.handler({
principal: null,
params: { id: "x" },
})) as Response;
expect(res.status).toBe(403);
});
it("streamLargeReport returns chunked Response", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/stream/large-report");
const res = (await r.handler({})) as Response;
expect(res.status).toBe(200);
expect(res.headers.get("transfer-encoding")).toBe("chunked");
const text = await res.text();
const lines = text.trim().split("\n");
expect(lines.length).toBe(3);
});
it("rateLimited route returns ok body", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/rate-limited");
const out = (await r.handler({})) as { ok: boolean };
expect(out.ok).toBe(true);
});
it("subscriptionRequired throws SUBSCRIPTION_INACTIVE", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/example/subscription-required");
try {
await r.handler({});
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.SUBSCRIPTION_INACTIVE
);
}
});
it("createInvoice reads body from request when ctx.body is undefined", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const req = new Request("http://t/example/invoices", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c",
amountCents: 5,
currency: "USD",
}),
});
const out = (await r.handler({
principal: adminPrincipal,
request: req,
})) as { status: string };
expect(out.status).toBe("open");
});
it("createInvoice rejects request with non-JSON body", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const req = new Request("http://t/example/invoices", {
method: "POST",
headers: { "content-type": "text/plain" },
body: "not json",
});
const res = (await r.handler({
principal: adminPrincipal,
request: req,
})) as Response;
expect(res.status).toBe(400);
});
it("createInvoice rejects empty JSON body", { timeout: 5000 }, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const req = new Request("http://t/example/invoices", {
method: "POST",
headers: { "content-type": "application/json" },
});
const res = (await r.handler({
principal: adminPrincipal,
request: req,
})) as Response;
expect(res.status).toBe(400);
});
it("createInvoice rejects request with no content-type header", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const headers = new Headers();
const req = new Request("http://t/example/invoices", {
method: "POST",
headers,
body: "{}",
});
const res = (await r.handler({
principal: adminPrincipal,
request: req,
})) as Response;
expect(res.status).toBe(400);
});
it("createInvoice rejects malformed JSON via raw request", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "POST", "/example/invoices");
const req = new Request("http://t/example/invoices", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not-json",
});
const res = (await r.handler({
principal: adminPrincipal,
request: req,
})) as Response;
expect(res.status).toBe(400);
});
it("failResponse emits violating payload with ?failResponse=1", {
timeout: 5000,
}, async () => {
const { table } = buildTable();
const r = find(table, "GET", "/_/test/fail-response");
const bad = (await r.handler({
query: { failResponse: "1" },
})) as { status: string };
expect(bad.status).toBe("exploded");
const good = (await r.handler({ query: {} })) as { status: string };
expect(good.status).toBe("open");
});
});
describe("asStore adapter", () => {
function ledgerOf(): ReturnType<typeof createInMemoryLedger> {
return createInMemoryLedger();
}
it("create rejects duplicate id", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
store.create({
id: "x",
status: "open",
amountCents: 1,
currency: "USD",
});
expect(() =>
store.create({
id: "x",
status: "open",
amountCents: 2,
currency: "USD",
})
).toThrow(/already exists/);
});
it("setState throws on missing id", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
expect(() => store.setState("missing", "paid")).toThrow(/not found/);
});
it("delete returns true/false", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
store.create({
id: "y",
status: "open",
amountCents: 1,
currency: "USD",
});
expect(store.delete("y")).toBe(true);
expect(store.delete("y")).toBe(false);
});
it("clear empties the ledger map", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
store.create({
id: "z",
status: "open",
amountCents: 1,
currency: "USD",
});
store.clear();
expect(ledger.invoices.size).toBe(0);
});
it("setCounter is a no-op for legacy ledgers", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
// The asStore adapter ignores setCounter — the legacy ledger
// manages its own counter via createInMemoryLedger.nextId. We
// assert no throw and that subsequent allocations are unaffected.
store.setCounter(99);
expect(ledger.nextId()).toBe("inv-1");
});
it("get returns undefined for missing id", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
expect(store.get("missing")).toBeUndefined();
});
it("list returns insertion order", { timeout: 5000 }, () => {
const ledger = ledgerOf();
const store = asStore(ledger);
store.create({
id: "a",
status: "open",
amountCents: 1,
currency: "USD",
});
store.create({
id: "b",
status: "open",
amountCents: 1,
currency: "USD",
});
expect(store.list().map((i) => i.id)).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,398 @@
import type { Principal } from "@suaveplan/api-types/principal";
import {
buildFailResponsePayload,
createInMemoryStore,
createInvoice as domainCreateInvoice,
finaliseInvoice as domainFinaliseInvoice,
getInvoiceOrThrow,
type InMemoryStore,
type Invoice,
listInvoices,
parseCreateInvoiceInput,
raiseSubscriptionInactive,
} from "../../domain/index.js";
import { EXAMPLE_STATUS_MAP } from "../../error-codes/error-codes.js";
import { getExampleInstruments } from "../../instruments/instruments.js";
import {
EXAMPLE_INVOICE_CREATED_TOPIC,
type ExampleInvoiceCreatedPayloadT,
} from "../events/events.js";
/**
* Minimal in-process invoice ledger consumed by the example routes.
* The integration tests inject a fresh instance into {@link buildRoutes}
* so suites are isolated from each other.
*
* The ledger is a thin façade over the shared `InMemoryStore` from
* `src/domain/`. Both the trusted-tier handlers in this file and the
* wasm-tier handler in `src/wasm/handle-request.ts` operate on the
* same store contract — this is what guarantees response parity for
* the shape-conformance test.
*/
export interface InvoiceLedger {
readonly invoices: Map<string, Invoice>;
nextId(): string;
}
/** Sink invoked when a route wants to publish onto the host event bus. */
export interface RoutePublisher {
publish(topic: string, payload: ExampleInvoiceCreatedPayloadT): void;
}
/** Construction inputs for {@link buildRoutes}. */
export interface BuildRoutesArgs {
readonly ledger: InvoiceLedger;
readonly publisher: RoutePublisher;
}
/** A route registration shape compatible with `createTestHost.installFixturePlugin`. */
export interface ExampleRoute {
readonly method: string;
readonly path: string;
readonly handler: (raw: unknown) => unknown | Promise<unknown>;
readonly statusMap?: Readonly<Record<string, number>>;
}
/** Per-request context shape the host hands to a fixture handler. */
interface FixtureCtx {
readonly principal: Principal | null;
readonly tenant: { readonly id: string; readonly slug: string } | null;
readonly request: Request;
readonly body?: unknown;
readonly query?: Readonly<Record<string, string>>;
}
/**
* Build the full route table exposed by the example plugin. Every route
* lives in this single factory so the test fixture and the production
* `definePlugin.start` hook materialise the same surface.
*
* The handlers delegate every business decision to
* `src/domain/invoice-rules.ts` — the same module the wasm-tier
* `handleRequest` calls. The trusted-tier layer only owns the Hono /
* `Request` adapter work, plus the OTel metric recording and event
* publishing the sandbox tier cannot perform.
*
* Routes registered:
*
* - GET /example/public/ping — public, no auth required.
* - GET /example/health — public health probe.
* - GET /example/invoices — RBAC-gated (requires "example:read").
* - GET /example/invoices/:id — RBAC + may throw INVOICE_NOT_FOUND (404).
* - POST /example/invoices — Zod-validated body, idempotency-aware,
* publishes example.invoice.created.
* - POST /example/invoices/:id/finalise
* — increments `example.invoices_finalised_total`
* AND records `example.stripe.call_duration_ms`.
* Throws INVALID_INVOICE_STATE on already-finalised.
* - GET /example/stream/large-report
* — streaming chunked Response (trusted-tier only).
* - GET /example/rate-limited — declared rate-limited (host gate handles 429).
* - GET /example/subscription-required
* — exercises SUBSCRIPTION_INACTIVE.
* - GET /_/test/fail-response — emits a response that violates the
* declared `CreateInvoiceResponse` schema,
* used to exercise the response-validation
* enforce vs warn modes.
*/
export function buildRoutes(args: BuildRoutesArgs): ExampleRoute[] {
const { ledger, publisher } = args;
const store = asStore(ledger);
return [
publicPing(),
publicHealth(),
listInvoicesRoute(store),
getInvoiceRoute(store),
createInvoiceRoute(store, publisher),
finaliseInvoiceRoute(store),
streamLargeReport(),
rateLimited(),
subscriptionRequired(),
failResponse(),
];
}
function publicPing(): ExampleRoute {
return {
method: "GET",
path: "/example/public/ping",
handler: () => ({
ok: true,
pluginName: "@suaveplan/api-plugin-example",
}),
};
}
function publicHealth(): ExampleRoute {
return {
method: "GET",
path: "/example/health",
handler: () => ({ ok: true }),
};
}
function listInvoicesRoute(store: InMemoryStore): ExampleRoute {
return {
method: "GET",
path: "/example/invoices",
statusMap: { ...EXAMPLE_STATUS_MAP },
handler: (raw) => {
const ctx = raw as FixtureCtx;
const deny = rbacCheck(ctx.principal, "example:read");
if (deny !== null) return deny;
return { invoices: listInvoices(store) };
},
};
}
function getInvoiceRoute(store: InMemoryStore): ExampleRoute {
return {
method: "GET",
path: "/example/invoices/:id",
statusMap: { ...EXAMPLE_STATUS_MAP },
handler: (raw) => {
const ctx = raw as FixtureCtx & {
readonly params: { readonly id: string };
};
const deny = rbacCheck(ctx.principal, "example:read");
if (deny !== null) return deny;
return getInvoiceOrThrow(store, ctx.params.id);
},
};
}
function createInvoiceRoute(
store: InMemoryStore,
publisher: RoutePublisher
): ExampleRoute {
return {
method: "POST",
path: "/example/invoices",
statusMap: { ...EXAMPLE_STATUS_MAP },
handler: async (raw) => {
const ctx = raw as FixtureCtx;
const deny = rbacCheck(ctx.principal, "example:write");
if (deny !== null) return deny;
const body = await readJsonBody(ctx);
const parsed = parseCreateInvoiceInput(body);
if (!parsed.success) {
return new Response(
JSON.stringify({
code: "VALIDATION_FAILED",
issues: parsed.issues,
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
}
const created = domainCreateInvoice(store, parsed.data);
getExampleInstruments().outstandingInvoiceCount.add(1);
publisher.publish(EXAMPLE_INVOICE_CREATED_TOPIC, {
id: created.id,
customerId: parsed.data.customerId,
amountCents: parsed.data.amountCents,
currency: parsed.data.currency,
});
return created;
},
};
}
function finaliseInvoiceRoute(store: InMemoryStore): ExampleRoute {
return {
method: "POST",
path: "/example/invoices/:id/finalise",
statusMap: { ...EXAMPLE_STATUS_MAP },
handler: async (raw) => {
const ctx = raw as FixtureCtx & {
readonly params: { readonly id: string };
};
const deny = rbacCheck(ctx.principal, "example:write");
if (deny !== null) return deny;
const start = performance.now();
await Promise.resolve();
const updated = domainFinaliseInvoice(store, ctx.params.id);
const instruments = getExampleInstruments();
instruments.invoicesFinalisedTotal.add(1);
instruments.outstandingInvoiceCount.add(-1);
instruments.stripeCallDurationMs.record(
Math.max(performance.now() - start, 0.001)
);
return updated;
},
};
}
function streamLargeReport(): ExampleRoute {
return {
method: "GET",
path: "/example/stream/large-report",
handler: () => {
const enc = new TextEncoder();
let seq = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (seq >= 3) {
controller.close();
return;
}
controller.enqueue(
enc.encode(`${JSON.stringify({ seq, payload: "x" })}\n`)
);
seq += 1;
},
});
return new Response(stream, {
status: 200,
headers: {
"content-type": "application/x-ndjson",
"transfer-encoding": "chunked",
},
});
},
};
}
function rateLimited(): ExampleRoute {
return {
method: "GET",
path: "/example/rate-limited",
handler: () => ({ ok: true }),
};
}
function subscriptionRequired(): ExampleRoute {
return {
method: "GET",
path: "/example/subscription-required",
statusMap: { ...EXAMPLE_STATUS_MAP },
handler: () => raiseSubscriptionInactive(),
};
}
function failResponse(): ExampleRoute {
return {
method: "GET",
path: "/_/test/fail-response",
handler: (raw) => {
const ctx = raw as FixtureCtx;
const payload = buildFailResponsePayload(
ctx.query?.["failResponse"] === "1"
);
// The handler's outer return type allows the violating
// shape (string `status`); the response-validation gate
// is the consumer that flags it.
return payload as unknown as Invoice;
},
};
}
/**
* Build a 403 RBAC envelope when the principal lacks the required
* scope, or `null` when the scope is satisfied. The handler returns
* the envelope directly so the host's pipeline serialises it without
* routing through the error boundary — RBAC is a per-request gate,
* not a thrown failure.
*/
function rbacCheck(
principal: Principal | null,
scope: string
): Response | null {
const scopes = (principal?.scopes ?? []) as ReadonlyArray<string>;
if (scopes.includes(scope)) return null;
return new Response(
JSON.stringify({
code: "RBAC_MISSING_SCOPE",
requiredScope: scope,
actualScopes: scopes,
}),
{ status: 403, headers: { "content-type": "application/json" } }
);
}
/**
* Read + JSON-parse the request body. Prefers the pre-parsed `ctx.body`
* surfaced by the host when route options declare a request schema; falls
* back to reading `ctx.request` directly when the host hands an undefined
* body (fixture registrations supplied through `createTestHost` carry no
* route options).
*/
async function readJsonBody(ctx: FixtureCtx): Promise<unknown> {
if (ctx.body !== undefined) return ctx.body;
const ct = ctx.request.headers.get("content-type") ?? "";
if (!ct.toLowerCase().includes("application/json")) return undefined;
const text = await ctx.request.clone().text();
if (text.length === 0) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/**
* Adapt a legacy `InvoiceLedger` to the shared `InMemoryStore`. The
* adapter reads/writes through `ledger.invoices` (a `Map<string, Invoice>`)
* so existing fixture-test setups that create a ledger and inspect
* `ledger.invoices` continue to observe state from the route handlers.
*
* Internal helper — exported only for the route-test suite so the
* uncovered adapter branches can be exercised directly. NOT part of
* the public root barrel.
*/
export function asStore(ledger: InvoiceLedger): InMemoryStore {
return {
nextId() {
return ledger.nextId();
},
create(invoice) {
if (ledger.invoices.has(invoice.id)) {
throw new Error(`invoice ${invoice.id} already exists`);
}
ledger.invoices.set(invoice.id, invoice);
return invoice;
},
get(id) {
return ledger.invoices.get(id);
},
list() {
return Array.from(ledger.invoices.values());
},
setState(id, state) {
const existing = ledger.invoices.get(id);
if (existing === undefined) {
throw new Error(`invoice ${id} not found`);
}
const updated: Invoice = { ...existing, status: state };
ledger.invoices.set(id, updated);
return updated;
},
delete(id) {
return ledger.invoices.delete(id);
},
clear() {
ledger.invoices.clear();
},
setCounter() {
// legacy ledgers manage their own counter
},
};
}
/** Backwards-compatible factory: legacy `InvoiceLedger` over the shared store. */
export function createInMemoryLedger(): InvoiceLedger {
const store = createInMemoryStore();
const invoices = new Map<string, Invoice>();
let counter = 0;
return {
invoices,
nextId() {
counter += 1;
// Keep the inner store's counter in sync for completeness.
store.setCounter(counter);
return `inv-${counter}`;
},
};
}

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,26 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { dispose } from "./dispose.js";
import { getStore, resetState } from "./wasm-state.js";
beforeEach(() => {
resetState();
});
describe("wasm/dispose", () => {
it("dispose clears the store", { timeout: 5000 }, () => {
getStore().create({
id: "x",
status: "open",
amountCents: 1,
currency: "USD",
});
dispose();
expect(getStore().list()).toEqual([]);
});
it("dispose is idempotent", { timeout: 5000 }, () => {
dispose();
dispose();
expect(getStore().list()).toEqual([]);
});
});

View File

@@ -0,0 +1,13 @@
import { getStore } from "./wasm-state.js";
/**
* 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.
*/
export function dispose(): void {
getStore().clear();
}

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,450 @@
import type { SerializedRequest } from "@suaveplan/api-types/sandbox-bridge";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
EXAMPLE_CODES,
type ExamplePluginError,
} from "../error-codes/error-codes.js";
import { handleRequest } from "./handle-request.js";
import { resetState } from "./wasm-state.js";
beforeEach(() => {
resetState();
});
function encode(text: string): string {
let binary = "";
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] ?? 0);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function decode(b64url: string): string {
const padLen = (4 - (b64url.length % 4)) % 4;
const padded =
b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
return atob(padded);
}
function req(opts: {
method: string;
path: string;
body?: string;
capabilities?: readonly string[];
}): SerializedRequest {
return {
url: `http://t${opts.path}`,
method: opts.method,
headers:
opts.body !== undefined
? [["content-type", "application/json"]]
: [],
bodyBase64: opts.body !== undefined ? encode(opts.body) : "",
principalId: null,
tenantId: null,
capabilities: opts.capabilities ?? [],
requestId: "r",
timestamp: 0,
};
}
const ADMIN: readonly string[] = ["example:read", "example:write"] as const;
describe("wasm/handle-request", () => {
it("public ping returns ok", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/example/public/ping" })
);
expect(res.status).toBe(200);
expect(JSON.parse(decode(res.bodyBase64))).toEqual({
ok: true,
pluginName: "@suaveplan/api-plugin-example",
});
});
it("health returns ok", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/example/health" })
);
expect(res.status).toBe(200);
});
it("listInvoices denies missing scope", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/example/invoices" })
);
expect(res.status).toBe(403);
});
it("listInvoices returns empty list with scope", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "GET",
path: "/example/invoices",
capabilities: ADMIN,
})
);
expect(res.status).toBe(200);
expect(JSON.parse(decode(res.bodyBase64))).toEqual({ invoices: [] });
});
it("createInvoice rejects missing scope", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
})
);
expect(res.status).toBe(403);
});
it("createInvoice rejects bad body with 400", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({ customerId: "" }),
capabilities: ADMIN,
})
);
expect(res.status).toBe(400);
});
it("createInvoice happy path returns the created invoice", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "c",
amountCents: 100,
currency: "USD",
}),
capabilities: ADMIN,
})
);
expect(res.status).toBe(200);
const body = JSON.parse(decode(res.bodyBase64)) as {
id: string;
status: string;
};
expect(body).toEqual({
id: "inv-1",
status: "open",
amountCents: 100,
currency: "USD",
});
});
it("getInvoice 404 by throw", { timeout: 5000 }, async () => {
try {
await handleRequest(
req({
method: "GET",
path: "/example/invoices/missing",
capabilities: ADMIN,
})
);
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVOICE_NOT_FOUND
);
}
});
it("getInvoice returns persisted record", {
timeout: 5000,
}, async () => {
await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
capabilities: ADMIN,
})
);
const res = await handleRequest(
req({
method: "GET",
path: "/example/invoices/inv-1",
capabilities: ADMIN,
})
);
expect(res.status).toBe(200);
});
it("getInvoice denies missing scope", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/example/invoices/x" })
);
expect(res.status).toBe(403);
});
it("finaliseInvoice transitions to paid", {
timeout: 5000,
}, async () => {
await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
capabilities: ADMIN,
})
);
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices/inv-1/finalise",
capabilities: ADMIN,
})
);
expect(res.status).toBe(200);
const body = JSON.parse(decode(res.bodyBase64)) as { status: string };
expect(body.status).toBe("paid");
});
it("finaliseInvoice denies missing scope", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({ method: "POST", path: "/example/invoices/x/finalise" })
);
expect(res.status).toBe(403);
});
it("finaliseInvoice double-finalise throws INVALID_INVOICE_STATE", {
timeout: 5000,
}, async () => {
await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
capabilities: ADMIN,
})
);
await handleRequest(
req({
method: "POST",
path: "/example/invoices/inv-1/finalise",
capabilities: ADMIN,
})
);
try {
await handleRequest(
req({
method: "POST",
path: "/example/invoices/inv-1/finalise",
capabilities: ADMIN,
})
);
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.INVALID_INVOICE_STATE
);
}
});
it("rate-limited route returns ok", { timeout: 5000 }, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/example/rate-limited" })
);
expect(res.status).toBe(200);
});
it("subscription-required throws SUBSCRIPTION_INACTIVE", {
timeout: 5000,
}, async () => {
try {
await handleRequest(
req({
method: "GET",
path: "/example/subscription-required",
})
);
throw new Error("expected throw");
} catch (e) {
expect((e as ExamplePluginError).code).toBe(
EXAMPLE_CODES.SUBSCRIPTION_INACTIVE
);
}
});
it("fail-response without flag returns conforming payload", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({ method: "GET", path: "/_/test/fail-response" })
);
expect(res.status).toBe(200);
const body = JSON.parse(decode(res.bodyBase64)) as { status: string };
expect(body.status).toBe("open");
});
it("fail-response with ?failResponse=1 returns violating payload", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "GET",
path: "/_/test/fail-response?failResponse=1",
})
);
const body = JSON.parse(decode(res.bodyBase64)) as { status: string };
expect(body.status).toBe("exploded");
});
it("unknown route returns 404", { timeout: 5000 }, async () => {
const res = await handleRequest(req({ method: "GET", path: "/nope" }));
expect(res.status).toBe(404);
});
it("POST under /example/invoices/ without /finalise returns 404", {
timeout: 5000,
}, async () => {
// Exercises the matchFinaliseId !endsWith(suffix) branch.
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices/inv-1/something-else",
capabilities: ADMIN,
})
);
expect(res.status).toBe(404);
});
it("POST to unrelated path falls through to 404", {
timeout: 5000,
}, async () => {
// Exercises matchFinaliseId !startsWith(prefix) branch.
const res = await handleRequest(
req({ method: "POST", path: "/somewhere/else" })
);
expect(res.status).toBe(404);
});
it("POST with empty body returns 400 via parseRequestBody fast-path", {
timeout: 5000,
}, async () => {
// Body is "" (bodyBase64 empty) — exercises parseRequestBody
// length-zero return.
const res = await handleRequest({
url: "http://t/example/invoices",
method: "POST",
headers: [],
bodyBase64: "",
principalId: null,
tenantId: null,
capabilities: ADMIN,
requestId: "r",
timestamp: 0,
});
expect(res.status).toBe(400);
});
it("decodes non-empty body correctly via base64-url-no-pad", {
timeout: 5000,
}, async () => {
// Test with body that produces "+" / "/" in base64
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices",
body: JSON.stringify({
customerId: "ÿøz",
amountCents: 1,
currency: "USD",
}),
capabilities: ADMIN,
})
);
expect(res.status).toBe(200);
});
it("malformed body returns 400 (parse failure → undefined → zod fail)", {
timeout: 5000,
}, async () => {
const res = await handleRequest({
url: "http://t/example/invoices",
method: "POST",
headers: [["content-type", "application/json"]],
bodyBase64: encode("{not-json"),
principalId: null,
tenantId: null,
capabilities: ADMIN,
requestId: "r",
timestamp: 0,
});
expect(res.status).toBe(400);
});
it("invoice id route with trailing slash is not matched", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "GET",
path: "/example/invoices/abc/def",
capabilities: ADMIN,
})
);
expect(res.status).toBe(404);
});
it("finalise route with empty id is not matched", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "POST",
path: "/example/invoices//finalise",
capabilities: ADMIN,
})
);
expect(res.status).toBe(404);
});
it("invoices empty id slot is not matched", {
timeout: 5000,
}, async () => {
const res = await handleRequest(
req({
method: "GET",
path: "/example/invoices/",
capabilities: ADMIN,
})
);
// The trailing slash makes /example/invoices/ ≠ /example/invoices
// so this returns 404 (no match in the GET branch).
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,201 @@
import type {
SerializedRequest,
SerializedResponse,
} from "@suaveplan/api-types/sandbox-bridge";
import {
buildFailResponsePayload,
createInvoice,
finaliseInvoice,
getInvoiceOrThrow,
type InMemoryStore,
listInvoices,
parseCreateInvoiceInput,
raiseSubscriptionInactive,
} from "../domain/index.js";
import { getStore } from "./wasm-state.js";
/**
* Routing + handler entry point for the wasm-tier surface. Receives a
* `SerializedRequest` (the WASI-stdio envelope payload), matches against
* the static route table by method + URL pathname, and dispatches to a
* per-route handler that delegates every business decision to
* `src/domain/invoice-rules.ts`.
*
* `SuaveplanError`-shaped throws are NOT caught here — the ABI shim
* in `wasm-compiler-javy` wraps `handleRequest` and emits an
* `{ kind: "error" }` envelope when a thrown `SuaveplanError` reaches
* the boundary.
*/
export async function handleRequest(
req: SerializedRequest
): Promise<SerializedResponse> {
const url = new URL(req.url);
const pathname = url.pathname;
const store = getStore();
const result = await dispatch({
method: req.method.toUpperCase(),
pathname,
search: url.searchParams,
body: decodeBody(req.bodyBase64),
capabilities: req.capabilities,
store,
});
return result;
}
interface DispatchCtx {
readonly method: string;
readonly pathname: string;
readonly search: URLSearchParams;
readonly body: Uint8Array;
readonly capabilities: readonly string[];
readonly store: InMemoryStore;
}
async function dispatch(ctx: DispatchCtx): Promise<SerializedResponse> {
if (ctx.method === "GET" && ctx.pathname === "/example/public/ping") {
return jsonResponse(200, {
ok: true,
pluginName: "@suaveplan/api-plugin-example",
});
}
if (ctx.method === "GET" && ctx.pathname === "/example/health") {
return jsonResponse(200, { ok: true });
}
if (ctx.method === "GET" && ctx.pathname === "/example/invoices") {
const deny = rbacCheck(ctx.capabilities, "example:read");
if (deny !== null) return deny;
return jsonResponse(200, { invoices: listInvoices(ctx.store) });
}
if (ctx.method === "GET") {
const id = matchInvoiceId(ctx.pathname);
if (id !== null) {
const deny = rbacCheck(ctx.capabilities, "example:read");
if (deny !== null) return deny;
return jsonResponse(200, getInvoiceOrThrow(ctx.store, id));
}
}
if (ctx.method === "POST" && ctx.pathname === "/example/invoices") {
const deny = rbacCheck(ctx.capabilities, "example:write");
if (deny !== null) return deny;
const parsedBody = parseRequestBody(ctx.body);
const parsed = parseCreateInvoiceInput(parsedBody);
if (!parsed.success) {
return jsonResponse(400, {
code: "VALIDATION_FAILED",
issues: parsed.issues,
});
}
const created = createInvoice(ctx.store, parsed.data);
return jsonResponse(200, created);
}
if (ctx.method === "POST") {
const finaliseId = matchFinaliseId(ctx.pathname);
if (finaliseId !== null) {
const deny = rbacCheck(ctx.capabilities, "example:write");
if (deny !== null) return deny;
// The trusted tier awaits a Promise here to keep its
// Stripe-call histogram observation > 0 ms; the wasm tier
// has no histogram to record so the await is a no-op.
await Promise.resolve();
const updated = finaliseInvoice(ctx.store, finaliseId);
return jsonResponse(200, updated);
}
}
if (ctx.method === "GET" && ctx.pathname === "/example/rate-limited") {
return jsonResponse(200, { ok: true });
}
if (
ctx.method === "GET" &&
ctx.pathname === "/example/subscription-required"
) {
return raiseSubscriptionInactive();
}
if (ctx.method === "GET" && ctx.pathname === "/_/test/fail-response") {
const fail = ctx.search.get("failResponse") === "1";
return jsonResponse(200, buildFailResponsePayload(fail));
}
return jsonResponse(404, { code: "ROUTE_NOT_FOUND", path: ctx.pathname });
}
function matchInvoiceId(pathname: string): string | null {
const prefix = "/example/invoices/";
if (!pathname.startsWith(prefix)) return null;
const tail = pathname.slice(prefix.length);
if (tail.length === 0 || tail.includes("/")) return null;
return tail;
}
function matchFinaliseId(pathname: string): string | null {
const prefix = "/example/invoices/";
const suffix = "/finalise";
if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) return null;
const id = pathname.slice(prefix.length, pathname.length - suffix.length);
if (id.length === 0 || id.includes("/")) return null;
return id;
}
function rbacCheck(
capabilities: readonly string[],
scope: string
): SerializedResponse | null {
if (capabilities.includes(scope)) return null;
return jsonResponse(403, {
code: "RBAC_MISSING_SCOPE",
requiredScope: scope,
actualScopes: capabilities,
});
}
function jsonResponse(status: number, payload: unknown): SerializedResponse {
const body = JSON.stringify(payload);
return {
status,
headers: [["content-type", "application/json"]],
bodyBase64: encodeBase64Url(textToBytes(body)),
};
}
/** Parse the request body bytes as JSON (or `undefined` when empty/invalid). */
function parseRequestBody(body: Uint8Array): unknown {
if (body.length === 0) return undefined;
const text = bytesToText(body);
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Decode base64-url-no-pad to raw bytes. */
function decodeBody(base64Url: string): Uint8Array {
if (base64Url.length === 0) return new Uint8Array(0);
const padLen = (4 - (base64Url.length % 4)) % 4;
const padded =
base64Url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
/** Encode raw bytes as base64-url-no-pad. */
function encodeBase64Url(bytes: Uint8Array): string {
const codes: number[] = [];
for (const byte of bytes) {
codes.push(byte);
}
const binary = String.fromCharCode(...codes);
const b64 = btoa(binary);
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function textToBytes(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
function bytesToText(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}

View File

@@ -0,0 +1,23 @@
import { dispose } from "./dispose.js";
import { handleRequest } from "./handle-request.js";
import { routes } from "./routes.js";
import { start } from "./start.js";
export { dispose } from "./dispose.js";
export { handleRequest } from "./handle-request.js";
export { routes } from "./routes.js";
export { start } from "./start.js";
export { getStore, resetState } from "./wasm-state.js";
/**
* Wasm-tier plugin facade. The `api-sandbox-wasm-host-bridge` reads
* this constant at bridge-init time, registers `routes` against the
* host router, and binds `handleRequest` as the per-route dispatcher.
* `start`/`dispose` are invoked through the bridge's lifecycle hooks.
*/
export const plugin = {
routes,
handleRequest,
start,
dispose,
} as const;

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,25 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { routes } from "./routes.js";
describe("wasm routes", () => {
it("wasm routes omit streaming endpoints", { timeout: 5000 }, () => {
for (const r of routes) {
expect(r.path.includes("/stream/")).toBe(false);
}
});
it("declares the nine non-streaming endpoints", { timeout: 5000 }, () => {
const paths = routes.map((r) => `${r.method} ${r.path}`);
expect(paths).toEqual([
"GET /example/public/ping",
"GET /example/health",
"GET /example/invoices",
"GET /example/invoices/:id",
"POST /example/invoices",
"POST /example/invoices/:id/finalise",
"GET /example/rate-limited",
"GET /example/subscription-required",
"GET /_/test/fail-response",
]);
});
});

View File

@@ -0,0 +1,22 @@
import type { SerializedRoute } from "@suaveplan/api-types/sandbox-bridge";
/**
* Static route list the wasm-tier surfaces to the host bridge. The
* `streamLargeReport` endpoint is deliberately omitted — chunked
* `ReadableStream` responses cannot be expressed under the synchronous
* WASI-stdio `(SerializedRequest) => SerializedResponse` contract.
*
* The order matches the trusted-tier route table so iteration-order
* sensitive consumers (e.g. OpenAPI emit) see identical sequences.
*/
export const routes: readonly SerializedRoute[] = [
{ method: "GET", path: "/example/public/ping" },
{ method: "GET", path: "/example/health" },
{ method: "GET", path: "/example/invoices" },
{ method: "GET", path: "/example/invoices/:id" },
{ method: "POST", path: "/example/invoices" },
{ method: "POST", path: "/example/invoices/:id/finalise" },
{ method: "GET", path: "/example/rate-limited" },
{ method: "GET", path: "/example/subscription-required" },
{ method: "GET", path: "/_/test/fail-response" },
] as const;

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,57 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { dispose } from "./dispose.js";
import { handleRequest } from "./handle-request.js";
import { start } from "./start.js";
import { resetState } from "./wasm-state.js";
beforeEach(() => {
resetState();
});
function emptyReq(url: string): Parameters<typeof handleRequest>[0] {
return {
url,
method: "GET",
headers: [],
bodyBase64: "",
principalId: null,
tenantId: null,
capabilities: ["example:read", "example:write"],
requestId: "r",
timestamp: 0,
};
}
function decode(b64url: string): string {
const padLen = (4 - (b64url.length % 4)) % 4;
const padded =
b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
return atob(padded);
}
describe("wasm/start", () => {
it("start is idempotent", { timeout: 5000 }, () => {
start();
start();
expect(true).toBe(true);
});
it("start re-primes after dispose", { timeout: 5000 }, async () => {
start();
const res = await handleRequest(emptyReq("http://t/example/invoices"));
expect(res.status).toBe(200);
const body = JSON.parse(decode(res.bodyBase64)) as {
invoices: readonly unknown[];
};
expect(body.invoices).toEqual([]);
dispose();
start();
const after = await handleRequest(
emptyReq("http://t/example/invoices")
);
const body2 = JSON.parse(decode(after.bodyBase64)) as {
invoices: readonly unknown[];
};
expect(body2.invoices).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
import { getStore } from "./wasm-state.js";
/**
* 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 has no production-grade seed data — `install`
* is a no-op in the trusted tier — so `start()` here is also a no-op
* beyond the idempotency guarantee. Future plugins forked from this
* one MAY add concrete seed records here.
*/
export function start(): void {
// Touch the store so the singleton is materialised before the
// first request lands. The store is created at module init so
// this is a redundant read — kept for symmetry with the
// trusted-tier `install` hook which performs the same touch.
getStore();
}

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.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getStore, resetState } from "./wasm-state.js";
describe("wasm-state", () => {
it("getStore returns a usable store", { timeout: 5000 }, () => {
resetState();
const store = getStore();
expect(store.list()).toEqual([]);
expect(store.nextId()).toBe("inv-1");
});
it("resetState swaps in a fresh empty store", { timeout: 5000 }, () => {
resetState();
getStore().create({
id: "x",
status: "open",
amountCents: 1,
currency: "USD",
});
expect(getStore().list()).toHaveLength(1);
resetState();
expect(getStore().list()).toEqual([]);
});
});

View File

@@ -0,0 +1,23 @@
import { createInMemoryStore, type InMemoryStore } from "../domain/index.js";
/**
* Module-singleton `InMemoryStore` for the wasm-tier surface. The WASI-stdio
* `(SerializedRequest) => SerializedResponse` contract carries no
* per-request context the host could thread a store reference through,
* so the wasm tier holds one store at module scope.
*
* `resetState()` swaps the singleton for a fresh empty store; the
* shape-conformance test calls this before every comparison so each
* iteration starts with `inv-1` as the next allocated id.
*/
let store: InMemoryStore = createInMemoryStore();
/** Read the current module-scoped store. */
export function getStore(): InMemoryStore {
return store;
}
/** Replace the module-scoped store with a fresh empty instance. */
export function resetState(): void {
store = createInMemoryStore();
}

View File

@@ -0,0 +1,290 @@
import { createTestHost } from "@suaveplan/api-host/testing";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
buildRoutes,
createInMemoryLedger,
EXAMPLE_CODES,
EXAMPLE_INVOICE_CREATED_TOPIC,
examplePluginModule,
USERS_USER_DELETED_TOPIC,
} from "../../src/index.js";
import { flushTestMetrics } from "../setup/polyfills.js";
const PRINCIPAL: Principal = {
id: "p-int",
scopes: ["example:read", "example:write"] as unknown as Principal["scopes"],
claims: {},
};
const TENANT_ID = "t-int" as TenantId;
let host: Awaited<ReturnType<typeof createTestHost>>;
let ledger: ReturnType<typeof createInMemoryLedger>;
beforeEach(async () => {
ledger = createInMemoryLedger();
host = await createTestHost({
seed: {
principal: PRINCIPAL,
tenants: [
{
id: TENANT_ID,
slug: "tenant-int",
status: "active",
createdAt: new Date("2026-01-01T00:00:00Z").toISOString(),
},
],
secrets: {
EXAMPLE_STRIPE_KEY: "sk_test_int",
EXAMPLE_HMAC_SIGNING_KEY: "hmac_test_int",
EXAMPLE_WEBHOOK_TOKEN: "webhook_test_int",
},
},
});
await host.installFixturePlugin({
spec: examplePluginModule,
routes: buildRoutes({
ledger,
publisher: {
publish(topic, payload) {
host.adapters.eventBus.publish(topic, payload);
},
},
}),
});
});
afterEach(async () => {
await host.dispose();
});
const URL_PREFIX = "http://test.local";
describe("api-plugin-example — full integration flow", () => {
it("plugin boots and serves /example/health", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/health`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("REST surface — public bypasses auth", { timeout: 30000 }, async () => {
const res = await host.fetchAs(
null,
new Request(`${URL_PREFIX}/example/public/ping`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("REST surface — RBAC denies missing scope", {
timeout: 30000,
}, async () => {
const noScope: Principal = {
id: "z",
scopes: [] as unknown as Principal["scopes"],
claims: {},
};
const res = await host.fetchAs(
noScope,
new Request(`${URL_PREFIX}/example/invoices`)
);
expect(res.status).toBe(403);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("RBAC_MISSING_SCOPE");
});
it("REST surface — Zod rejects malformed body", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ customerId: "c-1" }),
})
);
expect(res.status).toBe(400);
});
it("REST surface — happy path persists + emits event + increments metric", {
timeout: 30000,
}, async () => {
const eventWait = host.waitForEvent(
EXAMPLE_INVOICE_CREATED_TOPIC,
5000
);
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c-1",
amountCents: 1500,
currency: "USD",
}),
})
);
expect(res.status).toBe(200);
const created = (await res.json()) as { id: string; status: string };
expect(created.status).toBe("open");
const event = (await eventWait) as { id: string; amountCents: number };
expect(event.id).toBe(created.id);
expect(event.amountCents).toBe(1500);
});
it("REST surface — finalise transitions to paid + records OTel metrics", {
timeout: 30000,
}, async () => {
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c-1",
amountCents: 1,
currency: "USD",
}),
})
);
const finalise = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
expect(finalise.status).toBe(200);
const out = (await finalise.json()) as { status: string };
expect(out.status).toBe("paid");
const metricsCollected = await flushTestMetrics();
const allMetrics = metricsCollected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics)
);
const names = new Set(allMetrics.map((m) => m.descriptor.name));
expect(names.has("example.invoices_finalised_total")).toBe(true);
expect(names.has("example.stripe.call_duration_ms")).toBe(true);
expect(names.has("example.outstanding_invoice_count")).toBe(true);
});
it("REST surface — streaming delivers chunked NDJSON", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/stream/large-report`)
);
expect(res.status).toBe(200);
const text = await res.text();
const lines = text.trim().split("\n");
expect(lines.length).toBe(3);
});
it("custom error code surfaces with manifest-mapped status", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/subscription-required`)
);
expect(res.status).toBe(EXAMPLE_CODES.SUBSCRIPTION_INACTIVE ? 409 : 0);
expect(res.status).toBe(409);
});
it("INVOICE_NOT_FOUND surfaces 404", { timeout: 30000 }, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/missing`)
);
expect(res.status).toBe(404);
});
it("INVALID_INVOICE_STATE surfaces 422 on double-finalise", {
timeout: 30000,
}, async () => {
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
})
);
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
expect(res.status).toBe(422);
});
it("subscriber handler runs on incoming users.user.deleted", {
timeout: 30000,
}, async () => {
const sink = { seen: new Set<string>() };
host.adapters.eventBus.on(USERS_USER_DELETED_TOPIC, (raw) => {
const p = raw as { userId: string };
sink.seen.add(p.userId);
});
host.adapters.eventBus.publish(USERS_USER_DELETED_TOPIC, {
userId: "u-1",
tenantId: null,
});
await host.waitForEvent(USERS_USER_DELETED_TOPIC, 5000);
expect(sink.seen.has("u-1")).toBe(true);
});
it("every declared secret reads successfully", {
timeout: 30000,
}, async () => {
const secrets = host.adapters.host.secrets;
const a = await secrets.require("EXAMPLE_STRIPE_KEY");
const b = await secrets.require("EXAMPLE_HMAC_SIGNING_KEY");
const c = await secrets.require("EXAMPLE_WEBHOOK_TOKEN");
expect(a).toBe("sk_test_int");
expect(b).toBe("hmac_test_int");
expect(c).toBe("webhook_test_int");
});
it("response-validation route returns violating payload with ?failResponse=1", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/_/test/fail-response?failResponse=1`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
// Intentionally violates the schema — exercises the warn-mode
// path. Enforce mode would require host-side responseValidation
// wiring which is not yet present in the test-host.
expect(body.status).toBe("exploded");
});
it("response-validation route returns valid payload without the flag", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/_/test/fail-response`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("open");
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-example.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. The
* integration tests then call `flushTestMetrics()` to read the emitted
* datapoints and assert against the manifest-declared instruments.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,126 @@
/**
* Root-export shape snapshot. Pins the canonical exports of
* `@suaveplan/api-plugin-example` so the dual-shape refactor cannot
* accidentally drop or rename a public surface.
*
* Uses TypeScript `typeof` + a structural `Equal<>` helper rather than
* a runtime structural diff so the assertion fires at typecheck time —
* any drift in the exported type's signature breaks the build before
* the test even runs.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as RootBarrel from "../../src/index.js";
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
// Pin the canonical exports — assigning to a `true` constant means the
// build fails if the structural compatibility breaks.
type _Pin_EXAMPLE_CODES = Equal<
typeof RootBarrel.EXAMPLE_CODES,
{
readonly SUBSCRIPTION_INACTIVE: "PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE";
readonly INVOICE_NOT_FOUND: "PLUGIN.EXAMPLE.INVOICE_NOT_FOUND";
readonly INVALID_INVOICE_STATE: "PLUGIN.EXAMPLE.INVALID_INVOICE_STATE";
readonly RESPONSE_VALIDATION_TRIGGER: "PLUGIN.EXAMPLE.RESPONSE_VALIDATION_TRIGGER";
}
>;
const pinExampleCodes: _Pin_EXAMPLE_CODES = true;
type _Pin_examplePluginModule_name = Equal<
typeof RootBarrel.examplePluginModule.name,
"@suaveplan/api-plugin-example"
>;
const pinPluginName: _Pin_examplePluginModule_name = true;
type _Pin_buildRoutes = Equal<
Parameters<typeof RootBarrel.buildRoutes>[0]["ledger"]["nextId"],
() => string
>;
const pinBuildRoutes: _Pin_buildRoutes = true;
describe("root exports preserve existing types and names", () => {
it("exports the canonical EXAMPLE_CODES constants", {
timeout: 5000,
}, () => {
expect(RootBarrel.EXAMPLE_CODES.INVOICE_NOT_FOUND).toBe(
"PLUGIN.EXAMPLE.INVOICE_NOT_FOUND"
);
expect(RootBarrel.EXAMPLE_CODES.SUBSCRIPTION_INACTIVE).toBe(
"PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE"
);
expect(RootBarrel.EXAMPLE_CODES.INVALID_INVOICE_STATE).toBe(
"PLUGIN.EXAMPLE.INVALID_INVOICE_STATE"
);
expect(RootBarrel.EXAMPLE_CODES.RESPONSE_VALIDATION_TRIGGER).toBe(
"PLUGIN.EXAMPLE.RESPONSE_VALIDATION_TRIGGER"
);
});
it("exports ExamplePluginError as a public-constructor class", {
timeout: 5000,
}, () => {
const e = new RootBarrel.ExamplePluginError(
RootBarrel.EXAMPLE_CODES.INVOICE_NOT_FOUND,
"msg"
);
expect(e.code).toBe("PLUGIN.EXAMPLE.INVOICE_NOT_FOUND");
});
it("exports examplePluginModule with the canonical name + version", {
timeout: 5000,
}, () => {
expect(RootBarrel.examplePluginModule.name).toBe(
"@suaveplan/api-plugin-example"
);
expect(RootBarrel.examplePluginModule.version).toBe("0.1.0");
});
it("exports buildRoutes + createInMemoryLedger", {
timeout: 5000,
}, () => {
const ledger = RootBarrel.createInMemoryLedger();
const routes = RootBarrel.buildRoutes({
ledger,
publisher: { publish() {} },
});
expect(routes.length).toBeGreaterThan(0);
expect(typeof ledger.nextId).toBe("function");
});
it("exports the canonical event topics and payload schemas", {
timeout: 5000,
}, () => {
expect(RootBarrel.EXAMPLE_INVOICE_CREATED_TOPIC).toBe(
"example.invoice.created"
);
expect(RootBarrel.USERS_USER_DELETED_TOPIC).toBe("users.user.deleted");
expect(RootBarrel.ExampleInvoiceCreatedPayload).toBeDefined();
expect(RootBarrel.UsersUserDeletedPayload).toBeDefined();
});
it("exports the request/response Zod schemas", { timeout: 5000 }, () => {
expect(RootBarrel.CreateInvoiceRequest).toBeDefined();
expect(RootBarrel.CreateInvoiceResponse).toBeDefined();
expect(RootBarrel.InvoiceResponse).toBeDefined();
expect(RootBarrel.PingResponse).toBeDefined();
expect(RootBarrel.StreamChunk).toBeDefined();
});
it("exports instrument helpers", { timeout: 5000 }, () => {
expect(typeof RootBarrel.getExampleInstruments).toBe("function");
expect(typeof RootBarrel.resetExampleInstrumentsForTest).toBe(
"function"
);
});
it("type-level pins satisfy structural equality", {
timeout: 5000,
}, () => {
expect(pinExampleCodes).toBe(true);
expect(pinPluginName).toBe(true);
expect(pinBuildRoutes).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
/**
* Browser-bundle gate. Confirms that the `./wasm` subpath entry point
* compiles cleanly under `Bun.build({ target: "browser", format: "esm" })`
* AND that the produced bundle does not pull in any `node:*` specifier,
* Hono, or OTel-SDK dependency.
*
* The lint script `scripts/lint/check-wasm-subpath-deps.ts` is the
* static-analysis counterpart; this test is the runtime confirmation
* via the actual Bun bundler.
*/
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("wasm subpath bundles for browser target", () => {
it("Bun.build emits a valid ESM bundle with no banned specifiers", {
timeout: 30000,
}, async () => {
const entry = resolve(__dirname, "..", "..", "src", "wasm", "index.ts");
const result = await Bun.build({
entrypoints: [entry],
target: "browser",
format: "esm",
external: [
"@suaveplan/error",
"@suaveplan/api-types",
"@opentelemetry/api",
"zod",
"tslib",
],
});
if (!result.success) {
const log = result.logs.map((l) => String(l)).join("\n");
throw new Error(`Bun.build failed: ${log}`);
}
expect(result.success).toBe(true);
expect(result.outputs.length).toBeGreaterThan(0);
const text = await result.outputs[0]!.text();
// No node:* specifier in the produced bundle.
expect(text.includes("node:")).toBe(false);
// No Hono import in the produced bundle.
expect(/from\s+["']hono["']/.test(text)).toBe(false);
expect(/from\s+["']@hono\//.test(text)).toBe(false);
// No OTel SDK in the produced bundle.
expect(/from\s+["']@opentelemetry\/sdk-/.test(text)).toBe(false);
// No api-host import.
expect(text.includes("@suaveplan/api-host")).toBe(false);
});
});

View File

@@ -0,0 +1,325 @@
/**
* Trusted vs wasm parity test. For every overlapping route, dispatches
* the same logical request through:
* 1. The trusted-tier handler in `src/trusted/routes/routes.ts`
* 2. The wasm-tier `plugin.handleRequest` in `src/wasm/index.ts`
* and asserts the responses have the same HTTP status and JSON body.
*
* The streaming endpoint `/example/stream/large-report` is skipped with
* an explanatory `it.skip` — chunked `ReadableStream` responses cannot
* be expressed under the WASI-stdio contract.
*
* Both surfaces consume the SAME `domain/invoice-rules.ts` module, so
* the parity contract reduces to "both adapters serialise the same
* domain output identically".
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { buildRoutes, createInMemoryLedger } from "../../src/trusted/index.js";
import { plugin } from "../../src/wasm/index.js";
import { resetState } from "../../src/wasm/wasm-state.js";
interface ParityInput {
readonly name: string;
readonly method: string;
readonly path: string;
readonly scopes: readonly string[];
readonly body?: unknown;
}
const ADMIN_SCOPES = ["example:read", "example:write"] as const;
const URL_BASE = "http://t";
function encodeBase64Url(text: string): string {
let binary = "";
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] ?? 0);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function decodeBase64Url(b64url: string): string {
if (b64url.length === 0) return "";
const padLen = (4 - (b64url.length % 4)) % 4;
const padded =
b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
return atob(padded);
}
interface TrustedTable {
readonly table: ReturnType<typeof buildRoutes>;
}
function freshTrusted(): TrustedTable {
const ledger = createInMemoryLedger();
const table = buildRoutes({
ledger,
publisher: {
publish() {
// Publishing is a trusted-tier-only concern; the wasm tier
// does not emit events. The parity test asserts response
// equality, not side effects, so we discard.
},
},
});
return { table };
}
interface NormalisedResponse {
readonly status: number;
readonly bodyText: string;
}
async function runTrusted(
table: TrustedTable["table"],
input: ParityInput
): Promise<NormalisedResponse> {
const method = input.method;
const fullPath = input.path;
// Look up via path pattern: trusted uses `:id` segments.
const lookupPath = toPattern(method, fullPath);
const route = table.find(
(r) => r.method === method && r.path === lookupPath
);
if (route === undefined) {
throw new Error(`trusted route not found: ${method} ${lookupPath}`);
}
const params = extractParams(lookupPath, fullPath);
const principal = {
id: "p",
scopes: input.scopes as unknown as ReadonlyArray<
"example:read" | "example:write"
>,
claims: {},
};
const url = new URL(`${URL_BASE}${fullPath}`);
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
const ctxBase = {
principal,
tenant: null,
request: new Request(`${URL_BASE}${fullPath}`, {
method,
headers:
input.body !== undefined
? { "content-type": "application/json" }
: {},
body:
input.body !== undefined
? JSON.stringify(input.body)
: undefined,
}),
body: input.body,
query,
params,
};
const out = await route.handler(ctxBase);
if (out instanceof Response) {
return {
status: out.status,
bodyText: await out.text(),
};
}
return { status: 200, bodyText: JSON.stringify(out) };
}
async function runWasm(input: ParityInput): Promise<NormalisedResponse> {
const res = await plugin.handleRequest({
url: `${URL_BASE}${input.path}`,
method: input.method,
headers:
input.body !== undefined
? [["content-type", "application/json"]]
: [],
bodyBase64:
input.body !== undefined
? encodeBase64Url(JSON.stringify(input.body))
: "",
principalId: null,
tenantId: null,
capabilities: input.scopes,
requestId: "r",
timestamp: 0,
});
return { status: res.status, bodyText: decodeBase64Url(res.bodyBase64) };
}
function toPattern(method: string, path: string): string {
// /example/invoices/inv-1 -> /example/invoices/:id
if (path.startsWith("/example/invoices/") && path !== "/example/invoices") {
const tail = path.slice("/example/invoices/".length);
if (tail.endsWith("/finalise")) {
return "/example/invoices/:id/finalise";
}
if (!tail.includes("/")) return "/example/invoices/:id";
}
// Strip query string for matching.
const q = path.indexOf("?");
if (q !== -1) return path.slice(0, q);
return path;
}
function extractParams(
pattern: string,
actual: string
): Record<string, string> {
const patternParts = pattern.split("/");
const actualPath = actual.split("?")[0] ?? actual;
const actualParts = actualPath.split("/");
const params: Record<string, string> = {};
for (let i = 0; i < patternParts.length; i += 1) {
const p = patternParts[i];
if (p?.startsWith(":")) {
params[p.slice(1)] = actualParts[i] ?? "";
}
}
return params;
}
beforeEach(() => {
resetState();
});
afterEach(() => {
resetState();
});
const SHARED_BODIES: readonly ParityInput[] = [
{
name: "public ping",
method: "GET",
path: "/example/public/ping",
scopes: [],
},
{
name: "health",
method: "GET",
path: "/example/health",
scopes: [],
},
{
name: "rate-limited",
method: "GET",
path: "/example/rate-limited",
scopes: [],
},
{
name: "list invoices (no scope -> 403)",
method: "GET",
path: "/example/invoices",
scopes: [],
},
{
name: "list invoices (with scope -> 200)",
method: "GET",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
},
{
name: "createInvoice (bad body -> 400)",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: { customerId: "" },
},
{
name: "createInvoice (no scope -> 403)",
method: "POST",
path: "/example/invoices",
scopes: [],
body: { customerId: "c", amountCents: 1, currency: "USD" },
},
{
name: "fail-response default",
method: "GET",
path: "/_/test/fail-response",
scopes: [],
},
{
name: "fail-response with flag",
method: "GET",
path: "/_/test/fail-response?failResponse=1",
scopes: [],
},
];
describe("trusted vs wasm parity for every shared route", () => {
for (const input of SHARED_BODIES) {
it(input.name, { timeout: 5000 }, async () => {
resetState();
const trustedHandle = freshTrusted();
const [t, w] = await Promise.all([
runTrusted(trustedHandle.table, input),
runWasm(input),
]);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
}
it("createInvoice happy path body matches across surfaces", {
timeout: 5000,
}, async () => {
resetState();
const trustedHandle = freshTrusted();
const input: ParityInput = {
name: "createInvoice",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: { customerId: "c", amountCents: 1500, currency: "USD" },
};
const t = await runTrusted(trustedHandle.table, input);
const w = await runWasm(input);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
it("getInvoice happy path body matches", { timeout: 5000 }, async () => {
resetState();
const trustedHandle = freshTrusted();
const createBody = {
customerId: "c",
amountCents: 1,
currency: "USD",
};
const create: ParityInput = {
name: "create",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: createBody,
};
await runTrusted(trustedHandle.table, create);
await runWasm(create);
const get: ParityInput = {
name: "get",
method: "GET",
path: "/example/invoices/inv-1",
scopes: ADMIN_SCOPES,
};
const t = await runTrusted(trustedHandle.table, get);
const w = await runWasm(get);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
it.skip("streamLargeReport is skipped — streaming responses are out of scope for the wasm tier", {
timeout: 5000,
}, () => {
// Documented omission: the wasm subpath omits this endpoint
// because Javy/QuickJS lacks ReadableStream and the
// WASI-stdio envelope has no chunked-response shape.
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-example",
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\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
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/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 60000,
},
});