Two shared wasm-boundary fixes surfaced by the messenger port (WZ-013):
1. uuid[] DbValue variant. bnwasm had no uuid-array bind/scan, so every
ANY($1::uuid[]) query broke in the guest ("unsupported argument type
[]uuid.UUID") and messenger worked around it with a ::text[]::uuid[] cast.
Adds a dedicated DbValue.uuid_array_value (abiv1.UuidArray) — distinct from
text[] so the host binds a native uuid[] param (queries keep ::uuid[]) and
scans a uuid[] column straight into []uuid.UUID. Guest toDbValue marshals
[]uuid.UUID; naturalValue/assign parse the canonical strings back into
[]uuid.UUID (nil→NULL, empty stays empty). Pinned by the uuid_array entry in
the shared DbValueFixtures contract (round-trip + driver-value tests green).
2. Trusted identity headers (auth/trustedheaders.go). Context does not cross
the ABI, so guests cannot see the host's verified principal. The SECURE
contract: the host runs its RBAC guard against the signature-verified JWT,
strips any client-supplied copy of the X-Bn-Verified-* headers, and sets
them itself from auth.Get{Public,}UserFromContext; the guest reconstructs
context via auth.TrustedHeaderMiddleware and trusts ONLY those headers.
Guests MUST NOT decode a client cookie/Bearer token for identity — that is a
privilege-escalation bug (a verified public user forging an admin JWT the
guest would honour on a RolePublic method). Documented in docs/wasm-abi.md,
replacing the ambiguous "auth context reaches the guest via HttpRequest
headers" line that invited the insecure decode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
486 lines
28 KiB
Markdown
486 lines
28 KiB
Markdown
# Wasm Plugin ABI (v1)
|
||
|
||
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
|
||
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
|
||
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
|
||
Design rationale: the wasm plugin migration design spec in the cms repo
|
||
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
|
||
|
||
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
|
||
The `abi/` buf module is deliberately separate from the repo-root buf config:
|
||
`proto/` is the shared block/proto git submodule (service API contracts),
|
||
while this ABI is SDK-internal and versions in lockstep with the guest shim,
|
||
so it lives repo-local.
|
||
|
||
## Versioning — `abi_version`
|
||
|
||
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
|
||
**major** version the plugin was built against. Current value: **1**.
|
||
|
||
- The host **rejects** any manifest whose major version it does not support —
|
||
at install/publish time (manifest read) and again at `DESCRIBE`
|
||
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
|
||
newer guest shim can refuse an older host symmetrically).
|
||
- Within a major version, evolution is protobuf-additive only: new fields,
|
||
new `Hook` values, new capability methods. Removing or renaming anything
|
||
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
|
||
in `abi/buf.yaml`) enforces this against the previous commit.
|
||
|
||
## Module lifecycle — REACTOR mode (`_initialize`, no `_start`)
|
||
|
||
Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for
|
||
`go:wasmexport`; this repo's floor is higher — check `go.mod`):
|
||
|
||
```
|
||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
|
||
```
|
||
|
||
with one boilerplate main file next to the untouched Registration:
|
||
|
||
```go
|
||
//go:build wasip1
|
||
|
||
package main
|
||
|
||
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
|
||
|
||
func init() { wasmguest.Serve(Registration) }
|
||
func main() {} // never called — reactor mode
|
||
```
|
||
|
||
A reactor module exports `_initialize` instead of `_start`. The host MUST
|
||
run `_initialize` exactly once per instance **before any `bn_invoke`**
|
||
(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package
|
||
init funcs — hence `Serve`, which stores the registration and returns.
|
||
`main` exists only to satisfy the linker and is never called.
|
||
|
||
> Command mode (plain `go build`) does NOT work and must not be used
|
||
> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start`
|
||
> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go
|
||
> deadlock detector and traps, while a returning `main` exits and closes
|
||
> the module — either way the exports are never callable. The original
|
||
> blocking-main design was amended to reactor mode for this reason.
|
||
|
||
## Building & packing (`ninja plugin build`)
|
||
|
||
`ninja plugin build` turns a plugin repo into a `.bnp` artifact in one command —
|
||
no Docker/podman, just the local Go toolchain (**≥ 1.24**, enforced with a clear
|
||
error) plus wazero. It replaces the in-container `.so` compile and the old
|
||
`make build-so`. Steps:
|
||
|
||
1. Parse `plugin.mod` for `name`/`version` (both required).
|
||
2. `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .`
|
||
(reactor mode).
|
||
3. Extract `manifest.pb`: instantiate `plugin.wasm` with wazero and drive one
|
||
`HOOK_DESCRIBE`. The host functions are **stubbed to fail** — DESCRIBE must
|
||
not need a live host, so a plugin that reaches a capability at
|
||
register/describe time (e.g. a `db.*` call from `Register`) gets an
|
||
actionable error **naming the offending method** instead of a hang.
|
||
4. Stamp `manifest.data_dir` from `plugin.mod` (see below).
|
||
5. Pack a `tar.zst`: `plugin.wasm`, `plugin.mod`, `manifest.pb`, plus
|
||
`migrations/`, `schemas/`, `assets/`, and `web/dist` (Module Federation
|
||
output, flattened under `web/`) when present.
|
||
|
||
```
|
||
ninja plugin build [--dir .] [-o <name>-<version>.bnp]
|
||
ninja plugin verify <file.bnp>
|
||
```
|
||
|
||
`ninja plugin verify` re-runs the CMS `.bnp` reader's checks standalone (layout,
|
||
required members, path-safety + size caps on extraction, `abi_version` support,
|
||
and manifest name == `plugin.mod` name) so CI and the registry can gate uploads.
|
||
These rules are a **deliberate duplication** of the reader
|
||
(`cms backend/plugin/bnp/reader.go`); core cannot import the CMS module, and
|
||
WO-WZ-010's integration suite keeps the two in lockstep.
|
||
|
||
### Makefile convention — `make build-wasm`
|
||
|
||
Plugin repos expose a `build-wasm` target (replacing `build-so`) that just calls
|
||
the CLI:
|
||
|
||
```make
|
||
.PHONY: build-wasm
|
||
build-wasm:
|
||
ninja plugin build
|
||
```
|
||
|
||
### `plugin.mod` `data_dir`
|
||
|
||
`plugin.mod` may set an optional first-class boolean:
|
||
|
||
```toml
|
||
[plugin]
|
||
name = "my-plugin"
|
||
version = "0.1.0"
|
||
data_dir = true # request a persistent per-plugin /data preopen at load
|
||
```
|
||
|
||
`data_dir` is a real field on the mod parser (not an arbitrary key) precisely so
|
||
the CLI's mod round-trip cannot silently drop it — `writeMod` reconstructs
|
||
`plugin.mod` from known struct fields only. `ninja plugin build` copies it into
|
||
`PluginManifest.data_dir`; OFF by default.
|
||
|
||
## Calling convention (ptr+len, packed u64)
|
||
|
||
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
|
||
protobuf bytes in guest linear memory. The guest exports (via
|
||
`go:wasmexport`):
|
||
|
||
| Export | Signature | Purpose |
|
||
|---|---|---|
|
||
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
|
||
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
|
||
|
||
Host functions (guest→host capability calls) live in wasm import module
|
||
**`blockninja`** and use the same shape in reverse. There is exactly ONE
|
||
generic import rather than one symbol per capability family:
|
||
|
||
```
|
||
(blockninja) host_call(ptr: u32, len: u32) → packed: u64
|
||
```
|
||
|
||
The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose
|
||
`method` string — `"<family>.<snake_method>"`, including the `db.*` driver
|
||
methods — already selects the family); the host returns a packed `u64`
|
||
framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`.
|
||
The guest shim releases that buffer after decoding, so the host must not
|
||
reuse it. Per-family import symbols were considered and rejected (decision,
|
||
WO-WZ-002): they would add ~40 declarations on both sides for zero type
|
||
safety, since the payloads are opaque protobuf bytes either way.
|
||
|
||
Buffers MUST come from `bn_alloc` on both paths — the guest rejects a
|
||
`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`),
|
||
and treats an unknown host-call response pointer the same way.
|
||
|
||
A `packed` value of `0` means the callee could not even produce an envelope
|
||
(allocation failure / trap); the caller treats it as
|
||
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
||
|
||
> A logically-empty response is **not** packed `0`. A successful hook whose
|
||
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
|
||
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
|
||
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
|
||
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
|
||
> `len==0 → return 0` shortcut made every successful empty-response hook —
|
||
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
|
||
> instance. The cms host in WO-WZ-006 must likewise not conflate a
|
||
> zero-length payload with a missing envelope.)
|
||
|
||
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
||
concurrency comes from the per-plugin instance pool.
|
||
|
||
### Per-instance state: block pools & `HostServices`
|
||
|
||
Because concurrency is per-instance, guest state a render path depends on must be
|
||
established on **every** pooled instance, not just the one the host runs the
|
||
`HOOK_LOAD` hook on. `HOOK_LOAD` fires exactly once per plugin (on one acquired
|
||
instance); the deps-receiving entry points (`HTTPHandler`/`JobHandlers` init)
|
||
init lazily and only on instances that serve those hooks. A block render
|
||
(`HOOK_RENDER_BLOCK`) receives **no** services — only `ctx` + content — so a
|
||
DB-backed block cannot get its pool from Load.
|
||
|
||
The escape hatch is `wasmguest.HostServices()`: it returns the `CoreServices`
|
||
bound at `_initialize` on **every** instance (live `db.*` `Pool` + capability
|
||
stubs). A DB-backed block registers its pool from `HostServices().Pool` inside
|
||
`Register` (which `newGuest` runs on every instance, after the Pool is bound) —
|
||
see symposium's `register.go`. In native/DESCRIBE builds `HostServices()` returns
|
||
the zero value (Serve never ran), so callers nil-check `Pool`.
|
||
|
||
### Instance pool sizing & memory budget (WO-WZ-012)
|
||
|
||
Defaults (`wasmhost.DefaultConfig`, overridable via `WASM_POOL_MAX_SIZE` /
|
||
`WASM_MEMORY_LIMIT_MB`): **pool of 4 live instances per plugin, 512 MiB linear
|
||
memory cap per instance**, 30 s call deadline, 5 m idle TTL. The compiled module
|
||
(machine code + data segments) is compiled **once** per plugin and shared across
|
||
its pool; only each instance's linear memory + Go heap is per-instance.
|
||
|
||
Measured against the ported **symposium** plugin (45 MiB wasm — the fleet's
|
||
largest; representative public block mix = wiki index + course index + community
|
||
feed, one sqlc list query each over the `db.*` bridge, real wazero + Postgres):
|
||
|
||
| Metric | Value |
|
||
|---|---|
|
||
| Page render (3-block mix) p50 / p95 / p99 | **5.5 ms / 9.4 ms / 13.9 ms** |
|
||
| Per-block render (incl. DB round-trip) | ~1.8–3 ms |
|
||
| Process RSS, pool=1 (compile + 1 instance) | ~400–500 MiB |
|
||
| Process RSS, pool=2 under concurrent load | ~540 MiB |
|
||
| Process RSS, pool=4 under concurrent load | ~628 MiB |
|
||
| Marginal cost per extra pooled instance | ~50–130 MiB |
|
||
|
||
The ~400 MiB fixed cost is dominated by wazero compiling the 45 MiB module (once,
|
||
shared); marginal instances are cheap. Against the default orchestrator container
|
||
limit (`CONTAINER_MEMORY_MB` = **4096 MiB**), symposium at pool=4 (~628 MiB) plus
|
||
two smaller site-plugin pools + the CMS base fits comfortably. **Decision: keep
|
||
pool=4 / 512 MiB.** Memory-constrained deployments (≤1 GiB containers running the
|
||
largest plugins) should lower `WASM_POOL_MAX_SIZE` to 2.
|
||
|
||
Latency gate: the `.so` baseline for the identical block mix is unavailable on
|
||
`cms` main (the `.so` loader/builder was deleted in the big-bang cutover,
|
||
a04277ee2/da6177e1e), so a direct ≤25% p95 regression comparison cannot be run.
|
||
Absolute wasm numbers are recorded above; the wasm boundary overhead is small
|
||
relative to the per-block DB round-trip (~sub-ms of the ~3 ms), so a material
|
||
regression is not expected — recorded here for **explicit sign-off** per the
|
||
adjusted gate. Bench harness: `cms/backend/plugin/wasmintegration/symbench`.
|
||
|
||
## Hook catalog (`invoke.proto`)
|
||
|
||
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}` →
|
||
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
|
||
|
||
| Hook | Request / Response | Fires |
|
||
|---|---|---|
|
||
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
|
||
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
|
||
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
|
||
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
|
||
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
|
||
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
|
||
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
|
||
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
|
||
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
|
||
|
||
## Capability calls (`capability.proto`, `db.proto`)
|
||
|
||
Guest→host. Envelope: `HostCallRequest{method, payload}` →
|
||
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
|
||
each pair mirrors one Go interface method from `CoreServices` 1:1
|
||
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
|
||
|
||
| Family | Methods | Go surface |
|
||
|---|---|---|
|
||
| `content` | `get_author_profile`, `get_page`, `get_post`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | `content.Content` |
|
||
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting` | `settings.Settings`, `settings.Updater` |
|
||
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
|
||
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
|
||
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
|
||
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
|
||
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
|
||
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
|
||
| `media` | `deposit` | `plugin.Media` |
|
||
| `email` | `send` | `plugin.EmailSender` |
|
||
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
|
||
| `bridge` | `register_service`, `get_service` | `plugin.PluginBridge` |
|
||
| `jobs` | `submit` | `plugin.JobRunner` |
|
||
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
|
||
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
|
||
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
|
||
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
|
||
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
||
|
||
The guest half of the SDK implements the existing Go interfaces as stubs
|
||
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
|
||
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
|
||
the wasm shim binds `call` to the real `host_call` transport, tests inject a
|
||
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
|
||
instead of nil-panicking.
|
||
|
||
### Method disposition (every `CoreServices` member)
|
||
|
||
No silent gaps: each member is either a guest stub or served host-side.
|
||
|
||
| Member | Disposition |
|
||
|---|---|
|
||
| `Content` (7 methods) | **stub** — `caps/content.go` |
|
||
| `Settings` / `SettingsUpdater` | **stub** — `caps/settings.go` (one value, both fields) |
|
||
| `Gating` | **stub** — `caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
|
||
| `Crypto` | **stub** — `caps/crypto.go` |
|
||
| `Menus` | **stub** — `caps/menus.go` |
|
||
| `Datasources` | **stub** — `caps/datasources.go` |
|
||
| `PublicUsers` | **stub** — `caps/users.go` |
|
||
| `Subscriptions` | **stub** — `caps/subscriptions.go` |
|
||
| `Media` | **stub** — `caps/media.go` |
|
||
| `ToolRegistry` + `AITextCall` | **stub** — `caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
|
||
| `EmailSender` | **stub** — `caps/email.go` |
|
||
| `Bridge` | **stub** — `caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
|
||
| `ReviewSubmitter` | **stub** — `caps/reviews.go` |
|
||
| `BadgeRefresher` | **stub** — `caps/badges.go` |
|
||
| `JobRunner` | **stub** — `caps/jobs.go` |
|
||
| `EmbeddingService` | **stub** — `caps/embeddings.go` |
|
||
| `RAGService` | **stub** — `caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
|
||
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
|
||
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. The caller's **verified** identity reaches the guest via **trusted identity headers** (see "Trusted identity headers" below) — never by decoding a client token. |
|
||
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
|
||
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
|
||
|
||
Interface satisfaction is proven at compile time by a `var _ <iface> =
|
||
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
|
||
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
|
||
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
|
||
crosses the ABI for real.
|
||
|
||
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
|
||
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
|
||
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
|
||
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
|
||
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
|
||
zero value / best-effort on transport failure.
|
||
|
||
`CoreServices` members that do **not** cross as capability calls:
|
||
|
||
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
|
||
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
|
||
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
|
||
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
|
||
chain's deadline so a guest can never pin a connection. **Guest side
|
||
(WO-WZ-004):** `core/plugin/wasmguest/bnwasm` implements this over the
|
||
transport as two surfaces — a `database/sql` driver registered as `"bnwasm"`,
|
||
and a `plugin.Pool` handing out a `pgx.Tx`-shaped value. The latter is the
|
||
primary path: current plugins' sqlc configs use `sql_package: "pgx/v5"`, so
|
||
their generated `DBTX` needs `pgconn.CommandTag`/`pgx.Rows`/`pgx.Row` (which
|
||
`database/sql` cannot produce), and the `Pool`/`Tx` satisfy it with no source
|
||
edits. `DbError` surfaces as `*pgconn.PgError` (SQLSTATE preserved for
|
||
`errors.As`). Named args (`pgx.NamedArgs`/`QueryRewriter`) and nested
|
||
transactions/savepoints are rejected with clear errors — no fleet plugin uses
|
||
either. The DbValue↔Go scan mapping is pinned in the exported
|
||
`bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors.
|
||
**`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a
|
||
repeated string with no per-element NULL, so a Postgres `text[]` like
|
||
`{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as
|
||
a whole can still be SQL NULL (nil `[]string` → `DbValue_Null`); only a NULL
|
||
*inside* the array is unrepresentable. The host executor must honor this same
|
||
limit (encode a NULL element as `""` or reject it), not invent a sentinel.
|
||
**`uuid[]` (`DbValue.uuid_array_value`, `abiv1.UuidArray`):** a first-class
|
||
variant distinct from `text[]`, so a query keeps native `ANY($1::uuid[])`
|
||
(no `::text[]::uuid[]` cast workaround) and a `uuid[]` column scans straight
|
||
into `[]uuid.UUID`. Each element is a canonical UUID string (like the scalar
|
||
`uuid_value`); nil `[]uuid.UUID` → `DbValue_Null`, empty stays a non-NULL
|
||
empty `uuid[]`. The host binds a native `[]uuid.UUID` parameter and reads a
|
||
`UUIDArrayOID` column back into this variant. Pinned by the `uuid_array`
|
||
entry in `bnwasm.DbValueFixtures`.
|
||
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
|
||
`manifest.rbac_method_roles`.
|
||
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
|
||
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
|
||
declaration; the host constructs and mounts the handlers.
|
||
- `RAGService.RegisterContentFetcher` → static
|
||
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
|
||
callback inversion.
|
||
|
||
## Trusted identity headers
|
||
|
||
Context values do **not** cross the ABI, so a guest cannot see the
|
||
`auth.Claims` / `auth.PublicClaims` the host's middleware built. A guest that
|
||
needs the caller's identity (its Connect RPCs call
|
||
`auth.GetUserFromContext` / `GetPublicUserFromContext`) reads it from
|
||
**host-set trusted headers**, and **only** from those.
|
||
|
||
**Trust model (host-enforced, guest-trusting):**
|
||
|
||
1. Upstream CMS auth middleware verifies the JWT signature and populates
|
||
`r.Context()` with the principal. The wasm mount's RBAC guard then runs
|
||
`rbac.Authorize` against that **verified** principal (deny-by-default,
|
||
live-user validator) *before* the request is forwarded.
|
||
2. When forwarding over `HOOK_HANDLE_HTTP`, the host **strips any
|
||
client-supplied copy** of the trusted headers from the request and **sets
|
||
them itself** from the verified context principal. A guest therefore trusts
|
||
them unconditionally — they are not attacker-controllable.
|
||
3. The guest reconstructs its context with `auth.TrustedHeaderMiddleware`
|
||
(wrap it around your `HTTPHandler`) or `auth.ContextFromTrustedHeaders`.
|
||
No token parsing, no signature check — the guest holds no signing secret by
|
||
design.
|
||
|
||
The headers (`core/auth/trustedheaders.go`, canonical MIME form):
|
||
|
||
| Header | Source |
|
||
|---|---|
|
||
| `X-Bn-Verified-User-Id` / `X-Bn-Verified-Role` / `X-Bn-Verified-Email` | admin `auth.Claims` |
|
||
| `X-Bn-Verified-Public-User-Id` / `X-Bn-Verified-Public-Username` / `X-Bn-Verified-Public-Email` | public `auth.PublicClaims` |
|
||
|
||
> **SECURITY — do NOT decode a client token in the guest.** The host forwards
|
||
> the raw request, so its `Cookie` / `Authorization` headers are
|
||
> attacker-controlled across the boundary. Decoding an unsigned `access_token`
|
||
> cookie (or Bearer JWT) as identity in the guest is a **privilege-escalation
|
||
> bug** (a verified public user can forge an admin JWT the guest would then
|
||
> honour on a `RolePublic` method). Identity comes from the trusted headers
|
||
> above and nowhere else.
|
||
|
||
## Error semantics
|
||
|
||
`AbiError{code, message}` travels in `InvokeResponse.error` and
|
||
`HostCallResponse.error`:
|
||
|
||
| Code | Meaning | Instance consequence |
|
||
|---|---|---|
|
||
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
|
||
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
|
||
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
|
||
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
|
||
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
|
||
| `ABI_ERROR_CODE_TX_EXPIRED` | A `db.*` call named a transaction handle the host already expired (dropped at the call-chain deadline, WO-WZ-007). | None — retryable. The guest maps it to `bnwasm.ErrTxExpired`; plugin code re-runs the unit of work in a fresh transaction. |
|
||
|
||
`ABI_ERROR_CODE_TX_EXPIRED` is emitted by the cms `dbexec` side (adopted
|
||
separately from this WO) in place of the earlier INTERNAL+message-marker
|
||
workaround, so guests can distinguish a retryable tx expiry from a real fault
|
||
via `errors.Is(err, bnwasm.ErrTxExpired)`.
|
||
|
||
Host-function errors surface to plugin code as ordinary Go errors via the
|
||
guest SDK. Repeated instance failures trip the existing
|
||
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
|
||
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
|
||
sqlc/pgx error handling keeps working.
|
||
|
||
## Manifest ↔ `PluginRegistration` mapping
|
||
|
||
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
|
||
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
|
||
Field-by-field:
|
||
|
||
| `PluginRegistration` field | Wire counterpart |
|
||
|---|---|
|
||
| `Name` | `PluginManifest.name` |
|
||
| `Version` | `PluginManifest.version` |
|
||
| `Dependencies` | `dependencies` (`Dependency`) |
|
||
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
|
||
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
|
||
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
|
||
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
|
||
| `SettingsSchema` | `settings_schema` (JSON bytes) |
|
||
| `ThemePresets` | `theme_presets` (JSON bytes) |
|
||
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
|
||
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
|
||
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
|
||
| `SettingsPanel` | `settings_panel` |
|
||
| `AdminPages` | `admin_pages` (`AdminPage`) |
|
||
| `CSSManifest` | `css_manifest` (`CssManifest`) |
|
||
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
|
||
| `JobHandlers` | `job_types` + `HOOK_JOB` |
|
||
| `AIActions` | `ai_actions` (`AiAction`) |
|
||
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
|
||
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
|
||
| `Load` | `has_load_hook` + `HOOK_LOAD` |
|
||
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
|
||
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
|
||
| `RequiredIconPacks` | `required_icon_packs` |
|
||
| *(none — `plugin.mod` `data_dir`)* | `data_dir` (bool). Not a `PluginRegistration` field: the grant lives in `plugin.mod`, which the guest code cannot see, so `ninja plugin build` stamps `PluginManifest.data_dir` from `plugin.mod` after DESCRIBE. The `.bnp` reader also reads it straight from `plugin.mod` as a fallback. |
|
||
|
||
## Render context
|
||
|
||
`RenderContext` (`render.proto`) is the explicit envelope of every value
|
||
blocks read from `ctx` today (`core/blocks/context.go`): request info,
|
||
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
|
||
category/master-page, requested path, injected/expected slots, editor flag,
|
||
block + page IDs, human-proof banner, detail row, theme variables JSON, and
|
||
locale.
|
||
|
||
Function-valued context entries cannot serialize; their v1 mapping:
|
||
|
||
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
|
||
- `SlotRenderer` / `MediaResolver` / `EmbedResolver` → **open items** (below).
|
||
|
||
## Open items (flagged for runtime WOs — additive, no major bump needed)
|
||
|
||
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing
|
||
its guest-side `Handler` needs a host→guest hook (e.g. `HOOK_AI_TOOL_CALL`).
|
||
Deliberately not added here — the WO fixes the v1 hook catalog.
|
||
- **Job progress**: `plugin.JobHandlerFunc`'s `progress(current, total,
|
||
message)` callback needs a `jobs.progress` host function.
|
||
- **Bridge calls**: `bridge.register_service`/`get_service` cover
|
||
registration/lookup only; typed cross-plugin *invocation* (today: shared
|
||
in-process Go values) needs a runtime design.
|
||
- **Directory extension callbacks**: `panel_section_count` /
|
||
`pin_decorator_count` declare the guest callbacks; invoking them needs a
|
||
hook.
|
||
- **Slot/media/embed resolvers in render**: container-slot rendering and
|
||
media/embed resolution during a guest render need host functions (or host-
|
||
side pre-rendering into `RenderContext`).
|