Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:51:00 +08:00

333 lines
12 KiB
Go

package wasmguest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"connectrpc.com/connect"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/caps"
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
// AbiVersion is the ABI major version this guest shim implements
// (PluginManifest.abi_version). See core/docs/wasm-abi.md for the
// compatibility rules.
const AbiVersion uint32 = 1
// describe handles HOOK_DESCRIBE: validate the caller's ABI version and
// return the static PluginManifest. Publish-time only — never called on a
// live instance.
func (g *guest) describe(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.DescribeRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("DescribeRequest", err)
}
// Symmetric version gate: a newer guest shim refuses a host it does not
// support, mirroring the host's manifest rejection.
if req.GetHostAbiVersion() != AbiVersion {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: fmt.Sprintf("guest implements ABI major version %d; host declared %d",
AbiVersion, req.GetHostAbiVersion()),
}
}
return &abiv1.DescribeResponse{Manifest: g.buildManifest()}, nil
}
// buildManifest assembles the PluginManifest from the registration's static
// funcs plus the Register captures. Field-by-field mapping:
// core/docs/wasm-abi.md §"Manifest ↔ PluginRegistration mapping".
func (g *guest) buildManifest() *abiv1.PluginManifest {
reg := g.reg
m := &abiv1.PluginManifest{
AbiVersion: AbiVersion,
Name: reg.Name,
Version: reg.Version,
HasHttpHandler: reg.HTTPHandler != nil,
HasLoadHook: reg.Load != nil,
HasUnloadHook: reg.Unload != nil,
HasMediaHooks: reg.MediaHooks != nil,
HasProvisioner: reg.RegisterWithProvisioner != nil,
}
for _, d := range reg.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
// Register captures. BlockMeta.Source is omitted deliberately — the host
// assigns it from the manifest name at load time.
for _, meta := range g.blocks.metas {
m.Blocks = append(m.Blocks, &abiv1.BlockMeta{
Key: meta.Key,
Title: meta.Title,
Description: meta.Description,
Category: string(meta.Category),
HasInternalSlot: meta.HasInternalSlot,
Hidden: meta.Hidden,
EditorJs: meta.EditorJS,
})
}
for _, o := range g.blocks.overrides {
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: o.templateKey, BlockKey: o.blockKey, Source: o.source,
})
}
m.TemplateKeys = append(m.TemplateKeys, g.templates.templateKeys...)
for _, st := range g.templates.systemTemplates {
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range g.templates.pageTemplates {
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.systemKey,
Key: pt.meta.Key,
Title: pt.meta.Title,
Description: pt.meta.Description,
Slots: pt.meta.Slots,
})
}
seenWrapper := make(map[string]bool)
for _, ew := range g.templates.emailWrappers {
if !seenWrapper[ew.systemKey] {
seenWrapper[ew.systemKey] = true
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ew.systemKey)
}
}
// Static funcs.
if reg.AdminPages != nil {
for _, p := range reg.AdminPages() {
m.AdminPages = append(m.AdminPages, &abiv1.AdminPage{
Key: p.Key, Title: p.Title, Icon: p.Icon, Route: p.Route,
})
}
}
if reg.SettingsSchema != nil {
m.SettingsSchema = reg.SettingsSchema()
}
if reg.ThemePresets != nil {
m.ThemePresets = reg.ThemePresets()
}
if reg.BundledFonts != nil {
m.BundledFonts = reg.BundledFonts()
}
if reg.MasterPages != nil {
for _, mp := range reg.MasterPages() {
m.MasterPages = append(m.MasterPages, masterPageToProto(mp))
}
}
if reg.AIActions != nil {
for _, a := range reg.AIActions() {
m.AiActions = append(m.AiActions, &abiv1.AiAction{
Key: a.Key,
Title: a.Title,
Description: a.Description,
DefaultProvider: a.DefaultProvider,
DefaultModel: a.DefaultModel,
})
}
}
if reg.CSSManifest != nil {
if cm := reg.CSSManifest(); cm != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: cm.NPMPackages,
CssDirectives: cm.CSSDirectives,
InputCssAppend: cm.InputCSSAppend,
}
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
// Custom template tags/filters the plugin registered via blocks.RegisterTag
// / blocks.RegisterFilter during the Register pass (runRegister reset the
// registry first, so these reflect exactly this plugin). The host wires a
// pongo2 tag/filter per name that calls back via HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER.
m.DeclaredTags = blocks.RegisteredTagNames()
m.DeclaredFilters = blocks.RegisteredFilterNames()
if reg.DirectoryExtensions != nil {
if de := reg.DirectoryExtensions(); de != nil {
pde := &abiv1.DirectoryExtensions{
BooleanFilterFields: de.BooleanFilterFields,
SelectFilterFields: de.SelectFilterFields,
PanelSectionCount: uint32(len(de.PanelSections)),
PinDecoratorCount: uint32(len(de.PinDecorators)),
}
if len(de.BadgeLabels) > 0 {
pde.BadgeLabels = make(map[string]*abiv1.BadgeLabel, len(de.BadgeLabels))
for k, v := range de.BadgeLabels {
pde.BadgeLabels[k] = &abiv1.BadgeLabel{Positive: v[0], Negative: v[1]}
}
}
m.DirectoryExtensions = pde
}
}
if reg.SettingsPanel != nil {
m.SettingsPanel = reg.SettingsPanel()
}
// Function fields probed with capture-only CoreServices. These calls run
// without a live host, so panics from touching unwired capabilities are
// swallowed — whatever was declared before the panic is still captured.
m.JobTypes = g.captureJobTypes()
m.RagContentFetcherTypes = g.captureRagFetcherTypes()
g.captureServiceRegistration(m)
return m
}
// captureJobTypes enumerates JobHandlers keys with describe-time services.
func (g *guest) captureJobTypes() (types []string) {
if g.reg.JobHandlers == nil {
return nil
}
defer func() { recover() }() //nolint:errcheck // publish-time probe; partial capture is intended
handlers := g.reg.JobHandlers(describeServices())
types = sortedKeys(handlers)
return types
}
// captureRagFetcherTypes runs Load against capture-only services to record
// RAGService.RegisterContentFetcher declarations. DESCRIBE is publish-time
// only, so Load's runtime side effects have no host to land on; a panic
// (e.g. from an unwired capability) ends the probe but keeps prior
// registrations.
func (g *guest) captureRagFetcherTypes() (types []string) {
if g.reg.Load == nil {
return nil
}
rag := caps.NewRAGStub(nil)
svcs := describeServices()
svcs.RAGService = rag
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
_ = g.reg.Load(svcs)
}()
return rag.FetcherTypes()
}
// captureServiceRegistration probes ServiceHandlers to record RBAC method
// roles and CoreServiceBindings.Bind declarations.
func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
if g.reg.ServiceHandlers == nil {
return
}
bindings := &captureCoreServiceBindings{}
svcs := describeServices()
svcs.CoreServiceBindings = bindings
var sr *plugin.ServiceRegistration
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
reg, err := g.reg.ServiceHandlers(svcs)
if err == nil {
sr = reg
}
}()
m.CoreServiceBindings = append(m.CoreServiceBindings, bindings.bound...)
if sr == nil {
return
}
coreBound := make(map[string]bool, len(bindings.bound))
for _, b := range bindings.bound {
coreBound[b.GetServiceName()] = true
}
for _, svc := range sr.Services {
if coreBound[svc.Name()] {
continue // declared via core_service_bindings, host mounts it
}
for method, role := range svc.MethodRoles() {
if m.RbacMethodRoles == nil {
m.RbacMethodRoles = make(map[string]string)
}
m.RbacMethodRoles[method] = string(role)
}
}
}
// describeServices returns the CoreServices value used to probe registration
// funcs at publish time. It uses the capability stubs with a nil transport:
// there is no live host during DESCRIBE, so any capability a plugin invokes
// returns a clean "no host transport" error instead of nil-panicking, letting
// the probe capture whatever static registrations (jobs, RAG fetchers)
// surround the call. Callers still recover, belt-and-suspenders.
func describeServices() plugin.CoreServices {
return caps.NewCoreServices(nil)
}
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
out := &abiv1.MasterPageDefinition{
Key: mp.Key,
Title: mp.Title,
PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
pb := &abiv1.MasterPageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
Slot: b.Slot,
SortOrder: b.SortOrder,
HtmlContent: b.HtmlContent,
}
if b.Content != nil {
if raw, err := json.Marshal(b.Content); err == nil {
pb.ContentJson = raw
}
}
out.Blocks = append(out.Blocks, pb)
}
return out
}
// --- capture / no-op stubs used only during DESCRIBE probes ---
type captureCoreServiceBindings struct {
bound []*abiv1.CoreServiceBinding
}
func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[string]rbac.Role) (plugin.ConnectServiceBinding, error) {
roles := make(map[string]string, len(methodRoles))
for k, v := range methodRoles {
roles[k] = string(v)
}
c.bound = append(c.bound, &abiv1.CoreServiceBinding{ServiceName: serviceName, MethodRoles: roles})
return plugin.NewConnectServiceBinding(serviceName, struct{}{},
func(_ struct{}, _ ...connect.HandlerOption) (string, http.Handler) {
return "/" + serviceName + "/", http.NotFoundHandler()
}, methodRoles), nil
}
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
// Register pass; real provisioning runs host-side at load.
type noopProvisioner struct{}
func (noopProvisioner) EnsureDataTable(plugin.DataTableConfig) error { return nil }
func (noopProvisioner) MergeSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureSetting(string, any) error { return nil }
func (noopProvisioner) EnsurePage(plugin.PageConfig) error { return nil }
func (noopProvisioner) OverrideSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureMenuItem(string, plugin.MenuItemConfig) error { return nil }
func (noopProvisioner) RegisterEmbeddingConfig(plugin.EmbeddingConfigDef) error {
return nil
}
func (noopProvisioner) EnsureEmbed(plugin.EmbedConfig) error { return nil }
func (noopProvisioner) EnsureJobSchedule(plugin.JobScheduleConfig) error { return nil }
func (noopProvisioner) UpdateDataTableRowField(context.Context, uuid.UUID, string, any) error {
return nil
}
func (noopProvisioner) DisableOrphanedJobSchedules([]string) error { return nil }
func (noopProvisioner) EnsurePlugin(string) error { return nil }
func (noopProvisioner) EnsureCustomColor(plugin.CustomColorConfig) error { return nil }
func (noopProvisioner) EnsureMedia(plugin.MediaDeposit) error { return nil }