1494 lines
91 KiB
Markdown
1494 lines
91 KiB
Markdown
# API Plugin Foundation — Design
|
||
|
||
**Status:** Draft for review
|
||
**Date:** 2026-05-13
|
||
**Owner:** Suaveplan core
|
||
**Scope:** Foundation packages under `packages/api/` for a plug-and-play, multi-tenant, hybrid manifest+code plugin system.
|
||
|
||
## 1. Goal
|
||
|
||
Deliver the foundation layer that lets any future API capability (billing, users, search, files, …) ship as an independently installable, hot-reloadable, versioned plugin against a stable host contract.
|
||
|
||
Foundational decisions:
|
||
|
||
| Decision | Choice |
|
||
|---|---|
|
||
| Tenant model | Multi-tenant with row-level isolation |
|
||
| Plugin shape | Hybrid: declarative manifest (`plugin.json`) + code (`definePlugin({...})`) |
|
||
| Database requirement | Optional per plugin |
|
||
| Hot reload | Blue-green by default (zero-downtime); 503-window mode opt-in for deployments that can't host two concurrent module instances |
|
||
| Reload safety | Automatic rollback to last-known-good module on failed `start`; `RollbackJournal` retains the prior 3 known-good specifiers + manifests + migration ledger snapshots per plugin |
|
||
| Versioning | Host semver + capability flags (granular feature opt-in) |
|
||
| Plugin packaging | Workspace + npm-resolvable, same loader contract |
|
||
| HTTP framework binding | Framework-agnostic abstract router with adapter packages |
|
||
| Database access shape | Engine-neutral by default, engine-specific escape via capability check |
|
||
| Tenant ID source | Pluggable resolver chain; JWT-claim resolver default |
|
||
| Isolation tiers | Three first-class tiers: `trusted` (same process), `isolated` (worker thread per plugin, full capability marshalling), `sandboxed` (worker + permission-gated host proxies, optional WASM target). Trust level declared in manifest, gated by host config. |
|
||
| Distributed transactions | Two engines ship in foundation: `api-saga` (orchestrator, event-bus-backed, compensable steps) and `api-2pc` (two-phase-commit coordinator for engines that support PREPARE TRANSACTION) |
|
||
| GraphQL | First-class engine alongside REST. Plugins can mount GraphQL types/resolvers; host federates schemas across plugins; subscriptions over WebSocket |
|
||
| Persistent connections | First-class WebSocket and SSE engines with subscription bus, tenant-scoped fan-out, multi-host broadcast (Redis adapter), reconnect tokens |
|
||
| Auth ecosystem | Foundation ships `api-auth-jwt`, `api-auth-anonymous`, `api-auth-bearer-static`, and `api-auth-better-auth` together so the auth contracts are exercised against a real third-party library from day one |
|
||
| Package strategy | Layered foundation packages + reference plugin + scaffold |
|
||
|
||
The spec is designed to land as a single coherent foundation. Items are sequenced into eight implementation stages (Section 15) to keep the dependency graph linear and the testing surface manageable, but every stage is part of the v1 design contract — not a "v2 maybe."
|
||
|
||
## 2. Architecture
|
||
|
||
### 2.1 Package family (`packages/api/`)
|
||
|
||
Layered, downward dep direction. Nothing below depends on `api-host`.
|
||
|
||
```
|
||
# ── Contract / core packages ──────────────────────────────────────────────
|
||
api-types # Contract types only. Zero runtime, zero deps.
|
||
api-manifest # Zod schema + validate + dep/capability resolution. → api-types, error, zod
|
||
api-router # Abstract Router/Middleware/Request/Response + composition + blue-green slot mgmt. → api-types, error
|
||
api-loader # Resolve plugin module → import() → reload + RollbackJournal. → api-manifest, error, events
|
||
api-lifecycle # Lifecycle FSM (incl. crashed + rolled_back states). → fsm, error, events
|
||
api-host # Thin orchestration that wires loader+lifecycle+router+manifest. → all above, logger, opentelemetry
|
||
|
||
# ── Worker isolation & sandboxing ─────────────────────────────────────────
|
||
api-worker-rpc # Message protocol over MessagePort. Request/response correlation,
|
||
# AbortSignal forwarding, backpressure, error reconstruction.
|
||
api-worker-host # Worker-side runtime; hosts a single plugin, accepts capability proxies.
|
||
api-router-worker-bridge # Marshals HTTP requests/responses across the worker boundary.
|
||
api-db-worker-proxy # Worker-side proxy → main-process DB adapter.
|
||
api-events-worker-proxy # Worker-side proxy → main-process EventBus.
|
||
api-secrets-worker-proxy # Worker-side proxy → main-process secrets adapter.
|
||
api-jobs-worker-proxy # Worker-side proxy → main-process job scheduler.
|
||
api-logger-worker-proxy # Worker-side proxy → main-process logger.
|
||
api-sandbox-permissions # Permission model: fs / fetch host allowlist, CPU/memory/wall-clock budgets.
|
||
api-sandbox-vm # vm.Context-based sandbox for non-WASM trusted-but-isolated plugins.
|
||
api-sandbox-wasm # WASM target host; runs plugins compiled to WASI with narrow capability set.
|
||
|
||
# ── Router engine adapters ────────────────────────────────────────────────
|
||
api-router-hono # Hono adapter. → api-router, hono peer
|
||
api-router-in-memory # In-memory adapter for tests. → api-router
|
||
|
||
# ── GraphQL engine ────────────────────────────────────────────────────────
|
||
api-graphql # Abstract GraphQL contract: schema federation across plugins,
|
||
# resolver registry, DataLoader integration, RequestContext bridge,
|
||
# subscription dispatch. → api-types, api-router (for HTTP transport)
|
||
api-graphql-yoga # GraphQL Yoga engine adapter (default).
|
||
api-graphql-apollo # Apollo engine adapter (alternative).
|
||
api-graphql-ui-graphiql # GraphiQL UI for /graphql/ui (default).
|
||
api-graphql-ui-altair # Altair UI (alternative).
|
||
|
||
# ── WebSocket / SSE engines ───────────────────────────────────────────────
|
||
api-ws # Abstract WebSocket engine: connection registry, subscription bus,
|
||
# tenant-scoped fan-out, ping/pong heartbeats, reconnect tokens.
|
||
api-ws-bun # Bun.serve WebSocket adapter (default).
|
||
api-ws-broker-redis # Cross-host broadcast adapter (Redis pub/sub).
|
||
api-ws-broker-in-memory # In-memory broker (single-host + tests).
|
||
api-sse # Abstract Server-Sent-Events engine + Last-Event-Id resume.
|
||
api-sse-bun # Bun.serve SSE adapter (default).
|
||
|
||
# ── Auth (session resolvers + provider) ──────────────────────────────────
|
||
api-auth # Abstract session-resolver contract: Request → Principal | null.
|
||
api-auth-provider # Abstract auth-provider contract: account lifecycle routes.
|
||
api-auth-jwt # Session resolver via @suaveplan/jws against JWKS or static key.
|
||
api-auth-bearer-static # Session resolver for static service-to-service tokens.
|
||
api-auth-anonymous # Always returns null principal.
|
||
api-auth-better-auth # Both contracts via Better-Auth; owns its own DB connection.
|
||
api-auth-rolled # Reference rolled-our-own provider via genesis crypto/webauthn/otp/srp/hibp.
|
||
|
||
# ── Tenant resolvers ──────────────────────────────────────────────────────
|
||
api-tenant-resolver-jwt # Reads ctx.principal.tenantId.
|
||
api-tenant-resolver-header # Header-based resolver (service-to-service traffic).
|
||
api-tenant-resolver-subdomain # Subdomain resolver.
|
||
api-tenant-provider # Abstract "list tenants this host knows about" — backs perTenant jobs.
|
||
api-tenant-provider-memory # In-memory reference impl + test adapter.
|
||
|
||
# ── Secrets ───────────────────────────────────────────────────────────────
|
||
api-secrets # Abstract contract.
|
||
api-secrets-env # process.env / Bun.env source.
|
||
api-secrets-bun # Bun.secrets (OS keystore).
|
||
api-secrets-vault # @suaveplan/vault source.
|
||
|
||
# ── Database ──────────────────────────────────────────────────────────────
|
||
api-db-adapter # Bridge from @suaveplan/db → plugin DatabaseLike.
|
||
api-db-sqlite-memory # In-memory adapter for tests.
|
||
|
||
# ── Distributed transactions ──────────────────────────────────────────────
|
||
api-saga # Saga orchestrator: manifest-declared workflows, compensable steps,
|
||
# event-bus-backed, persisted state in host metadata DB,
|
||
# survives host restart by replaying state. → events, db-adapter
|
||
api-2pc # Two-phase commit coordinator for engines that support PREPARE TRANSACTION
|
||
# (Postgres). Persists prepared-tx handles in metadata DB. → db-adapter
|
||
|
||
# ── OpenAPI ───────────────────────────────────────────────────────────────
|
||
api-openapi # Emission from manifest + route schemas. Uses @asteasolutions/zod-to-openapi.
|
||
api-openapi-ui-scalar # Default /docs UI.
|
||
api-openapi-ui-swagger # Alternative.
|
||
api-openapi-ui-redoc # Alternative.
|
||
|
||
# ── Cross-cutting middleware ──────────────────────────────────────────────
|
||
api-rbac # Scope + rule-based authorisation. Reuses @suaveplan/rules.
|
||
api-rate-limit # Per-tenant / per-principal / per-IP token bucket.
|
||
api-audit-stdout # Default audit sink (containers redirect anyway).
|
||
api-audit-file # File-rotating audit sink.
|
||
|
||
# ── Authoring helpers ─────────────────────────────────────────────────────
|
||
api-plugin-example # Reference plugin exercising every capability. Load-bearing integration test.
|
||
create-suaveplan-plugin # Scaffold via @suaveplan/installer.
|
||
```
|
||
|
||
The total foundation footprint is ~55 packages. Adapter packages depend only on contracts (`api-types` plus the relevant abstract package). Plugin authors import types from `api-types`, never from `api-host`. Each package stays within the genesis 300-line-per-file constraint via feature-module subdirectories.
|
||
|
||
The event bus is **not** an api-family package: plugins publish/subscribe via the existing `@suaveplan/events` bus (consumed across the monorepo). `api-events-worker-proxy` and `api-events-in-memory` are an adapter and a test double over that bus — not a new contract layer.
|
||
|
||
### 2.2 Runtime topology — three isolation tiers
|
||
|
||
Every plugin runs in one of three tiers. Tier is declared in the manifest (`isolation: "trusted" | "isolated" | "sandboxed"`) and gated by host config (`allowedIsolationTiers`).
|
||
|
||
```
|
||
+----------------------------------------------------------------------------+
|
||
| Host process (api-host) |
|
||
| ┌──────────────────────────────────────────────────────────────────────┐ |
|
||
| │ Loader │ Manifest │ Lifecycle │ CapabilityInjector │ RouterSlotTable │ |
|
||
| └──────────────────────────────────────────────────────────────────────┘ |
|
||
| │ |
|
||
| ├─── injects ─────────────────────┐ |
|
||
| │ │ |
|
||
| ▼ ▼ (via api-worker-rpc MessagePort) |
|
||
| ┌──────────────────────────┐ ┌──────────────────────────┐ |
|
||
| │ Trusted plugin │ │ Worker thread (per plugin)│ |
|
||
| │ (same process) │ │ ┌──────────────────────┐ │ |
|
||
| │ direct capability call │ │ │ Isolated plugin OR │ │ |
|
||
| │ wrapped in err boundary│ │ │ Sandboxed plugin │ │ |
|
||
| │ │ │ │ + vm.Context guard │ │ |
|
||
| │ │ │ │ + WASI permissions │ │ |
|
||
| │ │ │ │ + fs/fetch proxies │ │ |
|
||
| │ │ │ │ + CPU/mem budgets │ │ |
|
||
| │ │ │ └──────────────────────┘ │ |
|
||
| └──────────────────────────┘ └──────────────────────────┘ |
|
||
| |
|
||
| Capability surface is identical in both — plugin author writes the same |
|
||
| definePlugin({...}) regardless of tier; the host decides marshalling. |
|
||
+----------------------------------------------------------------------------+
|
||
```
|
||
|
||
| Tier | Use case | Implementation | Capability call cost |
|
||
|---|---|---|---|
|
||
| `trusted` | First-party plugins owned by the host operator | Direct in-process call wrapped in error boundary | Sync; no marshalling |
|
||
| `isolated` | First-party plugins where bulkhead matters (memory leaks, runaway code, segfault-prone native deps) | Node `Worker` thread per plugin. Every capability call marshalled over `MessagePort` via `api-worker-rpc`. | Async RPC; +50-200μs per call |
|
||
| `sandboxed` | Third-party / marketplace plugins | Worker thread + `vm.Context` with frozen globals, OR WASM target via `api-sandbox-wasm`. Permission-gated host proxies for fs/fetch. CPU/memory/wall-clock budgets enforced. | Async RPC + permission checks |
|
||
|
||
A plugin crash:
|
||
|
||
- `trusted` — handler throws → error boundary catches → 500 → plugin stays `started`.
|
||
- `isolated` / `sandboxed` — worker dies → main host detects → plugin transitions to `crashed` → auto-restart up to `crashRestartMax` (default 3) with exponential backoff → if max exceeded, parked in `crashed` state. Operator can `POST /admin/plugins/{name}/restart` to retry.
|
||
|
||
Engine adapters are wired at boot from `host.json`. Plugins never name a concrete adapter and never know which isolation tier they're running in.
|
||
|
||
### 2.3 Auth: two distinct contracts
|
||
|
||
Conflating session resolution and account lifecycle is what makes most plugin systems painful to integrate with third-party auth.
|
||
|
||
```
|
||
@suaveplan/api-auth # Session resolver: Request → Principal | null.
|
||
# Used on EVERY request. Plugins consume ctx.principal.
|
||
|
||
@suaveplan/api-auth-provider # Optional. Account lifecycle:
|
||
# POST /auth/register, /auth/login, /auth/logout,
|
||
# /auth/refresh, /auth/oauth/*, /auth/verify-email, ...
|
||
# Only needed if you host your own auth.
|
||
```
|
||
|
||
Mix-and-match: Better-Auth's provider + its session resolver, OR Clerk/Auth0 hosted auth (no provider) + JWT session resolver. Each provider adapter declares which paths it owns (default `/auth/*`, configurable). Host reserves the prefix and refuses plugin routes that collide.
|
||
|
||
Multiple session resolvers can be chained — host config takes an ordered chain, first match wins. JWT for users + bearer-static for service callers is a common pattern.
|
||
|
||
**Foundation auth adapters shipping v1:**
|
||
|
||
- `api-auth-jwt` — verifies via `@suaveplan/jws` against a JWKS URL or static key. Pairs with hosted IdPs (Clerk, Auth0, Cognito) or self-hosted JWT-issuing providers.
|
||
- `api-auth-anonymous` — always returns null principal. Default for fully-public APIs.
|
||
- `api-auth-bearer-static` — static service-to-service tokens. No provider, no DB.
|
||
- `api-auth-better-auth` — implements both `auth.session` and `auth.provider` via Better-Auth. Owns its own DB connection per Better-Auth's opinionated model; maps Better-Auth's user/session shapes onto our `Principal` contract. Integration-tested against real Postgres via `testing-integration-postgres`. Shipping this on day one is how we know the auth contracts hold up against a non-trivial third-party library — wrapping it surfaces the contract pinch points before they ossify.
|
||
- `api-auth-rolled` — reference rolled-our-own provider composing `@suaveplan/crypto-rsa` + `@suaveplan/webauthn` + `@suaveplan/otp` + `@suaveplan/srp` + `@suaveplan/hibp` + `@suaveplan/password-strength` + `@suaveplan/password-generator`. Shares the host's main DB connection (not a separate one). Demonstrates how to compose the genesis crypto/auth primitives into a full provider — also doubles as a fallback for sites that don't want a third-party auth dependency.
|
||
|
||
## 3. Plugin manifest schema (`plugin.json`)
|
||
|
||
The declarative half of the hybrid contract. Lives at the package root. Validated by `api-manifest` (Zod) at install AND at every boot — a manifest that drifted between install and reload is a hard failure.
|
||
|
||
```jsonc
|
||
{
|
||
// ── Identity ─────────────────────────────────────────────────────────
|
||
"$schema": "https://schemas.suaveplan.com/api-plugin/v1.json",
|
||
"name": "@acme/billing-plugin",
|
||
"version": "1.4.2",
|
||
"displayName": "Billing",
|
||
"description": "Subscriptions and invoicing.",
|
||
"license": "MIT",
|
||
|
||
// ── Compat ───────────────────────────────────────────────────────────
|
||
"apiHost": "^1",
|
||
"requiredCapabilities": ["router.streaming", "router.blue-green", "db.transactions", "auth.session"],
|
||
"optionalCapabilities": ["events.bounded-queue", "secrets.bun"],
|
||
|
||
// ── Isolation tier (Section 2.2) ─────────────────────────────────────
|
||
"isolation": "isolated", // "trusted" | "isolated" | "sandboxed"
|
||
"trustLevel": "first-party", // "first-party" | "third-party" | "untrusted"
|
||
|
||
// ── Sandbox permissions (only used when isolation=sandboxed) ─────────
|
||
"permissions": {
|
||
"fs": ["./uploads/*"], // host-proxied fs access allowlist
|
||
"fetch": ["api.stripe.com"], // host-proxied outbound HTTP allowlist
|
||
"budgets": {
|
||
"cpuPercent": 25, // sustained CPU cap
|
||
"memoryMb": 512,
|
||
"wallClockMs": 30000 // per request wall-clock
|
||
}
|
||
},
|
||
|
||
// ── Dependencies on other plugins ────────────────────────────────────
|
||
"dependsOn": { "@suaveplan/api-plugin-users": "^1" },
|
||
|
||
// ── HTTP surface ─────────────────────────────────────────────────────
|
||
"routes": {
|
||
"prefix": "/billing",
|
||
"publicRoutes": ["/webhooks/stripe"]
|
||
},
|
||
|
||
// ── GraphQL surface (optional, requires api-graphql capability) ──────
|
||
"graphql": {
|
||
"contributes": true,
|
||
"types": "graphql/types.graphql", // path to SDL contributions
|
||
"resolversFrom": "src/graphql.ts" // export named `resolvers`
|
||
},
|
||
|
||
// ── WebSocket endpoints (optional) ───────────────────────────────────
|
||
"websockets": [
|
||
{
|
||
"path": "/live/orders",
|
||
"auth": "orders:read",
|
||
"subscriptions": ["orders.created", "orders.updated"],
|
||
"maxConnectionsPerTenant": 100,
|
||
"heartbeatMs": 30000
|
||
}
|
||
],
|
||
|
||
// ── SSE endpoints (optional) ─────────────────────────────────────────
|
||
"sse": [
|
||
{
|
||
"path": "/feed",
|
||
"auth": "feed:read",
|
||
"subscriptions": ["feed.item"]
|
||
}
|
||
],
|
||
|
||
// ── Sagas this plugin participates in or owns ────────────────────────
|
||
"sagas": {
|
||
"owns": [
|
||
{
|
||
"name": "create-customer-with-subscription",
|
||
"steps": [
|
||
{ "plugin": "@suaveplan/api-plugin-users", "action": "createCustomer", "compensate": "archiveCustomer" },
|
||
{ "plugin": "@acme/billing-plugin", "action": "createSubscription", "compensate": "cancelSubscription" }
|
||
]
|
||
}
|
||
],
|
||
"participatesIn": ["@acme/onboarding-plugin/full-tenant-setup"]
|
||
},
|
||
|
||
// ── 2PC participant declaration ──────────────────────────────────────
|
||
"twoPhaseCommit": {
|
||
"participates": true // requires db.preparable capability
|
||
},
|
||
|
||
// ── Database (hybrid engine-neutral + escape hatch) ──────────────────
|
||
"database": {
|
||
"required": true,
|
||
"preferredEngine": "postgres",
|
||
"compatibleEngines": ["postgres", "sqlite", "surrealdb"],
|
||
"schemaVersion": 7,
|
||
"migrationsDir": "migrations",
|
||
"tenantIsolation": "row-level",
|
||
"tablePrefix": "billing_"
|
||
},
|
||
|
||
// ── Background work ──────────────────────────────────────────────────
|
||
"jobs": {
|
||
"cron": [{ "name": "reconcile-invoices", "expression": "0 3 * * *", "tz": "UTC" }],
|
||
"queued": [{ "name": "send-receipt", "maxAttempts": 5 }]
|
||
},
|
||
|
||
// ── Event bus topology ───────────────────────────────────────────────
|
||
"events": {
|
||
"publishes": ["billing.invoice.created", "billing.payment.succeeded"],
|
||
"subscribes": ["users.user.deleted"]
|
||
},
|
||
|
||
// ── Secrets / config ─────────────────────────────────────────────────
|
||
"secrets": [
|
||
{ "name": "STRIPE_SECRET_KEY", "required": true },
|
||
{ "name": "STRIPE_WEBHOOK_SECRET", "required": true }
|
||
],
|
||
"config": { "schemaPath": "config.schema.json", "defaults": { "trialDays": 14 } },
|
||
|
||
// ── AuthZ scopes ─────────────────────────────────────────────────────
|
||
"scopes": [
|
||
{ "name": "billing:read", "description": "Read invoices and subscriptions" },
|
||
{ "name": "billing:write", "description": "Create/cancel subscriptions" },
|
||
{ "name": "billing:admin", "description": "Refunds and manual overrides" }
|
||
],
|
||
|
||
// ── Observability ────────────────────────────────────────────────────
|
||
"telemetry": {
|
||
"metricPrefix": "billing.",
|
||
"spanPrefix": "billing.",
|
||
"counters": [{ "name": "billing.invoices_finalised_total", "labels": ["currency"] }],
|
||
"histograms": [{ "name": "billing.stripe.call_duration_ms", "labels": ["operation"] }],
|
||
"gauges": [{ "name": "billing.outstanding_invoice_count" }]
|
||
},
|
||
|
||
// ── OpenAPI ──────────────────────────────────────────────────────────
|
||
"openapi": {
|
||
"contributes": true,
|
||
"tagDisplayName": "Billing & Subscriptions",
|
||
"externalDocs": "https://docs.acme.io/billing"
|
||
},
|
||
|
||
// ── Error code → HTTP status mapping ─────────────────────────────────
|
||
"errors": {
|
||
"statusMap": {
|
||
"PLUGIN.BILLING.SUBSCRIPTION_INACTIVE": 409,
|
||
"PLUGIN.BILLING.INVOICE_ALREADY_FINALISED": 409,
|
||
"PLUGIN.BILLING.STRIPE_API_FAILURE": 502
|
||
},
|
||
"redact": { "PLUGIN.BILLING.STRIPE_API_FAILURE": ["details.stripeKey"] }
|
||
},
|
||
|
||
// ── Uninstall ────────────────────────────────────────────────────────
|
||
"uninstall": {
|
||
"dataPolicy": "archive", // archive | drop | keep
|
||
"archiveTablePrefix": "_archived_billing_"
|
||
}
|
||
}
|
||
```
|
||
|
||
Companion `src/index.ts` (the code half):
|
||
|
||
```ts
|
||
import { definePlugin } from "@suaveplan/api-host";
|
||
|
||
export default definePlugin({
|
||
requires: ["db", "auth.session", "events"] as const,
|
||
|
||
install: async (ctx) => { /* DB migrations auto-run before this; custom seed here */ },
|
||
start: async (ctx) => { /* register listeners etc. */ },
|
||
stop: async (ctx) => { /* drain handles */ },
|
||
uninstall: async (ctx) => { /* archive/drop per manifest policy */ },
|
||
|
||
routes: (router, ctx) => {
|
||
router.get("/invoices/:id", { auth: "billing:read" }, async (req) => { /* ... */ });
|
||
},
|
||
|
||
jobs: {
|
||
"reconcile-invoices": async (ctx) => { /* ... */ },
|
||
"send-receipt": async (payload, ctx) => { /* ... */ },
|
||
},
|
||
|
||
events: {
|
||
"users.user.deleted": async (event, ctx) => { /* ... */ },
|
||
},
|
||
});
|
||
```
|
||
|
||
Design rules:
|
||
|
||
- **Manifest is the source of truth** for declarative facts (routes prefix, scopes, jobs schedule, events topology, DB engine, capability requirements, secrets). The host validates and acts on these *before* loading the plugin's code.
|
||
- **Code half is only for behaviour** (handlers, hooks). You can't declare a route in code that wasn't manifested.
|
||
- **Forward-compatible**: every top-level key except `name`/`version`/`apiHost` is optional. Adding new keys is non-breaking.
|
||
- **Scopes plugins declare** are auto-registered into the host's RBAC registry at install. Plugins can invent new scopes — by design, plugins own their authorisation vocabulary.
|
||
- **`publicRoutes` allowlist** is per-plugin and explicit. Default deny — every route requires `auth.session` unless declared public. "Forgot the auth middleware" stops being a class of bug.
|
||
- **`uninstall.dataPolicy` defaults to `archive`**. Host config can override.
|
||
|
||
## 4. Lifecycle FSM
|
||
|
||
```
|
||
┌──────────────┐
|
||
│ uninstalled │ ◀───── terminal
|
||
└──────┬───────┘
|
||
│ install()
|
||
▼
|
||
┌──────────────┐ install hook throws / migration fails
|
||
│ installing │ ───────────────────────────────┐
|
||
└──────┬───────┘ │
|
||
│ install hook + migrations OK ▼
|
||
▼ ┌───────────────┐
|
||
┌──────────────┐ │ install_failed│ ◀── retryable
|
||
│ installed │ └───────────────┘
|
||
└──────┬───────┘
|
||
│ start()
|
||
▼
|
||
┌──────────────┐ start hook throws / new module fails import
|
||
│ starting │ ───────────────────────────────┐
|
||
└──────┬───────┘ │
|
||
│ start hook OK ▼
|
||
▼ ┌───────────────┐
|
||
┌──────────────┐ │ start_failed │ ◀── retryable (auto-retry × 3)
|
||
│ started │ ◀── HEALTHY └───────────────┘
|
||
└──────┬─────┬─┘ │
|
||
│ │ │ rollback triggered
|
||
│ │ ▼
|
||
│ │ ┌───────────────┐
|
||
│ │ │ rolled_back │ ◀── prior known-good
|
||
│ │ │ (= started) │ module re-activated
|
||
│ │ └───────────────┘
|
||
│ │
|
||
│ │ (isolation=isolated|sandboxed only)
|
||
│ │ worker crash / OOM / wall-clock budget
|
||
│ ▼
|
||
│ ┌───────────────┐ auto-restart × crashRestartMax ┌──────────────┐
|
||
│ │ crashed │ ──────────────────────────────▶ │ starting │
|
||
│ └───────────────┘ └──────────────┘
|
||
│ │ max exceeded → parked
|
||
│ ▼
|
||
│ (operator must POST /admin/plugins/{name}/restart)
|
||
│
|
||
│ stop()
|
||
▼
|
||
┌──────────────┐ stop hook throws (logged, transition proceeds)
|
||
│ stopping │
|
||
└──────┬───────┘
|
||
│
|
||
▼
|
||
┌──────────────┐
|
||
│ stopped │
|
||
└──┬─────┬─────┘
|
||
│ │ uninstall()
|
||
│ ▼
|
||
│ ┌───────────────┐ uninstall hook throws / archive fails
|
||
│ │ uninstalling │ ─────────────────────────────┐
|
||
│ └──────┬────────┘ │
|
||
│ │ archive/drop OK ▼
|
||
│ ▼ ┌─────────────────┐
|
||
│ ┌──────────────┐ │uninstall_failed │ → quarantined sub-state
|
||
│ │ uninstalled │ └─────────────────┘
|
||
│ └──────────────┘
|
||
│
|
||
│ start()
|
||
└──────────────── back to "starting" (hot reload path; no install re-run)
|
||
```
|
||
|
||
| Transition | Side effects |
|
||
|---|---|
|
||
| `uninstalled → installing` | Validate manifest, resolve dep graph, acquire DB connection, run migration runner up to `schemaVersion`, write plugin ledger, register scopes in RBAC, call user `install` hook |
|
||
| `installing → installed` | Emit `plugin.installed`, OTel span complete |
|
||
| `installed → starting` | Mount routes under prefix, subscribe to event topics, register jobs (paused), inject capabilities, call user `start` hook |
|
||
| `starting → started` | Unpause jobs, mark health ready, emit `plugin.started` |
|
||
| `started → stopping` | Pause jobs, unsubscribe events, unmount routes (503 + Retry-After), call user `stop` hook with `gracefulMs` (default 30s) |
|
||
| `stopping → stopped` | Force-cancel stragglers, dispose injected capabilities, emit `plugin.stopped` |
|
||
| `stopped → uninstalling` | Apply `manifest.uninstall.dataPolicy`, drop scopes, drop migration ledger entry, call user `uninstall` hook |
|
||
| `uninstalling → uninstalled` | Emit `plugin.uninstalled` |
|
||
|
||
Hot reload re-enters `installed` without rerunning install **unless** `database.schemaVersion` increased, new scopes/events/jobs/secrets were declared, or `dependsOn` changed. Downgrades (plugin `version` lower than ledger) → `install_failed` with `VERSION_DOWNGRADE`.
|
||
|
||
Failure recovery:
|
||
|
||
- `install_failed` — operator-retryable via `POST /admin/plugins/<n>/retry-install`.
|
||
- `start_failed` — when the failure happened during a hot reload (a prior known-good module exists in the `RollbackJournal`), the loader automatically transitions the plugin to `rolled_back` by re-activating the previous module specifier — operator gets a non-disruptive failure they can investigate on their own time. Otherwise auto-retry × 3 with `@suaveplan/retry` exponential backoff before parking; operator can still `retry-start` after parking.
|
||
- `rolled_back` — semantically equivalent to `started` (the prior known-good version is live). Admin endpoint shows it explicitly so operators see "this plugin rolled back at T, last good version V, failed version V+1, cause: <code>". Operator clears with `POST /admin/plugins/{name}/clear-rollback` after fixing.
|
||
- `crashed` — only reachable from `isolated` / `sandboxed` tiers. Auto-restart up to `crashRestartMax` (default 3) with exponential backoff; subsequent crashes within `crashRestartWindowMs` count against the budget. Budget reset after `crashRestartCooldownMs` of healthy operation. Operator can `POST /admin/plugins/{name}/restart` to retry past the budget.
|
||
- `uninstall_failed` — quarantined sub-state. `force-uninstall` drops data with audit log, or `repair` after manual fix-up.
|
||
- A handler throw or job tick error does NOT transition the FSM; the error boundary handles it and the plugin stays `started`. Only catastrophic capability failures (host evicts) or worker death (isolated/sandboxed tiers) trigger out-of-`started` transitions.
|
||
|
||
The FSM has a per-plugin mutex — at most one transition at a time. Concurrent triggers queue.
|
||
|
||
Plugin ledger is **host-owned** in a small dedicated database — by default the host opens a SQLite file at `host.config.metadataDbPath` (default `./suaveplan-host.db`); production deployments override to a Postgres URL or a shared Surreal namespace. The ledger never shares a connection with a plugin's own DB. Table `suaveplan_plugin_ledger` survives plugin DB recreation; lets one host operate plugins across multiple per-plugin DBs without coordinating ledger schemas. On host restart the host resumes each plugin from its recorded state.
|
||
|
||
## 5. Capability injection
|
||
|
||
### 5.1 Two context flavours
|
||
|
||
```ts
|
||
// Given to install / start / stop / uninstall / job handlers / scheduled tasks.
|
||
interface PluginContext<Caps extends CapabilityMap> {
|
||
plugin: { name: string; version: string };
|
||
logger: LoggerLike; // pre-tagged with plugin name + version
|
||
events: EventBusLike;
|
||
secrets: SecretsLike; // typed against manifest.secrets[]
|
||
config: PluginConfig<Caps>; // typed from config.schema.json
|
||
jobs: JobRegistrationHandle;
|
||
db?: AdminDatabaseLike; // present iff manifest.database; UNSCOPED for migrations
|
||
abortSignal: AbortSignal; // signaled on stop/uninstall
|
||
instruments: { // from manifest.telemetry
|
||
counters: Record<string, Counter>;
|
||
histograms: Record<string, Histogram>;
|
||
gauges: Record<string, Gauge>;
|
||
};
|
||
}
|
||
|
||
// Given to route handlers + tenant-scoped event/job handlers.
|
||
interface RequestContext<Caps> extends PluginContext<Caps> {
|
||
request: Request;
|
||
principal: Principal | null; // null on publicRoutes
|
||
tenantId: TenantId;
|
||
requestId: string; // ulid
|
||
span: Span;
|
||
db?: TenantScopedDatabaseLike; // OVERRIDES; tenant-bound
|
||
}
|
||
```
|
||
|
||
The `db` slot is the same field name but a different type per context. Migrations and admin work get raw access; request handlers can only see their tenant. Plugins literally cannot read another tenant's rows from a route handler — the tenant-scoped adapter rejects unscoped queries at the boundary.
|
||
|
||
Default scope in route handlers is **tenant-scoped**. Admin access from a route is the exception and must be declared explicitly via `db.unsafe-queries` capability flag. Every such call emits `plugin.unsafe_query_total` for audit.
|
||
|
||
### 5.2 Adapter binding
|
||
|
||
```jsonc
|
||
// host.json (simplified)
|
||
{
|
||
"capabilities": {
|
||
"router": { "adapter": "@suaveplan/api-router-hono" },
|
||
"auth.session": { "adapter": "@suaveplan/api-auth-jwt", "config": { "jwksUrl": "..." } },
|
||
"tenant.resolver": {
|
||
"chain": [
|
||
{ "adapter": "@suaveplan/api-tenant-resolver-jwt" },
|
||
{ "adapter": "@suaveplan/api-tenant-resolver-header" }
|
||
]
|
||
},
|
||
"secrets": { "adapter": "@suaveplan/api-secrets-bun" },
|
||
"db": { "adapter": "@suaveplan/api-db-adapter", "engine": "postgres", "url": "..." }
|
||
}
|
||
}
|
||
```
|
||
|
||
Each adapter exports `defineCapability({ id, version, scope, provider, disposer })`. The host's `CapabilityRegistry` validates capability versions against every plugin's `apiHost` + `requiredCapabilities`. Mismatch → host refuses to start the plugin with `CAPABILITY_VERSION_MISMATCH`.
|
||
|
||
### 5.2a Worker-RPC variants — required on every capability
|
||
|
||
Every capability adapter ships **two callable surfaces** from day one:
|
||
|
||
1. **Direct surface** — the in-process synchronous (or async) capability call used by `trusted`-tier plugins.
|
||
2. **Proxy surface** — a `MessagePort`-marshalled stub used by `isolated`- and `sandboxed`-tier plugins.
|
||
|
||
The host instantiates the right surface based on the plugin's `isolation` declaration. Plugins author against the same `PluginContext` / `RequestContext` types — they cannot tell which surface they hold. The proxy surface uses `api-worker-rpc` for:
|
||
|
||
- Request/response correlation via monotonic message ids.
|
||
- `AbortSignal` forwarding (cancel propagates main → worker → in-flight call).
|
||
- Streaming responses via transferable `ReadableStream` (db cursors, large query results).
|
||
- Error reconstruction — `SuaveplanError` is rebuilt with the same code + chain on the calling side.
|
||
- Backpressure — bounded outstanding-message budget per worker; exceeding triggers `RPC_BACKPRESSURE` before exhausting memory.
|
||
- Structured-clone-safe boundary — non-cloneable values (functions, class instances without explicit `serialize` hooks) → `RPC_NOT_CLONEABLE` at the call site, not silent corruption.
|
||
|
||
Every conformance suite (Section 13) runs against both surfaces. An adapter that passes only one is not shippable.
|
||
|
||
### 5.2b Sandboxed-tier capability gating
|
||
|
||
Sandboxed plugins get the same proxy surface plus an additional permission layer enforced by the host before each call:
|
||
|
||
- `db` — query gated by manifest's declared `database` (plugin can only query its own DB scope).
|
||
- `secrets` — only manifested secrets are readable.
|
||
- `events` — only manifested `publishes` topics are emittable; only manifested `subscribes` topics deliverable.
|
||
- `fetch` (host-proxied) — destination host must match `permissions.fetch[]`.
|
||
- `fs` (host-proxied) — path must match `permissions.fs[]`.
|
||
- All others — proxy surface unchanged from `isolated` tier.
|
||
|
||
Permission denials emit `plugin.permission_denied_total{plugin, capability, reason}` and surface as `PERMISSION_DENIED` errors inside the plugin.
|
||
|
||
### 5.3 Type safety
|
||
|
||
`definePlugin` is generic over the capability map; manifest's `requiredCapabilities` is the type's source of truth:
|
||
|
||
```ts
|
||
export default definePlugin({
|
||
requires: ["db", "auth.session", "events"] as const,
|
||
routes: (router, ctx) => {
|
||
ctx.db.query(...); // ✓ typed
|
||
ctx.events.emit(...); // ✓ typed
|
||
// ctx.secrets.get(...); // ✗ TS error — not declared
|
||
},
|
||
});
|
||
```
|
||
|
||
Manifest and code must agree. Plugin can't accidentally use a capability it didn't declare.
|
||
|
||
### 5.4 Per-tenant jobs
|
||
|
||
```jsonc
|
||
"jobs": {
|
||
"cron": [{ "name": "send-monthly-statements", "expression": "0 0 1 * *", "perTenant": true }]
|
||
}
|
||
```
|
||
|
||
`perTenant: true` makes the host fan out — enumerate tenants from the `tenant.provider` capability, spawn one job invocation per tenant, each receiving a tenant-scoped context for the run. `tenant.provider` ships in v1 (in-memory reference impl + adapter slot) so retrofitting tenant enumeration into a stable contract doesn't have to happen later.
|
||
|
||
### 5.5 Disposal
|
||
|
||
Following `@suaveplan/network`'s `PluginManager` pattern: register forward, dispose reverse. On `stop()`, capabilities dispose in reverse-registration order — request-scoped first, logger last (so disposal logs still work).
|
||
|
||
## 6. Per-request data flow
|
||
|
||
Fixed pipeline. Plugins can only insert middleware between steps 7 and 8 — never before auth, RBAC, or validation. That surface was a CVE waiting to happen.
|
||
|
||
```
|
||
1. HTTP intake (api-router-* adapter) — parse, ULID requestId, root span, abort on socket close
|
||
2. Route match (host) — radix tree built from all plugin manifests
|
||
3. Auth resolver (api-auth-* adapter) — sets ctx.principal; 401 only if non-public && null
|
||
4. Tenant resolver chain (api-tenant-resolver-*) — first principal.tenantId, then fallback resolvers
|
||
4a. Rate limit (optional) (api-rate-limit) — per-tenant or per-principal token bucket
|
||
5. RBAC gate (optional) (api-rbac) — required scopes vs principal.scopes; rule policy via @suaveplan/rules
|
||
6. Body/params/query parse + validate — Zod runtime gate (route.runtimeValidation, default true)
|
||
6a. Idempotency hook (@suaveplan/idempotency) — when route is idempotent + Idempotency-Key header present
|
||
7. Capability scoping (host) — ctx.db ← tenant-scoped wrapper; ctx.logger ← per-request child
|
||
8. Plugin route handler (user code) — error-boundary wrapped
|
||
9. Response validation (host) — handler return vs route.responses[].body if route.openapi/runtimeValidation
|
||
10. Response composition (host + engine) — serialize, ETag, X-Request-Id, Server-Timing, encoding
|
||
11. Telemetry close (host) — span.end, api.request_duration_ms{...}, api.requests_total{...}
|
||
```
|
||
|
||
Streaming routes (`streaming: true`) bypass steps 6 and 10's buffering — handler reads/writes `ReadableStream` directly.
|
||
|
||
For `isolated` / `sandboxed` plugins, step 8 marshals `RequestContext` (minus the raw `Request` object) and the validated `body` / `params` / `query` across the `MessagePort` to the worker. Streams are transferred via `Transferable` (zero-copy). The handler's return value flows back the same way. Steps 1–7 and 9–11 always run in the main process — auth, RBAC, validation, tenant scoping, and telemetry are NEVER in worker hands. This means worker plugins gain isolation without losing any host-enforced security guarantees.
|
||
|
||
Cookie-based auth adapters trigger CSRF verification using `Bun.CSRF` in step 3 for non-GET/HEAD methods. Default ON for cookie auth, opt-out per route via `csrf: false`. JWT/Bearer auth skips CSRF (no implicit cred submission).
|
||
|
||
Response validation strictness in production defaults to `enforce` (return 500 if violation, fail closed on contract breach); `warn` in dev (return body + log + counter); per-route override allowed.
|
||
|
||
Every step short-circuits to error response on throw → error boundary → standard envelope:
|
||
|
||
```jsonc
|
||
{
|
||
"error": {
|
||
"code": "PLUGIN.BILLING.SUBSCRIPTION_INACTIVE",
|
||
"message": "No active subscription for tenant.",
|
||
"status": 409,
|
||
"requestId": "01HZX...",
|
||
"traceId": "0af7651916cd43dd8448eb211c80319c",
|
||
"details": { /* schema'd per code; redacted */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
`X-Trace-Id` mirrors `traceId` in the response header so an end-user pasting it into a support ticket lets an operator find the span instantly.
|
||
|
||
## 7. OpenAPI
|
||
|
||
Auto-generated from route Zod schemas + manifest metadata. Plugin authors write Zod schemas on routes; OpenAPI assembles itself.
|
||
|
||
```ts
|
||
router.post("/invoices", {
|
||
auth: "billing:write",
|
||
summary: "Create an invoice",
|
||
tags: ["invoices"], // appended after auto-tag (plugin displayName)
|
||
request: { body: BodySchema },
|
||
responses: { 201: { body: InvoiceSchema }, 409: { body: ErrorSchema } },
|
||
runtimeValidation: true, // default true; false skips 400 gate
|
||
openapi: true, // default true; false omits from /openapi.json
|
||
}, async (req) => { /* req.body typed AND validated at runtime */ });
|
||
```
|
||
|
||
`runtimeValidation` and `openapi` are independently togglable. Internal endpoints can have validation but no docs; legacy ones can have docs but no runtime gate.
|
||
|
||
`@suaveplan/api-openapi` reads:
|
||
|
||
1. Each plugin's manifest (`name`, `displayName`, `description`, `scopes`, route prefix, `publicRoutes`).
|
||
2. Each plugin's registered routes (method/path/summary/tags/request/response schemas).
|
||
3. The active auth adapter (`securitySchemes` — Bearer JWT, API key, OAuth2 flows, …).
|
||
4. Host config (server URLs, contact, license, top-level info).
|
||
|
||
Endpoints:
|
||
|
||
```
|
||
GET /openapi.json # full merged document
|
||
GET /openapi.yaml # yaml flavour
|
||
GET /docs # Scalar (default). Pluggable: api-openapi-ui-{scalar,swagger,redoc}
|
||
GET /docs/{plugin} # per-plugin filtered view
|
||
```
|
||
|
||
Namespacing:
|
||
|
||
- **Tags** auto-set to plugin `displayName`. Per-route `tags` append.
|
||
- **Components/schemas** namespaced: `#/components/schemas/billing.Invoice`.
|
||
- **operationId** = `<pluginName>.<method>.<routePath>` — deterministic.
|
||
- **Security** — every route inherits the auth adapter's default scheme unless on `publicRoutes`.
|
||
|
||
Zod-to-OpenAPI conversion lives in `api-openapi` only. Uses `@asteasolutions/zod-to-openapi` (established, OpenAPI 3.1, handles refs/discriminators/coercion). Plugins import `z` from `zod` directly per the genesis convention.
|
||
|
||
`openapi.generation` is an **optional** capability. Plugins without schemas still mount routes; they appear as "no schema declared." Host config `openapi.strictness: "warn" | "error"` controls whether missing schemas log a warning or refuse install.
|
||
|
||
Merged doc `info.version` = host version. Each plugin contributes `x-plugin-version` extension on its tag, so consumers can detect plugin upgrades. Bumping a plugin's major version bumps its routes' operationId namespace (`billing.v2.create-invoice`) — non-breaking for clients still consuming v1 routes.
|
||
|
||
## 8. Hot reload
|
||
|
||
Two trigger paths, one underlying primitive.
|
||
|
||
### 8.1 Underlying reload primitive — blue-green by default
|
||
|
||
```
|
||
acquire(P.mutex)
|
||
1. P.state ∈ {started, rolled_back} → assert. Otherwise reject.
|
||
2. Snapshot the current module specifier + manifest + migration-ledger
|
||
position into RollbackJournal[P]. Truncate journal to last 3.
|
||
3. Re-resolve manifest from disk. Compute reinstall criteria (8.6).
|
||
4. Import the new module under a fresh specifier (?v=N+1).
|
||
Native binding present → refuse reload with NATIVE_BINDING_HELD.
|
||
5. If reinstall criteria met → run migrations forward against P's DB
|
||
(forward-only; migrations are idempotent).
|
||
6. Mount the new module's routes under a SHADOW slot in the router
|
||
(same prefix, distinct version pointer). Run start() against the
|
||
new module. Run health probe.
|
||
a. Health probe fails OR start() throws → transition P to
|
||
start_failed. Atomically discard the shadow slot. Re-activate
|
||
the previous specifier from RollbackJournal[P] (cheap — module
|
||
object still in WeakRef-pinned cache). Transition P to
|
||
rolled_back. Emit plugin.rolled_back. Done.
|
||
b. Health probe passes → atomically flip the router's version
|
||
pointer for P from "live=N, shadow=N+1" to "live=N+1,
|
||
draining=N". New requests immediately hit N+1.
|
||
7. In-flight requests on the now-draining N slot continue against
|
||
the old module. Slot disposed when all settle (or after
|
||
forceTimeoutMs default 30s, then ctx.abortSignal fires).
|
||
8. Old module released from cache; WeakRef may collect.
|
||
release(P.mutex)
|
||
```
|
||
|
||
The two module versions coexist briefly. Per-plugin mutex makes burst file-save events safe (queue, intermediate ones are no-ops, last runs). The router's per-plugin version pointer is the single atomic point that determines "which module gets this request" — `Atomics.store` on a SAB-backed integer.
|
||
|
||
`host.config.blueGreen: false` opts a deployment back into the simpler drain-then-replace mode (Section 8.5 below) for environments that cannot tolerate two concurrent module instances (e.g. plugins holding exclusive native locks). The router and lifecycle paths shipped support both modes; the choice is host-config-level, not per-plugin.
|
||
|
||
### 8.1a RollbackJournal
|
||
|
||
Per-plugin ring buffer (default depth 3) recording the last known-good specifiers. Each entry:
|
||
|
||
```jsonc
|
||
{
|
||
"specifier": "file:///abs/path/dist/index.js?v=42",
|
||
"manifestHash": "sha256:...",
|
||
"moduleRef": /* WeakRef<Module>; nullable after GC */,
|
||
"migrationCursor": 7, // schemaVersion at the time
|
||
"capturedAt": "2026-05-13T08:14:22.014Z",
|
||
"version": "1.4.2" // from manifest
|
||
}
|
||
```
|
||
|
||
Persisted entries (manifest hash, migration cursor, version) survive host restart by being written to the plugin ledger; the `moduleRef` is in-memory only and lost on restart. After a restart, rollback is no longer auto-zero-cost — the host has to re-import the previous specifier. The host can still do this; the cost is one additional module import.
|
||
|
||
Operator can inspect via `GET /admin/plugins/{name}/rollback-history` and force a manual rollback with `POST /admin/plugins/{name}/rollback-to/{version}` — useful for "the new module passed health probe but is producing bad results in production."
|
||
|
||
### 8.2 Module cache busting
|
||
|
||
Bun ESM cache is URL-keyed. Re-import with same URL → same instance. We bust with a query suffix:
|
||
|
||
```ts
|
||
import(`${moduleUrl}?v=${reloadCounter}`)
|
||
```
|
||
|
||
ESM treats `file:///x.js` and `file:///x.js?v=2` as different specifiers → fresh evaluation. Old module held by a `WeakRef` for GC purposes; host's reference to the new module replaces it atomically.
|
||
|
||
Transitive deps reload **for free** — bumping the importing module's specifier re-resolves the whole subgraph. Native bindings (`.node` files) cannot be reloaded; loader walks the dep graph at install time and refuses reload if any are present (logs `RELOAD.NATIVE_BINDING_HELD`, advises full restart).
|
||
|
||
### 8.3 Trigger 1 — File-watch in dev
|
||
|
||
Active when `host.config.devMode === true` (default `false`).
|
||
|
||
- One watcher per loaded plugin against `packages/api/<plugin>/dist/**` (or `src/**` if running TS via Bun).
|
||
- Debounce 100 ms.
|
||
- Coalesce by plugin.
|
||
- Glob ignore: tests, `.md`, `coverage/`, `.turbo/`.
|
||
- `plugin.json` always triggers reload regardless of ignore.
|
||
- Cross-plugin invalidation respects `dependsOn` — dependents reload after their dep returns to `started`.
|
||
|
||
### 8.4 Trigger 2 — Admin API
|
||
|
||
Always available. Mounted under `host.config.adminPrefix` (default `/admin`, configurable; `null` to disable).
|
||
|
||
```
|
||
POST /admin/plugins/{name}/reload
|
||
POST /admin/plugins/{name}/disable
|
||
POST /admin/plugins/{name}/enable
|
||
POST /admin/plugins/{name}/install
|
||
POST /admin/plugins/{name}/uninstall
|
||
POST /admin/plugins/{name}/retry-install
|
||
POST /admin/plugins/{name}/retry-start
|
||
POST /admin/plugins/{name}/force-uninstall
|
||
GET /admin/plugins
|
||
GET /admin/plugins/{name}
|
||
GET /admin/capabilities
|
||
```
|
||
|
||
Protection:
|
||
|
||
- Auth required, no `publicRoutes` bypass.
|
||
- Default scope `host:admin` enforced by RBAC.
|
||
- Audit: every state-changing call emits `host.admin_action`.
|
||
- Rate-limited per-principal (60 ops/min; destructive endpoints 1/min).
|
||
- Optional mutual-TLS gate when paired with `api-auth-bearer-static` for destructive endpoints.
|
||
|
||
`adminPrefix: null` disables HTTP admin; admin operations then only via host signals + a CLI binary over Unix socket. v1 ships this option for sidecar-only deployments.
|
||
|
||
### 8.5 In-flight requests during reload
|
||
|
||
**Blue-green mode (default).** Two slots coexist (live + draining). New requests go to the live slot; in-flight on the draining slot complete against the old module. Old module disposed when:
|
||
- all in-flight settle, OR
|
||
- `drainDeadlineMs` (default 30s) elapses → `ctx.abortSignal` fires on stragglers; non-terminating handlers return 503 and continue in background until `forceTimeoutMs` (default 60s) at which point the worker (isolated/sandboxed) or the Promise (trusted) is force-cancelled and the return discarded.
|
||
|
||
**503-window mode (`blueGreen: false`).** Single slot. Router flips `live → draining → replaced` atomically. New requests during the drain window get 503 + `Retry-After: 1`. Two sub-modes:
|
||
- `forceCancel: false` (default in prod): host waits for in-flight to finish before swapping. Mutex has hard ceiling `reloadMutexMaxMs` default 5min → `RELOAD.MUTEX_DEADLINE_EXCEEDED`.
|
||
- `forceCancel: true` (default in dev): `ctx.abortSignal` fires immediately; handler that doesn't terminate within `forceTimeoutMs` returns 503 and continues in background (return discarded).
|
||
|
||
### 8.6 Reinstall criteria
|
||
|
||
Re-install runs only when:
|
||
|
||
- `manifest.database.schemaVersion` increased (pending migrations execute).
|
||
- New `scopes`, `events.publishes`, `events.subscribes`.
|
||
- New `secrets`.
|
||
- `dependsOn` changed.
|
||
|
||
Everything else (handler code, route options, cron expression, response schemas) → skip install, go straight to `start`. Reload ledger records which branch fired.
|
||
|
||
### 8.7 Failure modes
|
||
|
||
| Failure | Outcome | Recovery |
|
||
|---|---|---|
|
||
| `stop` hook throws (on old module during drain) | Logged, drain proceeds | Best-effort; not blocking |
|
||
| New module fails import | Plugin → `rolled_back` (auto), shadow slot discarded, previous live slot continues serving. Emit `plugin.rolled_back`. | Fix file. Next file-watch / admin `reload` runs again. Operator clears via `clear-rollback` once fixed. |
|
||
| New module's `start` throws | Same as above — auto-rollback to previous specifier from `RollbackJournal`. | Same. |
|
||
| New module's health probe fails | Same — auto-rollback before any traffic flips to the new slot. | Same. |
|
||
| Migration fails midway | Plugin → `install_failed`. Old module continues serving (blue-green) or 503 (window mode). Ledger records partial state. | Fix migration + `retry-install` (idempotent migrations re-run from last successful), or `force-uninstall`. |
|
||
| Reload mutex held > deadline | Caller gets `RELOAD_BUSY`. | Wait + retry. |
|
||
| `RollbackJournal` exhausted (host restart cleared in-memory module refs, AND no entries persisted) | Plugin → `start_failed` (true terminal). Operator action required. | Manual deploy of prior known-good version + admin `install`. |
|
||
|
||
Rollback is "best-effort cheap on the happy path, always-recoverable on the unhappy path." The in-memory `WeakRef` makes rollback effectively free on the most common case (reload immediately after a bad save); persisted journal entries make it possible (with one extra import) after a restart; if both fail, operators are no worse off than before — they redeploy.
|
||
|
||
### 8.8 Operator UX
|
||
|
||
`GET /admin/plugins/{name}` returns terminal-friendly JSON:
|
||
|
||
```jsonc
|
||
{
|
||
"name": "@acme/billing",
|
||
"version": "1.4.2",
|
||
"state": "started",
|
||
"since": "2026-05-12T18:31:08Z",
|
||
"manifestHash": "sha256:7af9...",
|
||
"schemaVersion": 7,
|
||
"lastReload": {
|
||
"at": "2026-05-12T18:31:08Z",
|
||
"trigger": "filewatch",
|
||
"ranMigrations": false,
|
||
"drainedRequests": 12,
|
||
"durationMs": 184
|
||
},
|
||
"lastError": null,
|
||
"capabilities": {
|
||
"required": { "router": "1.0.0", "db": "1.0.0", "auth.session": "1.0.0", "events": "1.0.0" },
|
||
"optional": { "events.bounded-queue": "1.0.0" },
|
||
"missing": []
|
||
}
|
||
}
|
||
```
|
||
|
||
## 9. Distributed transactions
|
||
|
||
Cross-plugin writes against multiple databases (or even a single DB shared by plugins with row-level tenant isolation) need transactional guarantees that no single plugin can give. Two engines ship in the foundation; plugins opt into whichever fits their workflow.
|
||
|
||
### 9.1 `api-saga` — orchestrated, compensable workflows (default)
|
||
|
||
The saga engine is the recommended path for "create a customer + open a subscription + send a welcome email" workflows where strict ACID isn't required but eventual consistency with rollback semantics is. The saga itself is declared in the saga-owning plugin's manifest:
|
||
|
||
```jsonc
|
||
"sagas": {
|
||
"owns": [{
|
||
"name": "create-customer-with-subscription",
|
||
"timeoutMs": 60000,
|
||
"steps": [
|
||
{ "plugin": "@suaveplan/api-plugin-users", "action": "createCustomer", "compensate": "archiveCustomer" },
|
||
{ "plugin": "@acme/billing-plugin", "action": "createSubscription", "compensate": "cancelSubscription" },
|
||
{ "plugin": "@acme/email-plugin", "action": "sendWelcome", "compensate": null }
|
||
]
|
||
}]
|
||
}
|
||
```
|
||
|
||
The orchestrator (`api-saga`):
|
||
|
||
- Persists every saga instance in the host metadata DB (`suaveplan_saga_instances` + `suaveplan_saga_steps`). Survives host restart by replaying state.
|
||
- Drives steps in declared order. On each step, emits `saga.step.requested` on the bus with `{sagaId, stepName, input}`. Target plugin's `actions[stepName]` handler runs, returns either success-with-output or failure-with-cause.
|
||
- Idempotency contract: every step's action and compensate handlers MUST be idempotent (keyed by `{sagaId, stepName}`). Host wraps every call in `@suaveplan/idempotency` so a retried step doesn't produce a second record. Plugins that need to enforce idempotency manually opt out via `idempotency: "manual"` in their action declaration.
|
||
- On any step failure: orchestrator runs `compensate` handlers in reverse order over completed steps. Compensations are themselves idempotent + retryable up to `maxCompensationAttempts`.
|
||
- After successful compensation: saga ends in `compensated` state; failure events emitted.
|
||
- After successful completion: `completed` state.
|
||
- Unrecoverable compensation failure: `compensation_failed` state; alert emitted; operator intervention required (admin endpoint to manually mark steps).
|
||
|
||
Plugins participate in sagas they don't own simply by declaring `sagas.participatesIn[]` and exporting matching `actions` and `compensations` named in the saga's step declaration. The host validates at install that every step's named plugin exists, exports the named action, and has matching compensate (where declared).
|
||
|
||
Step actions execute with a `RequestContext` whose `principal` is the saga's *initiator* principal (carried from the request that started the saga) and whose `tenantId` is the saga's *target tenant* (carried from the input). Tenant isolation is preserved — a saga step cannot read another tenant's data unless the plugin declares `saga.cross-tenant`.
|
||
|
||
OTel surface:
|
||
|
||
```
|
||
saga.instance span per saga lifetime
|
||
saga.step span per step attempt; attr: stepName, attempt, outcome
|
||
saga.compensate span per compensation
|
||
saga.instances_total { name, outcome }
|
||
saga.step_duration_ms histogram { name, step, outcome }
|
||
saga.compensations_total { name, step }
|
||
saga.in_flight gauge — instances currently running
|
||
```
|
||
|
||
### 9.2 `api-2pc` — two-phase commit coordinator
|
||
|
||
For workflows that genuinely require atomic cross-DB writes (no anomaly tolerable; classic finance/inventory scenarios), `api-2pc` provides a coordinator on top of engines that support `PREPARE TRANSACTION` (Postgres is the only mainstream one). Surreal and SQLite plugins cannot participate as 2PC writers — they can only join as read-only voters.
|
||
|
||
```ts
|
||
import { run2pc } from "@suaveplan/api-host";
|
||
|
||
await run2pc({
|
||
participants: [
|
||
{ plugin: "@suaveplan/api-plugin-users", action: "debit", input: { ... } },
|
||
{ plugin: "@acme/billing-plugin", action: "credit", input: { ... } },
|
||
],
|
||
timeoutMs: 5000,
|
||
}, ctx);
|
||
```
|
||
|
||
Phase 1: coordinator sends `prepare` to every participant. Each runs the work inside a transaction and issues `PREPARE TRANSACTION '<participantTxId>'`. Returns `vote_commit` or `vote_abort`. Phase 2: if all vote commit → coordinator issues `COMMIT PREPARED` everywhere; if any vote abort or timeout → `ROLLBACK PREPARED` everywhere.
|
||
|
||
Coordinator state in `suaveplan_2pc_transactions` table — survives host restart. On restart, coordinator queries every participant's `pg_prepared_xacts` for any orphaned prepared transactions matching the host's instance ID and resolves them (commit if log says all-voted, rollback otherwise).
|
||
|
||
Plugins participating in 2PC declare `twoPhaseCommit.participates: true` and require the `db.preparable` capability (refused at install if the bound DB engine doesn't support it).
|
||
|
||
Operational warning: 2PC holds row-level locks across the prepare → commit window. Bad for throughput, lethal for high-contention writes. The orchestrator emits `2pc.prepare_duration_ms` and `2pc.commit_duration_ms` so operators see lock-holding times. Default `timeoutMs` 5s with hard ceiling 30s — long prepares are treated as failures.
|
||
|
||
### 9.3 Choosing between them
|
||
|
||
| Scenario | Engine |
|
||
|---|---|
|
||
| Async workflow across services, eventual consistency OK | Sagas |
|
||
| User-facing transactional operation that must succeed-or-undo atomically | Sagas (compensate covers the undo) |
|
||
| Mixed-engine plugins (Postgres + Surreal + SQLite) | Sagas (2PC can't span) |
|
||
| Financial / inventory writes where partial visibility is unacceptable | 2PC |
|
||
| Read-modify-write across plugins on the same Postgres | 2PC |
|
||
| Plugin author isn't sure | Default to sagas |
|
||
|
||
Both engines are first-class. The reference plugin (`api-plugin-example`) demonstrates both.
|
||
|
||
## 10. GraphQL surface
|
||
|
||
GraphQL is a first-class engine alongside REST. Plugins can mount GraphQL types and resolvers; the host federates a single schema across all plugins.
|
||
|
||
### 10.1 Plugin authoring
|
||
|
||
```ts
|
||
// src/index.ts
|
||
export default definePlugin({
|
||
requires: ["db", "auth.session", "graphql"] as const,
|
||
|
||
routes: (router, ctx) => { /* REST routes still allowed in parallel */ },
|
||
|
||
graphql: (registry, ctx) => {
|
||
registry.addTypes(/* GraphQL */ `
|
||
extend type Query {
|
||
invoices(limit: Int = 20): [Invoice!]!
|
||
}
|
||
extend type Mutation {
|
||
createInvoice(input: CreateInvoiceInput!): Invoice!
|
||
}
|
||
type Invoice {
|
||
id: ID!
|
||
amountCents: Int!
|
||
createdAt: DateTime!
|
||
customer: Customer! # cross-plugin ref
|
||
}
|
||
input CreateInvoiceInput {
|
||
amountCents: Int!
|
||
currency: String!
|
||
customerId: ID!
|
||
}
|
||
`);
|
||
|
||
registry.addResolvers({
|
||
Query: {
|
||
invoices: async (_p, args, rctx) => {
|
||
// rctx is RequestContext — principal, tenantId, db all tenant-scoped
|
||
return rctx.db.query("SELECT * FROM invoices LIMIT $1", [args.limit]);
|
||
},
|
||
},
|
||
Mutation: {
|
||
createInvoice: async (_p, args, rctx) => { /* ... */ },
|
||
},
|
||
Invoice: {
|
||
// Cross-plugin field — delegated to users-plugin's resolver
|
||
customer: (parent, _a, rctx) => rctx.loaders.customer.load(parent.customerId),
|
||
},
|
||
});
|
||
|
||
// DataLoaders registered per-request automatically by the host
|
||
registry.addLoader("customer", async (ids, rctx) => {
|
||
return rctx.bus.requestReply("users.customer.batchGet", { ids });
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
### 10.2 Federation
|
||
|
||
The host (`api-graphql`) builds one merged schema across all plugins at boot and on every reload. `extend type Query { ... }` from multiple plugins is unioned. Cross-plugin type references work because every plugin contributes to the same merged type graph; resolvers from one plugin can produce values consumed by resolvers from another plugin via DataLoaders.
|
||
|
||
Schema collisions (two plugins declaring the same field on the same type) → boot fails with `GRAPHQL.SCHEMA_COLLISION`. Manifest can declare `graphql.namespace` to scope a plugin's contributions under a wrapper type — e.g. `BillingQuery` rather than directly on `Query`.
|
||
|
||
### 10.3 Pipeline integration
|
||
|
||
A GraphQL request flows through the same per-request pipeline as REST (Section 6). Step 8 (handler) dispatches into the GraphQL executor instead of a single REST handler. Inside resolvers:
|
||
|
||
- `rctx.principal` / `rctx.tenantId` / `rctx.db` exactly as for REST.
|
||
- `auth` / RBAC checks on resolvers via the `@auth(scope: "billing:read")` schema directive — the directive emits the same RBAC gate at the resolver call site.
|
||
- Per-field rate limiting via `@rateLimit(rpm: 60)` directive.
|
||
- DataLoader instances are per-request, populated at step 7 (capability scoping) and disposed at step 11.
|
||
|
||
### 10.4 Subscriptions
|
||
|
||
GraphQL subscriptions ride the WebSocket engine (Section 11). The host wires `graphql-ws` protocol over `api-ws`; subscription resolvers return `AsyncIterable` driven by the event bus.
|
||
|
||
```ts
|
||
registry.addResolvers({
|
||
Subscription: {
|
||
invoiceCreated: {
|
||
subscribe: (_, _a, rctx) => rctx.bus.asyncIterator("billing.invoice.created", { tenant: rctx.tenantId }),
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
### 10.5 Engine choice
|
||
|
||
Default engine is `api-graphql-yoga` (GraphQL Yoga, Bun-friendly, fastest for our usage). `api-graphql-apollo` available for shops standardised on Apollo's broader feature set (Apollo Studio, persisted queries, etc.). Both implement the same `api-graphql` contract; switching is host-config only.
|
||
|
||
### 10.6 Endpoints
|
||
|
||
```
|
||
POST /graphql # main endpoint
|
||
GET /graphql/sdl # merged SDL
|
||
GET /graphql/ui # GraphiQL (or Altair via api-graphql-ui-altair)
|
||
WS /graphql/ws # subscriptions
|
||
```
|
||
|
||
### 10.7 Coexistence with REST + OpenAPI
|
||
|
||
A route can be REST-only, GraphQL-only, or both — same handler logic factored into a plugin-internal function that's called from both surfaces. The OpenAPI doc covers REST routes; the GraphQL SDL covers GraphQL operations. The two don't merge.
|
||
|
||
## 11. WebSocket and SSE engines
|
||
|
||
Persistent connections are first-class. Plugins declare endpoints in the manifest; the host runs the engine, manages connection lifecycle, fans out subscriptions.
|
||
|
||
### 11.1 WebSocket — connection lifecycle
|
||
|
||
```ts
|
||
export default definePlugin({
|
||
requires: ["ws", "auth.session"] as const,
|
||
|
||
websockets: {
|
||
"/live/orders": {
|
||
onConnect: async (conn, ctx) => {
|
||
// ctx.principal + ctx.tenantId already resolved at handshake
|
||
conn.subscribe("orders.created", { tenant: ctx.tenantId });
|
||
conn.subscribe("orders.updated", { tenant: ctx.tenantId });
|
||
},
|
||
onMessage: async (conn, msg, ctx) => {
|
||
// Optional — plugins can also be receive-only
|
||
if (msg.type === "ack") conn.ack(msg.id);
|
||
},
|
||
onClose: async (conn, ctx, info) => {
|
||
// info.reason, info.code, info.wasClean
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
Handshake flow:
|
||
|
||
1. WebSocket upgrade request → same per-request pipeline (Section 6) steps 1–7.
|
||
2. At handshake completion, the host invokes the plugin's `onConnect` with a `Connection` object bound to `principal` + `tenantId`. The handshake principal is frozen for the connection's lifetime — token expiry mid-connection emits a `connection.principal_expired` event the plugin can listen for to force-disconnect.
|
||
3. Connection enters the per-plugin `ConnectionRegistry` with an index by `tenantId`.
|
||
|
||
### 11.2 Subscription bus
|
||
|
||
`conn.subscribe(topic, { tenant?, filter? })` registers the connection with the host's subscription bus. Any plugin emitting `bus.broadcast(topic, { tenant, payload })` triggers fan-out:
|
||
|
||
- Host looks up matching connections from `ConnectionRegistry` by `(topic, tenant)`.
|
||
- Optional `filter` predicate runs per-connection (e.g. "only deliver if `payload.priority >= conn.minPriority`").
|
||
- Payload encoded once, sent to each matching connection.
|
||
|
||
Cross-tenant fan-out requires `ws.broadcast-cross-tenant` capability (audit-logged on every use). Default deny.
|
||
|
||
### 11.3 Multi-host fan-out
|
||
|
||
Single host is the default. Production deployments with multiple host replicas wire `api-ws-broker-redis` (or another broker adapter) — `bus.broadcast` writes to Redis pub/sub, every host receives, each delivers to its locally-connected subscribers. Connections are NOT migrated across hosts; the broker just makes broadcasts visible everywhere. Sticky sessions are NOT required.
|
||
|
||
### 11.4 Backpressure + heartbeats
|
||
|
||
- Per-connection send queue is bounded (`maxQueuedMessages`, default 256). Exceeding → connection receives a `BACKPRESSURE_EVICTED` close frame and is dropped.
|
||
- Ping/pong heartbeats every `heartbeatMs` (default 30000). Two missed pongs → connection considered dead, `onClose` fired with `code: ABNORMAL`, slot released.
|
||
- Per-tenant connection caps (`maxConnectionsPerTenant`); exceeding → handshake rejected with 429.
|
||
|
||
### 11.5 Reconnect tokens
|
||
|
||
On `onClose` the host issues a `reconnectToken` (HMAC-signed, 5-minute TTL, contains `{tenant, principalId, lastEventId}`). Client reconnects with `?reconnectToken=...`; host validates and resumes subscriptions automatically — the plugin's `onConnect` runs in `resume` mode with the previous subscription set pre-restored.
|
||
|
||
For SSE, the same idea uses `Last-Event-Id` (an HTTP standard) — host buffers the last `eventBufferSize` events per `(tenant, topic)` for `eventBufferMs` and replays on reconnect.
|
||
|
||
Where the event buffer for resume lives (in-memory single-host, Redis when the broker is Redis-backed, or fully pluggable) is one of the open implementation questions in Section 14 — the default direction is "Redis when the broker is Redis, in-memory otherwise."
|
||
|
||
### 11.6 SSE — simpler cousin
|
||
|
||
SSE endpoints have the same subscription model but no client → server channel and no ack semantics. Useful for activity feeds, dashboards, notifications. Reuses the same bus + broker; just a different transport. Falls back gracefully on networks that block WebSocket (corporate proxies, etc.).
|
||
|
||
### 11.7 OTel surface
|
||
|
||
```
|
||
ws.handshake.duration_ms histogram { plugin, path }
|
||
ws.connections gauge { plugin, tenant }
|
||
ws.messages_in_total counter { plugin, path }
|
||
ws.messages_out_total counter { plugin, path }
|
||
ws.broadcasts_total counter { topic, tenant, recipients }
|
||
ws.backpressure_evicted_total counter { plugin }
|
||
ws.heartbeat_failures_total counter { plugin }
|
||
sse.connections gauge { plugin, path, tenant }
|
||
sse.events_sent_total counter { plugin, topic }
|
||
```
|
||
|
||
### 11.8 Worker-tier plugins
|
||
|
||
Worker-isolated plugins still own their `onConnect` / `onMessage` / `onClose` handlers — the host marshals connection events over `api-worker-rpc`. The `Connection` object in the worker is a proxy; `conn.subscribe` / `conn.send` / `conn.close` become async RPC calls. Connection state (principal, tenant, subscriptions) lives in the main host; the worker holds a handle.
|
||
|
||
## 12. Errors & observability
|
||
|
||
### 12.1 Error code namespacing
|
||
|
||
`PLUGIN.<NAME>.<CODE>`. Status mapping declared in manifest, not code. Codes thrown that aren't in the map → 500 with `code` preserved (operator sees real code; client sees opaque 500). Prevents 200-with-error-envelope on a forgotten map entry.
|
||
|
||
### 12.2 Redaction
|
||
|
||
Two layers:
|
||
|
||
- Per-code manifest policy (`errors.redact["PLUGIN.X.Y"]: ["details.field"]`).
|
||
- Auto-redaction via `@suaveplan/data-masking` for emails, credit-card shapes, JWT shapes. Disable-able per-code.
|
||
|
||
Dev: `details` shows raw cause chain. Prod: only what per-code schema allows.
|
||
|
||
### 12.3 OTel — what the host emits for free
|
||
|
||
**Spans** (per HTTP request, nested under `api.request`):
|
||
|
||
```
|
||
api.request
|
||
api.route_match
|
||
api.auth.resolve { auth.adapter, auth.outcome }
|
||
api.tenant.resolve { tenant.resolver_used }
|
||
api.rbac.check { rbac.required, rbac.outcome }
|
||
api.body.parse { body.size_bytes, body.media_type }
|
||
api.body.validate { validation.outcome }
|
||
plugin.handler { plugin.name, plugin.version, route }
|
||
(plugin's own child spans)
|
||
api.response.validate
|
||
api.response.compose { response.size_bytes, response.encoding }
|
||
```
|
||
|
||
**Metrics**:
|
||
|
||
```
|
||
api.requests_total { route, status, plugin, tenant(opt-in) }
|
||
api.request_duration_ms histogram, same labels
|
||
api.body.bytes histogram { plugin, route }
|
||
api.response.bytes histogram { plugin, route }
|
||
api.errors_total { code, plugin, route }
|
||
api.auth.failures_total { reason }
|
||
api.rbac.denials_total { plugin, route, missing_scope }
|
||
api.tenant.unresolved_total { route, principal_present }
|
||
api.validation.failures_total { plugin, route, where }
|
||
api.idempotency.replays_total { plugin, route }
|
||
api.rate_limit.exceeded_total { plugin, route, key_kind }
|
||
plugin.lifecycle_transitions_total { plugin, from, to, outcome }
|
||
plugin.reload_total { plugin, trigger, outcome }
|
||
plugin.reload_duration_ms histogram { plugin, trigger }
|
||
plugin.unsafe_query_total { plugin }
|
||
host.admin_action_total { action, outcome }
|
||
```
|
||
|
||
Tenant label is opt-in to keep cardinality manageable on big multi-tenant deployments.
|
||
|
||
**Logs**: every span emits one structured log line on close. `ctx.logger` is a child logger pre-tagged with `plugin`, `request_id`, `trace_id`, `tenant_id`.
|
||
|
||
### 12.4 Plugin-declared telemetry
|
||
|
||
Plugins declare instruments in manifest (Section 3). Host instantiates them at install; injects `ctx.instruments`. This is the only way plugins emit metrics — manifest is the SoT for what metrics exist.
|
||
|
||
### 12.5 Audit
|
||
|
||
Every state-changing admin action AND every non-2xx response on an auth-required route produces:
|
||
|
||
```jsonc
|
||
{
|
||
"ts": "2026-05-13T08:14:22.014Z",
|
||
"type": "admin.plugin.uninstall" | "request.error" | "auth.failure",
|
||
"principal": { "id": "user_01HZ...", "scopes": ["host:admin"] },
|
||
"tenant": "acme",
|
||
"request_id": "01HZX...",
|
||
"trace_id": "0af7...",
|
||
"subject": "@acme/billing",
|
||
"outcome": "success" | "failure",
|
||
"cause": { "code": "...", "message": "..." }
|
||
}
|
||
```
|
||
|
||
Sink pluggable via `audit` capability. Default `api-audit-stdout` (containers redirect anyway). `api-audit-file` for old-school single-host. Plugin-emitted audit records are host-signed (HMAC over canonical bytes) so tampering is detectable.
|
||
|
||
### 12.6 Health and readiness
|
||
|
||
```
|
||
GET /internal/livez → 200 if process is responding (no plugin checks)
|
||
GET /internal/readyz → 200 only if every plugin's state is "started"
|
||
(or an explicit allowlist); else 409 + structured body
|
||
```
|
||
|
||
Plugins register a `health` capability hook returning `{ status: "ok"|"degraded"|"unhealthy", details? }`. Periodic background check (10s default) populates `readyz`. Plugin reporting `unhealthy` for `>healthFailWindowMs` emits `unhealthy` event; host config can auto-disable repeatedly-unhealthy plugins (off by default).
|
||
|
||
### 12.7 Error boundary placement
|
||
|
||
```
|
||
┌── plugin handler throws ─────────────────┐
|
||
│ │
|
||
HTTP intake → ... → handler → response validation → compose → done
|
||
│ │
|
||
▼ │
|
||
error boundary │
|
||
│ │
|
||
├─ SuaveplanError → manifest statusMap → ──┘
|
||
├─ AbortError → 499 (client gone) ─────────┘
|
||
└─ anything else → 500 + log cause ────────┘
|
||
```
|
||
|
||
Every other middleware in the pipeline is wrapped the same way. The boundary is the only code allowed to mutate the response after a throw.
|
||
|
||
## 13. Testing strategy
|
||
|
||
### 13.1 Three layers
|
||
|
||
```
|
||
Layer 1 Unit per package, per feature, in-memory, 100% coverage
|
||
Layer 2 Plugin contract per plugin, in-memory host, real capabilities
|
||
Layer 3 Integration per plugin, testcontainers + real Bun.serve
|
||
```
|
||
|
||
No `vi.mock` / `vi.stubGlobal` anywhere. Real implementations only.
|
||
|
||
### 13.2 Host testing-utility kit
|
||
|
||
`@suaveplan/api-host/testing` subpath ships **real, in-memory** implementations of every capability. Production-grade adapters that happen to keep state in process memory — not test doubles.
|
||
|
||
```ts
|
||
import { createTestHost } from "@suaveplan/api-host/testing";
|
||
import billingPlugin from "../src/index.js";
|
||
import billingManifest from "../plugin.json";
|
||
import { describe, it, expect } from "@suaveplan/testing/runner";
|
||
|
||
describe("billing plugin", () => {
|
||
it("creates an invoice", { timeout: 5000 }, async () => {
|
||
await using host = await createTestHost({
|
||
plugins: [{ manifest: billingManifest, module: billingPlugin }],
|
||
adapters: {
|
||
router: "in-memory",
|
||
"auth.session": { kind: "static-principal", principal: { id: "u1", scopes: ["billing:write"], tenantId: "acme" } },
|
||
"tenant.resolver": "from-principal",
|
||
db: { engine: "sqlite-memory" },
|
||
secrets: { values: { STRIPE_SECRET_KEY: "sk_test_..." } },
|
||
events: "in-memory",
|
||
jobs: { clock: "fake" },
|
||
},
|
||
});
|
||
|
||
const res = await host.fetch("POST", "/billing/invoices", {
|
||
body: { amountCents: 100_00, currency: "USD" },
|
||
});
|
||
|
||
expect(res.status).toBe(201);
|
||
expect(host.events.published("billing.invoice.created")).toHaveLength(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
`await using` cleans up on scope exit. No `afterEach`, no leaked handles.
|
||
|
||
In-memory adapters live alongside production ones: `api-router-in-memory`, `api-auth-static-principal`, `api-db-sqlite-memory`, `api-events-in-memory`, etc. Same conformance suites apply to both.
|
||
|
||
### 13.3 FakeClock
|
||
|
||
Time and timers caller-supplied via `@suaveplan/types` `Clock` and `TimersLike`. `jobs: { clock: "fake" }` injects `FakeClock` + `FakeTimers` from `@suaveplan/testing`. Tests advance deterministically:
|
||
|
||
```ts
|
||
host.clock.advance("1h");
|
||
await host.jobs.runScheduled();
|
||
```
|
||
|
||
### 13.4 Capability conformance suites
|
||
|
||
Each abstract capability ships a conformance suite in its `./testing` subpath (same pattern `@suaveplan/i18n-shared/testing` uses):
|
||
|
||
```ts
|
||
// @suaveplan/api-router/testing.ts
|
||
export function runRouterConformance(label: string, makeFactory: () => RouterFactory): void {
|
||
describe(`${label} conforms to Router contract`, () => {
|
||
it("matches static routes", { timeout: 5000 }, async () => { /* ... */ });
|
||
it("matches param routes", { timeout: 5000 }, async () => { /* ... */ });
|
||
// ~30 invariants
|
||
});
|
||
}
|
||
```
|
||
|
||
Every adapter runs the suite. Adding a new adapter that passes the suite is automatically known good. Adding a new invariant to the suite automatically enforces it across every existing adapter.
|
||
|
||
Per capability: `runRouterConformance`, `runAuthSessionConformance`, `runTenantResolverConformance`, `runDbAdapterConformance`, `runSecretsConformance`, `runEventsBusConformance`, `runJobsConformance`.
|
||
|
||
### 13.5 Reference plugin as load-bearing test
|
||
|
||
`@suaveplan/api-plugin-example` exercises every contract surface:
|
||
|
||
- Every capability flag the host emits.
|
||
- Every lifecycle hook including failure paths.
|
||
- Routes covering every Section 6 step (public + private, validated + streaming, idempotent, rate-limited, RBAC-gated).
|
||
- Per-tenant AND global background jobs.
|
||
- Publishes and subscribes to events.
|
||
- Intentional schema-violation responses (behind `?failResponse=1`) to integration-test response validation.
|
||
- Migrations spanning two schema versions to exercise upgrade-on-reload.
|
||
|
||
Full integration test runs against real Postgres + real Hono router as part of host CI — host change breaking a plugin contract makes the example fail loudly. Doubles as the canonical onboarding doc.
|
||
|
||
### 13.6 Pre-publish gate
|
||
|
||
Already in CLAUDE.md. Every package: `lint typecheck test test:coverage` green at 100%. Integration gated on `INTEGRATION=1` — daily CI fast, nightly CI runs integration.
|
||
|
||
### 13.7 Scaffold
|
||
|
||
`create-suaveplan-plugin <name>` generates a working package with `plugin.json` skeleton, `definePlugin` skeleton, `createTestHost` test, integration test with `testing-integration-postgres` if DB selected, both vitest configs, migrations stub, README. Author runs `bun test` immediately, sees green, replaces skeleton — never starts from empty.
|
||
|
||
### 13.8 Out of scope
|
||
|
||
- Engine-specific edge cases (a plugin's Postgres migrations). The conformance suite tests the contract; the plugin's own integration tests cover its SQL.
|
||
- Cross-plugin interactions. If `billing` subscribes to `users.user.deleted`, that integration is in `billing`'s own integration test (booting `users` alongside), not in the host.
|
||
|
||
## 14. Open questions
|
||
|
||
Items that remain genuinely undecided and the implementation plan should resolve:
|
||
|
||
| Item | Question |
|
||
|---|---|
|
||
| `definePlugin` typing mechanism | Branded `as const` literal arrays (Section 5.3) is the chosen direction — but the exact `CapabilityMap` lookup-type shape and how `requires` flows into `RequestContext<Caps>` needs spike at implementation time. Codegen remains the fallback if pure-TS inference can't deliver the ergonomics. |
|
||
| Bun ESM cache-bust correctness | The `?v=N` query-suffix approach works in Bun today (Section 8.2). Implementation plan needs to verify behaviour under: HMR mode, `bun build`-emitted bundles, source-map preservation across reload, native binding detection accuracy. Fallback is a separate worker per generation if native cache-bust proves unreliable. |
|
||
| Saga step-replay semantics on host crash | When the host dies mid-step, on restart the orchestrator can re-issue the step. The contract says step handlers are idempotent — but what about step handlers that called a non-idempotent external API (e.g. Stripe `POST /charges`)? Three options: (a) leave it to plugin authors to deduplicate via `Idempotency-Key`; (b) host-issued idempotency key per `{sagaId, stepName, attempt}` injected into `ctx`; (c) "at-most-once" mode opt-in per step. Plan should pick. |
|
||
| 2PC heuristic — when to refuse | Postgres can run thousands of prepared transactions but performance degrades. Plan needs a default cap on outstanding prepared transactions per coordinator and a back-off policy. |
|
||
| GraphQL schema collision resolution | Plan needs to decide whether `graphql.namespace` (wrapper-type approach) is the only solution, or whether name-mangling (auto-prefix with plugin name) is also offered. |
|
||
| WebSocket reconnect persistence | Reconnect tokens carry HMAC state — does the buffered event store (for resume) live in-memory only, in Redis, or pluggable? Default for v1 should be Redis when the broker is Redis-backed, in-memory single-host otherwise. |
|
||
| Worker thread library choice | Bun's `Worker` is the obvious pick. But `child_process` is also viable for stricter isolation (separate process, separate heap). Plan should decide based on benchmarks and on whether worker-vs-process matters to operators. |
|
||
| Sandboxed-tier WASI permission model granularity | WASI's permission model is at the file-descriptor / socket level. Plan needs to map our manifest's `permissions.fs[]` / `permissions.fetch[]` strings down to WASI preopens and capability descriptors. Reference impl probably constrains to "read-only fs preopens of listed paths + outbound socket allowlist." |
|
||
|
||
Genuine non-goals (not deferred — explicitly out of scope):
|
||
|
||
| Item | Why |
|
||
|---|---|
|
||
| Multi-region active-active host coordination | Cross-region consistency for the host metadata DB (plugin ledger, saga state, 2PC coordinator) is a separate design. The foundation assumes one host instance per region with eventual replication being a deployment concern. |
|
||
| Plugin marketplace / discovery service | The foundation supports third-party plugins via npm. A central marketplace + signing infra is its own product. |
|
||
| Built-in feature-flag system | `@suaveplan/feature-flags` already exists; plugins use it via the standard dep. Not a host capability. |
|
||
| Live schema migration across plugin versions | Forward-only migrations (Section 4) are the model. Plugin authors can write reversible migrations if they want, but the host doesn't enforce or run reversals. |
|
||
|
||
## 15. Implementation plan — eight stages, all Wave 1
|
||
|
||
The foundation is one cohesive design. It lands as a single Wave with eight implementation stages, ordered by dependency direction so each stage can be tested against real adapters from the previous stage. No stage is "Wave 2" or "later" — every stage is v1 scope.
|
||
|
||
### Stage 1 — Contracts
|
||
|
||
Pure type/contract packages with no runtime adapters. Locks the surface that every subsequent stage builds against.
|
||
|
||
- `api-types` — every interface used downstream. Zero deps. Types-only build.
|
||
- `api-manifest` — Zod schemas (manifest + companion schemas for `config.schema.json`, route schemas, saga schemas, WS/SSE schemas), validators, dep-graph + capability resolver.
|
||
- `api-router` — abstract `Router`, `Middleware`, `Request`, `Response`, `RouteOptions` types; composition (prefix mounting, middleware chains, blue-green slot management interfaces).
|
||
- `api-graphql` — abstract `Registry`, `Resolver`, `LoaderFactory`, federation contract.
|
||
- `api-ws` + `api-sse` — abstract connection / subscription / broker contracts.
|
||
- `api-auth` + `api-auth-provider` — abstract session-resolver and provider contracts.
|
||
- `api-secrets`, `api-tenant-provider` — abstract contracts.
|
||
- `api-loader` — module resolver + `RollbackJournal` interface (no implementations yet).
|
||
- `api-lifecycle` — FSM definition (states + transitions), no concrete plugin yet.
|
||
|
||
### Stage 2 — Host orchestration core
|
||
|
||
- `api-host` — `CapabilityRegistry`, `definePlugin`, lifecycle driver, hot-reload primitive (Section 8), plugin ledger, admin API, error boundary, error envelope, OTel scaffolding.
|
||
- Persistent state: SQLite metadata DB schema for `suaveplan_plugin_ledger`, `suaveplan_rollback_journal`, `suaveplan_saga_instances`, `suaveplan_saga_steps`, `suaveplan_2pc_transactions`.
|
||
|
||
At end of Stage 2, the host can boot but has zero functional adapters.
|
||
|
||
### Stage 3 — Worker isolation infrastructure
|
||
|
||
- `api-worker-rpc` — `MessagePort` protocol, correlation, AbortSignal forwarding, streaming, error reconstruction, backpressure.
|
||
- `api-worker-host` — worker-side runtime hosting one plugin.
|
||
- `api-router-worker-bridge` — request/response marshalling.
|
||
- Worker-side proxies: `api-db-worker-proxy`, `api-events-worker-proxy`, `api-secrets-worker-proxy`, `api-jobs-worker-proxy`, `api-logger-worker-proxy`.
|
||
|
||
Conformance gates: every Stage-4 capability must run against both direct and worker-proxy surfaces.
|
||
|
||
### Stage 4 — Base adapters (both surfaces)
|
||
|
||
- `api-router-hono` — Hono adapter.
|
||
- `api-auth-jwt`, `api-auth-anonymous`, `api-auth-bearer-static`.
|
||
- `api-tenant-resolver-jwt`.
|
||
- `api-secrets-env`.
|
||
- `api-db-adapter` (bridge to `@suaveplan/db`, supporting Postgres, SQLite, Surreal).
|
||
- `api-tenant-provider-memory`.
|
||
|
||
### Stage 5 — Testing kit + conformance suites
|
||
|
||
- In-memory adapters: `api-router-in-memory`, `api-auth-static-principal`, `api-db-sqlite-memory`, `api-events-in-memory`, `api-ws-broker-in-memory`.
|
||
- Conformance suites under `./testing` subpaths on every abstract package: `runRouterConformance`, `runAuthSessionConformance`, `runTenantResolverConformance`, `runDbAdapterConformance`, `runSecretsConformance`, `runEventsBusConformance`, `runJobsConformance`, `runGraphqlEngineConformance`, `runWsEngineConformance`, `runSseEngineConformance`, `runSagaParticipantConformance`, `run2pcParticipantConformance`.
|
||
- `createTestHost` from `@suaveplan/api-host/testing`.
|
||
|
||
End of Stage 5: every Stage-4 adapter passes its conformance suite under both isolation tiers.
|
||
|
||
### Stage 6 — Distributed transactions + Better-Auth
|
||
|
||
- `api-saga` — orchestrator, manifest-declared workflows, compensable steps, persisted state.
|
||
- `api-2pc` — coordinator with prepared-transaction recovery on restart.
|
||
- `api-auth-better-auth` — Better-Auth wraps both `auth.session` and `auth.provider`, owns its own DB connection.
|
||
|
||
Stage 6 is the first "real-world contract consumer" stage — sagas exercise event-bus + DB + idempotency together; Better-Auth exercises the auth contracts against a non-trivial library. Issues found in Stage 6 may surface design fixes to contracts from Stage 1–4; that's by design.
|
||
|
||
### Stage 7 — GraphQL + WebSocket / SSE
|
||
|
||
- `api-graphql-yoga`, `api-graphql-apollo`.
|
||
- `api-graphql-ui-graphiql`, `api-graphql-ui-altair`.
|
||
- `api-ws-bun`, `api-ws-broker-redis`.
|
||
- `api-sse-bun`.
|
||
- Schema federation glue inside `api-graphql`.
|
||
- Subscription bus + broker abstraction inside `api-ws`.
|
||
|
||
### Stage 8 — Sandboxing + cross-cutting middleware + authoring helpers
|
||
|
||
- `api-sandbox-permissions` — permission model.
|
||
- `api-sandbox-vm` — vm.Context-based sandbox.
|
||
- `api-sandbox-wasm` — WASM/WASI target.
|
||
- `api-rbac`, `api-rate-limit`.
|
||
- `api-openapi` + `api-openapi-ui-{scalar,swagger,redoc}`.
|
||
- `api-audit-stdout`, `api-audit-file`.
|
||
- `api-auth-rolled` — reference rolled-our-own auth.
|
||
- `api-secrets-bun`, `api-secrets-vault`.
|
||
- `api-tenant-resolver-header`, `api-tenant-resolver-subdomain`.
|
||
- `api-plugin-example` — exercises every capability across every isolation tier. Load-bearing integration test.
|
||
- `create-suaveplan-plugin` — scaffold via `@suaveplan/installer`.
|
||
|
||
End of Stage 8: foundation complete. Existing `@suaveplan/api-schemas` and `@suaveplan/sitemap` either migrate to live as plugins (if they want lifecycle hooks) or stay as pure libraries consumed by plugins.
|
||
|
||
### Per-package quality gate
|
||
|
||
Every package in every stage goes through the standard genesis pre-publish gate: `lint typecheck test test:coverage` green at 100%, all files within the 300-line hard cap, co-located `.md` per implementation file, tests imported from `@suaveplan/testing/runner`, no `vi.mock` anywhere.
|
||
|
||
### What "done" looks like
|
||
|
||
- Reference plugin (`api-plugin-example`) deploys cleanly against all three isolation tiers (`trusted`, `isolated`, `sandboxed`).
|
||
- Reference plugin exercises: REST routes (public + private + streaming + idempotent + rate-limited + RBAC-gated), GraphQL types + resolvers + subscriptions, WebSocket endpoint with subscription fan-out, SSE endpoint, owned saga + 2PC participation, scheduled + per-tenant cron jobs, queued jobs, event publish + subscribe, custom error codes with manifest-mapped statuses, custom metrics, multiple secrets, schema migrations across two `schemaVersion` bumps with auto-rollback test.
|
||
- Better-Auth adapter deploys against the reference plugin's auth needs end-to-end, including OAuth flow with at least one provider (e.g. GitHub).
|
||
- Full hot-reload cycle exercised: file-watch in dev triggers blue-green swap, in-flight requests complete on old slot, new slot health probe passes, traffic flips. Bad-module path exercised: failed `start` triggers auto-rollback, plugin enters `rolled_back`, prior version keeps serving.
|
||
- Admin API can install / uninstall / disable / enable / reload / rollback / force-uninstall a plugin end-to-end with audit log entries for each.
|
||
- Conformance suites pass against every adapter under both direct and worker-proxy surfaces.
|