diff --git a/developing-blockninja-plugins/SKILL.md b/developing-blockninja-plugins/SKILL.md index 61da024..4c31493 100644 --- a/developing-blockninja-plugins/SKILL.md +++ b/developing-blockninja-plugins/SKILL.md @@ -7,31 +7,69 @@ description: Use when creating, modifying, building, or publishing BlockNinja CM ## Overview +> **⚠️ If you remember `.so` instructions, they are obsolete.** Standalone plugins are now +> **wasm** (wazero, WASI reactor mode) — the big-bang migration landed 2026-07-03 and the +> `.so` loader/builder were deleted from cms (`so_loader.go`, `internal/builder/`, cms +> commit a04277ee2). There is NO `make build-so`, NO `plugin.Open`, NO CGO, NO in-container +> compile, NO host/plugin Go-version lock-step, NO `copy-plugin-source` deploy, and NO +> go:embed'd web bundle. Instances **download** a prebuilt `.bnp` artifact — they never +> compile. A registry version published as a source archive is **rejected** at install +> ("legacy source archive; republish as .bnp"). + Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at: - **SDK source:** `~/src/blockninja/core` — import prefix `git.dev.alexdunmow.com/block/core/...` is the ONLY one allowed; never `block/cms/...` -- **Canonical guide:** `~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md` -- **Publish workflow + hard rules:** `~/src/blockninja/plugins/CLAUDE.md` -- **Exemplars:** `plugins/messenger` (compact), `plugins/symposium` (service-heavy: RPC, jobs, AI, embeddings) +- **ABI contract (authoritative):** `~/src/blockninja/core/docs/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir` +- **Architecture spec:** `~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md` +- **Canonical guide (registration concepts, blocks, provisioning):** `~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md` +- **Exemplars:** `plugins/testplugin` — THE reference wasm plugin, every surface + failure endpoints (see its README surface table); `plugins/symposium` — the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention) Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own data/UI → plugin (decision table in PLUGIN_DEVELOPMENT.md). -## Doc routing +## Anatomy of a wasm plugin -| Working on | Read first (under `cms/docs/`) | +`PluginRegistration` is **unchanged** from the .so era — same fields, same `Register`, +same `CoreServices`. What changed is how it's served. Boilerplate `main.go` +(testplugin/main.go verbatim): + +```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 +``` + +Compile: `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .` +(Go ≥ 1.24; reactor mode — plain `go build` command mode does NOT work, the exports are +never callable). You never run this by hand: `ninja plugin build` does it. + +At publish time a DESCRIBE probe instantiates the module once and captures every static +registration surface into `manifest.pb`; the CMS loader reads that without instantiating +the module. Dynamic work (block render, HTTP, jobs, Load/Unload, RAG fetch, media hooks) +crosses the ABI as protobuf hooks into a **pool of instances** (default 4, 512 MiB memory +cap each, 30 s per-call deadline). Full hook catalog + manifest↔Registration field map: +`wasm-abi.md`. + +**DESCRIBE constraint:** host functions are stubbed to fail during the probe, so +`Register` (and anything reached from it) must not call capabilities or the DB — it gets a +named error at build time. Load-time work belongs in `Load`. + +## Worked example — plugins/testplugin + +| Surface | File | |---|---| -| Scaffold, registration, custom block types, `CoreServices` | PLUGIN_DEVELOPMENT.md | -| **Section/content blocks — html block vs custom; menus; live vs pre-render** | **[html-blocks.md](html-blocks.md)** (in this skill) — read BEFORE writing a custom block type | -| Depositing media into the library (seed `EnsureMedia` / runtime `CoreServices.Media`) | PLUGIN_DEVELOPMENT.md §Depositing Media | -| Plugin migrations / DB tables — each plugin owns a Postgres schema (named after it), never `public` | PLUGIN_DEVELOPMENT.md §Migrations | -| **Platform data tables** — provision via `Provisioner.EnsureDataTable` (`RegisterWithProvisioner`); the row-as-JSONB store the admin Data Platform / buckets / **data Views** read, idempotent + **View-rootable**. NOT the plugin's own Postgres schema above | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables | -| Public HTTP routes (webhooks, widgets) | PLUGIN_HTTP_HANDLERS.md | -| Load/Unload, runtime state, goroutines | PLUGIN_LIFECYCLE_HOOKS.md | -| Themes, templates, master pages, CSS | TEMPLATE_PLUGINS.md | -| Theme preview screenshots (showcase preset, gallery instances, `ninja theme screenshot`, registry `previewImageUrl`) | theme-previews.md | -| Block editor / settings UI (Module Federation) | PLUGIN_EDITOR_SDK.md | -| .so build pipeline, loader internals | compiled-plugin-architecture.md | -| Release, registry, install | `plugins/CLAUDE.md` (not cms/docs) | +| Blocks (`rendercontext`, `content`) + template (`testplugin_page`) | `registration.go` | +| Connect service via `plugin.NewConnectServiceBinding` (RBAC roles in manifest) | `service.go` | +| Raw chi routes + failure endpoints (`/panic`, `/slow`, `/forbidden-sql`, `/alloc`) | `httphandler.go` | +| Job handler, media hooks, Load/Unload, RAG fetcher, deps stashing | `registration.go` | +| Goose migration + sqlc (pgx/v5) queries | `migrations/`, `sql/queries/`, `db/` | +| Settings schema, theme preset (go:embed'd JSON) | `assets/` | +| `data_dir = true` grant + `/data` round-trip | `plugin.mod`, `onLoad` | ## Blocks: prefer the built-in `html` block @@ -44,35 +82,166 @@ custom block only when you need a structured field-form editor or render logic a express. Custom keys also risk red `block-fallback` boxes when a key isn't registered; built-in `html` never does. Decision guide, render modes, menu wiring, and gotchas: **[html-blocks.md](html-blocks.md)**. -## Verifying SDK symbols +Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below. -Before using an unfamiliar SDK call, check: (1) the pinned SDK the build compiles against — `go doc git.dev.alexdunmow.com/block/core/plugin CoreServices` from the plugin dir; (2) nearest exemplar usage in messenger/symposium; (3) the CMS-side implementation in `cms/backend` for semantics. +## Database — sqlc unchanged, sandboxed role -**Missing capability** ⇒ two sanctioned paths only: extend `block/core` (long-term; needs SDK release + re-pin + image rebuild), or vendor the cms-internal package into the plugin's `internal/` with a provenance header (established convention — check-safety vendors cms `internal/theme` this way). NEVER `replace` directives; NEVER `block/cms` imports. +sqlc-generated code (`sql_package: "pgx/v5"`) works **without source edits**: at runtime +`deps.Pool` is a `bnwasm.Pool` (pgx-shaped `Pool`/`Tx` over the ABI's `db.*` messages). +Autocommit: type-assert the pool to your `db.DBTX` (`registration.go` `openQueries`). +Transactions: `deps.Pool.Begin(ctx)` → `db.New(tx)` → `Commit/Rollback` (`httphandler.go` +`/db-tx`). Errors surface as `*pgconn.PgError` with real SQLSTATEs. -**Version pinning:** `go.mod` pins `block/core` to exactly what the CMS uses: -`grep 'block/core ' ~/src/blockninja/cms/backend/go.mod` +Hard rules and limits: -## Gates — run before commit / bump / publish +- **Own schema ONLY.** Queries run under a per-plugin Postgres role (`plugin_`) + granted only on schema ``. `public.*` is **permission denied (SQLSTATE 42501) by + design** — core data goes through `CoreServices` capability interfaces, never SQL. +- **No raw SQL** — sqlc for static, Bob for dynamic (workspace rule, applies to plugins). +- Savepoints/nested transactions and named args (`pgx.NamedArgs`) are **rejected** with + clear errors. +- `text[]` values cannot carry NULL **elements** (collapse to `""`); the whole array can + still be NULL. +- A tx handle dies with the call chain's deadline: `errors.Is(err, bnwasm.ErrTxExpired)` + is retryable — re-run the unit of work in a fresh transaction. +- Migrations are plain Goose SQL in `migrations/` — run by the **host** (never cross the + boundary). `db/*.sql.go` is committed; regenerate with `sqlc generate`. + +## Capabilities — CoreServices works as before, know the dispositions + +Plugin code compiles against the same Go interfaces (`deps.Content`, `deps.Settings`, +`deps.Crypto`, `deps.Menus`, `deps.RAGService`, …) — the guest SDK ships them as stubs +marshaling to host functions. Full family/method table + disposition of every +`CoreServices` member: **`wasm-abi.md`**. The ones that behave differently: + +- `Interceptors`, `CoreServiceBindings` — **host-side**; RBAC method roles merge from the + manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran). +- `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`. +- `Bridge.GetService` reports availability but returns `nil` (typed values can't cross — + open item); `RegisterService` forwards names only. +- `RAGService.RegisterContentFetcher` — records guest-side + a manifest declaration; the + host calls back via `HOOK_RAG_FETCH`. +- Methods without an error channel (`Slugify`, `EvaluateAccess`, `Bridge.*`, …) degrade to + zero values on transport failure. + +### Per-instance state (the #1 wasm gotcha) + +Concurrency = an instance pool; each instance is a separate Go runtime with its own +globals. `Load` fires **once, on one instance** — globals it sets do NOT exist on the +others. Two sanctioned patterns (both in testplugin/symposium): + +1. **Stash deps in every deps-receiving entry point** (`Load`, `HTTPHandler`, + `JobHandlers`) so deps-less hooks (media hooks, `Unload`) can reach the DB — testplugin's + `pkgDeps`/`stashDeps`. Instances are single-threaded, so a plain global is safe. +2. **DB-backed blocks:** `HOOK_RENDER_BLOCK` receives no services. Get the pool inside + `Register` (which runs on every instance) via `wasmguest.HostServices().Pool` — + **nil-check it**: in native/DESCRIBE builds it's the zero value. See symposium + `register.go`. + +## Sandbox constraints — write reload-safe code + +- **No filesystem, no network, no env** in the guest. One opt-in exception: `data_dir = true` + in `plugin.mod` grants a read-write preopen at guest `/data` — a per-plugin directory on + a persistent volume (`instance--plugin-data`), shared across the plugin's pool + instances, deleted on uninstall. Off by default; the grant is visible at publish time. + Absent at DESCRIBE time — tolerate the write failure (testplugin `onLoad`). +- **Buffered HTTP only** — no SSE/WebSocket/streaming from plugin endpoints. Realtime + features use the core realtime system (`@block-ninja/ui` hooks / core topics). +- **Panics recover to errors**: an in-hook panic → INTERNAL error → 5xx; the host and other + plugins survive. Deadline overruns and OOM discard the instance; the pool refills. + Error-code table (INTERNAL/DECODE/UNIMPLEMENTED/DEADLINE/PERMISSION/TX_EXPIRED): + `wasm-abi.md`. +- **Hot swap**: plugin updates swap live (`SwapPlugin`) with no restart — never assume an + instance lives long, or that in-memory state survives an update. Persist in the DB or + `/data`. +- No `os.Getenv` for secrets (nothing is there anyway) — plugin settings or `CoreServices`. + +## Build → verify → publish + +`plugin.mod` is still the manifest (name/version/kind/scope/categories/tags, plus the new +first-class `data_dir` bool). `kind` is still **frozen at first publish**. Pin a recent +`block/core`; exact-version lock-step with the CMS is dead (testplugin builds on v0.15.1 +while cms runs v0.15.2) — compatibility is gated by ABI major (`abi_version`, currently 1). ```bash -make # CGO build; templ/sqlc drift surfaces here -cd ~/src/blockninja/check-safety && go run . # MUST exit 0 -make archive-check # proves `git archive HEAD` (= what publish ships) compiles +ninja plugin build --dir . # wasip1 compile + DESCRIBE probe → -.bnp +ninja plugin verify -.bnp # loader-identical checks: layout, abi_version, name match +cd ~/src/blockninja/check-safety && go run . # MUST exit 0 (unchanged mandate) + +ninja plugin bump patch # commits plugin.mod — does NOT git-tag +git tag vX.Y.Z && git push origin main vX.Y.Z # --follow-tags skips lightweight tags +ninja plugin build --dir . # rebuild at the bumped version +ninja plugin publish --bnp -.bnp # --bnp is REQUIRED: without it publish + # ships a source archive, which instances REJECT ``` -check-safety traps: plugin `web/` lint extends `../../../cms/web/eslint.config.js`, so it only runs from the canonical sibling layout; raw `