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>
333 lines
12 KiB
Go
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 }
|