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 }