Initial Commit
This commit is contained in:
105
packages/api-stack/api/api-plugin-example/README.md
Normal file
105
packages/api-stack/api/api-plugin-example/README.md
Normal 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.
|
||||
|
||||
111
packages/api-stack/api/api-plugin-example/package.json
Normal file
111
packages/api-stack/api/api-plugin-example/package.json
Normal 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"]
|
||||
}
|
||||
110
packages/api-stack/api/api-plugin-example/plugin.json
Normal file
110
packages/api-stack/api/api-plugin-example/plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { ExamplePluginCode } from "./error-codes.js";
|
||||
export {
|
||||
EXAMPLE_CODES,
|
||||
EXAMPLE_STATUS_MAP,
|
||||
ExamplePluginError,
|
||||
} from "./error-codes.js";
|
||||
47
packages/api-stack/api/api-plugin-example/src/index.ts
Normal file
47
packages/api-stack/api/api-plugin-example/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ExampleInstruments } from "./instruments.js";
|
||||
export {
|
||||
getExampleInstruments,
|
||||
resetExampleInstrumentsForTest,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
CreateInvoiceRequestT,
|
||||
CreateInvoiceResponseT,
|
||||
InvoiceResponseT,
|
||||
PingResponseT,
|
||||
StreamChunkT,
|
||||
} from "./schemas.js";
|
||||
export {
|
||||
CreateInvoiceRequest,
|
||||
CreateInvoiceResponse,
|
||||
InvoiceResponse,
|
||||
PingResponse,
|
||||
StreamChunk,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export { examplePluginModule } from "./plugin.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BuildRoutesArgs,
|
||||
ExampleRoute,
|
||||
InvoiceLedger,
|
||||
RoutePublisher,
|
||||
} from "./routes.js";
|
||||
export { buildRoutes, createInMemoryLedger } from "./routes.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
23
packages/api-stack/api/api-plugin-example/src/wasm/index.ts
Normal file
23
packages/api-stack/api/api-plugin-example/src/wasm/index.ts
Normal 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;
|
||||
51
packages/api-stack/api/api-plugin-example/src/wasm/routes.md
Normal file
51
packages/api-stack/api/api-plugin-example/src/wasm/routes.md
Normal 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`.
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
22
packages/api-stack/api/api-plugin-example/src/wasm/routes.ts
Normal file
22
packages/api-stack/api/api-plugin-example/src/wasm/routes.ts
Normal 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;
|
||||
52
packages/api-stack/api/api-plugin-example/src/wasm/start.md
Normal file
52
packages/api-stack/api/api-plugin-example/src/wasm/start.md
Normal 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(...)`.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
19
packages/api-stack/api/api-plugin-example/src/wasm/start.ts
Normal file
19
packages/api-stack/api/api-plugin-example/src/wasm/start.ts
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-plugin-example/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-example/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-example/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-example/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-plugin-example/vite.config.ts
Normal file
30
packages/api-stack/api/api-plugin-example/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-example/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-example/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user