Help us shape the future of Go! Take the 2026 Go Developer Survey

pluginhost

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 55 Imported by: 0

Documentation

Overview

Package pluginhost connects the WASM plugin runtime (internal/wasmhost) to the rest of the core: it loads plugins from a directory, maps their exports onto the hooks engine, and keeps the settings store and the adapters that turn core events into SDK hook payloads.

Package pluginhost holds the host-side glue between the core and the plugin SDK: settings storage, service adapters, manifest validation and extension bridges.

Index

Constants

View Source
const (
	ManifestFile = "plugin.json"
	WasmFile     = "plugin.wasm"
)

Plugin file names inside <plugin dir>/<name>/.

View Source
const CookiePrefix = "hmp_"

CookiePrefix starts the name of every cookie a plugin may read or set.

View Source
const DefaultFetchPerMinute = 600

DefaultFetchPerMinute is the outbound request cap per plugin.

Variables

View Source
var ErrCommunityScripts = errors.New("site.scripts cannot be granted to a community package")

ErrCommunityScripts is returned for a site.scripts grant to a community package.

View Source
var ErrFetchBlocked = safefetch.ErrBlocked

ErrFetchBlocked is wrapped by every policy refusal (allowlist, address, scheme).

View Source
var ErrKVQuota = errors.New("plugin kv quota exceeded")

ErrKVQuota is returned when a plugin's kv store is full.

View Source
var ErrNotRequested = errors.New("permission not requested by the plugin")

ErrNotRequested is returned by Grant for a permission the manifest does not list.

View Source
var ErrPluginNotFound = errors.New("plugin not found")

ErrPluginNotFound is returned by Grant for a plugin that is not installed.

View Source
var ErrRoleUnavailable = errors.New("plugin database role unavailable")

ErrRoleUnavailable means the plugin's role could not be created or used.

View Source
var ErrSQLBlocked = errors.New("sql blocked")

ErrSQLBlocked is wrapped by every guard refusal.

View Source
var ErrUnknownPermission = errors.New("unknown permission")

ErrUnknownPermission is returned for a permission name the SDK does not define.

Functions

func AgentFirstCrawlObserver

func AgentFirstCrawlObserver(e *hooks.Engine) func(agents.FirstCrawl)

AgentFirstCrawlObserver returns the callback for agents.NewFirstCrawlTracker that fires agent.first_crawl.

func AgentHitObserver

func AgentHitObserver(e *hooks.Engine) func([]agents.Hit)

AgentHitObserver returns a callback for agents.Writer.SetObserver that re-fires every stored hit as the agent.hit action. Nothing is built when no plugin subscribes. The IP never leaves the agents package: sdk.AgentHitEvent has no such field.

func BeforeSave

func BeforeSave(engine *hooks.Engine) entity.BeforeSaveFunc

BeforeSave adapts the engine's strict entity.before_save filter chain to entity.BeforeSaveFunc.

func BindHostHooks

func BindHostHooks(e *hooks.Engine)

BindHostHooks connects core extension points to the hooks engine. Call once at boot, after the engine exists.

func CompileSchema

func CompileSchema(schema []byte) (*jsonschema.Schema, error)

CompileSchema compiles a plugin settings schema (JSON Schema 2020-12).

func DecodeArgs

func DecodeArgs(raw json.RawMessage) ([]any, error)

DecodeArgs decodes a JSON array of query arguments.

func ExtSources

func ExtSources(h *hooks.Engine) render.ExtSourceFunc

ExtSources adapts the builder.data hook chain to render.ExtSourceFunc: one Apply per batch. Each plugin sees only the requests that name its own declared sources, and only results for those requests are taken back. Nil engine gives nil (no external data).

func HeadFilter

func HeadFilter(h *hooks.Engine) render.HeadFilter

HeadFilter adapts the render.head hook chain to render.HeadFilter. The render package sanitises and escapes whatever tags come back.

func HostFunctionsFor

func HostFunctionsFor(perms []sdk.Permission) []string

HostFunctionsFor returns the names of the host functions a plugin holding perms may call (functions that need no permission included), sorted.

func MaskWriteOnly

func MaskWriteOnly(schema []byte, v json.RawMessage) json.RawMessage

MaskWriteOnly blanks top-level `writeOnly` properties so secrets are never returned by the API.

func MergeDefaults

func MergeDefaults(schema []byte, stored json.RawMessage) json.RawMessage

MergeDefaults fills top-level properties missing from stored with the schema's `default` values.

func PluginCookieName

func PluginCookieName(plugin, name string) string

PluginCookieName is the browser cookie name of a plugin's cookie: plugins are confined to the namespace hmp_<plugin>_<name>, so they can neither read nor set the session cookie, the CSRF cookie or another plugin's cookies. The plugin itself only ever sees and uses <name>.

func PreserveWriteOnly

func PreserveWriteOnly(schema []byte, old, in json.RawMessage) json.RawMessage

PreserveWriteOnly keeps the stored value of a top-level `writeOnly` property when the incoming object omits it or sends an empty string. An explicit null clears the stored value.

func SDKRefusal

func SDKRefusal(m sdk.Manifest) error

SDKRefusal is nil when this host serves the manifest's sdk range, as decided by Manifest.Compatible. Otherwise it names the plugin, the range, the host's SDK version and the SDK minors the host serves. Load and install both report it.

func SchemaName

func SchemaName(plugin string) string

SchemaName is the Postgres schema of a plugin's data. Plugin names are [a-z][a-z0-9-]*, so "-" becomes "_" (no collision: "_" is not a legal name character).

func WriteOnlySet

func WriteOnlySet(schema []byte, stored json.RawMessage) map[string]bool

WriteOnlySet names the top-level `writeOnly` properties that hold a non-empty stored value, so a form can say "stored" without showing it.

Types

type API

type API struct {
	// contains filtered or unexported fields
}

API answers a plugin's hm_call requests. Every call is permission checked against the plugin's grants and bound to a deadline.

func NewAPI

func NewAPI(svc Services, grants Grants, manifest func(string) (sdk.Manifest, bool), log *slog.Logger) *API

NewAPI returns the host-function dispatcher. manifest looks up a loaded plugin's manifest.

func (*API) Call

func (a *API) Call(ctx context.Context, plugin string, in []byte) []byte

Call implements wasmhost.HostCall.

type Assets

type Assets struct {
	Migrations []Migration       // migrations/*.sql
	Partials   map[string]string // partials/<name>.html
	Sections   map[string]string // sections/<type>.html
}

Assets are the files a plugin directory carries next to plugin.wasm.

func AssetsFromFS

func AssetsFromFS(root fs.FS) (Assets, error)

AssetsFromFS reads migrations/, partials/ and sections/ of a plugin (a directory or a package). A bad asset fails the plugin's load: half a plugin is worse than none.

type Caller

type Caller func(ctx context.Context, plugin, export string, in []byte) ([]byte, error)

Caller runs an export of a plugin (wasmhost.Host.Call).

type Config

type Config struct {
	Services  Services
	Grants    Grants
	Scheduler Scheduler
}

Config wires the runtime's capabilities. Every field is optional: without Services the host functions answer "unavailable", without Grants nothing is ever granted (a memory store), without Scheduler cron jobs do not run.

type EntityPaths

type EntityPaths interface {
	// Path is the canonical public path of ent ("/a/b").
	Path(ctx context.Context, ent *entity.Entity) string
	// Resolve returns the published entity served at path, or
	// entity.ErrEntityNotFound.
	Resolve(ctx context.Context, path string) (*entity.Entity, error)
	// BaseURL is the configured public origin ("" when none is set).
	BaseURL(ctx context.Context) string
}

EntityPaths gives the entities.* host functions the public site's view of an entity: where it lives and which entity lives at a path. The server implements it over the URL registry and the public resolver, so a plugin sees exactly the URLs the site serves.

type EntitySink

type EntitySink struct {
	// contains filtered or unexported fields
}

EntitySink turns entity lifecycle events into entity.* hook actions, which run asynchronously off the request path (so a slow plugin can never block a save). It implements entity.EventSink and worker.PublishEventSink.

func NewEntitySink

func NewEntitySink(engine *hooks.Engine, log *slog.Logger) *EntitySink

NewEntitySink builds the sink on engine.

func (*EntitySink) Emit

func (s *EntitySink) Emit(ctx context.Context, ev entity.Event)

Emit implements entity.EventSink.

func (*EntitySink) EntityPublished

func (s *EntitySink) EntityPublished(ctx context.Context, id uuid.UUID, schemaSlug, slug, name string, version int)

EntityPublished implements worker.PublishEventSink for scheduled publishes.

func (*EntitySink) SetLocaleLookup

func (s *EntitySink) SetLocaleLookup(f func(context.Context, uuid.UUID) string)

SetLocaleLookup wires the entity locale reader. The worker interface (internal/worker, not owned by this change) carries no locale, so a scheduled publish looks it up here; an unset or failing lookup leaves the locale of entity.published empty.

type FetchRequest

type FetchRequest struct {
	Method    string            `json:"method,omitempty"`
	URL       string            `json:"url"`
	Headers   map[string]string `json:"headers,omitempty"`
	Body      string            `json:"body,omitempty"`
	BodyB64   string            `json:"body_b64,omitempty"`
	TimeoutMS int               `json:"timeout_ms,omitempty"`
}

FetchRequest is the input of http.fetch.

type FetchResponse

type FetchResponse struct {
	Status    int               `json:"status"`
	Headers   map[string]string `json:"headers"`
	Body      string            `json:"body,omitempty"`
	BodyB64   string            `json:"body_b64,omitempty"`
	Truncated bool              `json:"truncated,omitempty"`
}

FetchResponse is the result of http.fetch.

type Fetcher

type Fetcher struct {
	// contains filtered or unexported fields
}

Fetcher performs guarded outbound requests.

func NewFetcher

func NewFetcher() *Fetcher

NewFetcher returns a Fetcher using the system resolver.

func NewFetcherForTests

func NewFetcherForTests() *Fetcher

NewFetcherForTests returns a Fetcher that also reaches loopback and private addresses, so a test can stand an httptest server in for a remote service. The plugin's allowed_hosts still apply. Never use it outside tests.

func (*Fetcher) Do

func (f *Fetcher) Do(ctx context.Context, plugin string, allowed []string, req FetchRequest) (*FetchResponse, error)

Do runs req for a plugin whose manifest allows the given hosts.

type FieldError

type FieldError struct {
	Path    string `json:"path"`
	Message string `json:"message"`
}

FieldError is one schema violation, addressed by JSON pointer.

func Validate

func Validate(sch *jsonschema.Schema, v json.RawMessage) []FieldError

Validate checks v against sch and returns the violations (nil when valid).

type GrantStore

type GrantStore struct {
	// contains filtered or unexported fields
}

GrantStore keeps grants in hatamoto_plugin_grants and mirrors them in memory, so the permission check on every host call costs a map lookup. With a nil pool it is memory only (tests).

func NewGrantStore

func NewGrantStore(pool *pgxpool.Pool) *GrantStore

NewGrantStore returns a store over pool (nil: memory only). Call Load once at boot.

func (*GrantStore) Granted

func (g *GrantStore) Granted(plugin string) []sdk.Permission

Granted returns the plugin's granted permissions, sorted.

func (*GrantStore) Has

func (g *GrantStore) Has(plugin string, perm sdk.Permission) bool

Has reports whether plugin holds perm.

func (*GrantStore) Load

func (g *GrantStore) Load(ctx context.Context) error

Load reads every stored grant.

func (*GrantStore) Set

func (g *GrantStore) Set(ctx context.Context, plugin string, perms []sdk.Permission) error

Set stores the plugin's granted set (deduplicated, sorted).

type Grants

type Grants interface {
	Has(plugin string, perm sdk.Permission) bool
	Granted(plugin string) []sdk.Permission
	// Set replaces the plugin's granted set.
	Set(ctx context.Context, plugin string, perms []sdk.Permission) error
}

Grants is the owner's decision on which of a plugin's requested capabilities it may use. Nothing is granted by default.

type Lookup

type Lookup func(ctx context.Context, host string) ([]netip.Addr, error)

Lookup resolves a host name to addresses.

type Mailer

type Mailer interface {
	IsConfigured() bool
	Send(ctx context.Context, to []string, subject, htmlBody, textBody string) error
}

Mailer is the slice of the email service mail.send uses.

type Migration

type Migration struct {
	Version int64
	Name    string
	Stmts   []string
}

Migration is one plugin migration file: <version>_<name>.sql in the goose format. Only the "-- +goose Up" section runs; Down sections are ignored.

func ReadMigrations

func ReadMigrations(fsys fs.FS, dir string) ([]Migration, error)

ReadMigrations reads dir/*.sql from fsys, validates every statement with the SQL guard, and returns them in version order.

type PageFilter

type PageFilter struct {
	// contains filtered or unexported fields
}

PageFilter applies the render.page chain. Cookies are the request's plugin cookies by browser name (hmp_<plugin>_<name>; core cookies are never put in).

func NewPageFilter

func NewPageFilter(engine *hooks.Engine) *PageFilter

NewPageFilter returns the adapter over engine; nil engine gives nil.

func (*PageFilter) Active

func (p *PageFilter) Active() bool

Active reports whether any plugin handles render.page; pages are then request-dependent and must not be shared-cached.

func (*PageFilter) Apply

Apply runs the chain over in and returns the result (in itself when nothing handled it or everything failed).

type PluginDB

type PluginDB struct {

	// AllowPublicObjects lets db.* be granted although objects the application
	// role does not own are open to PUBLIC (HATAMOTO_PLUGIN_DB_ALLOW_PUBLIC_OBJECTS=1).
	AllowPublicObjects bool
	// contains filtered or unexported fields
}

PluginDB provisions and queries the per-plugin databases (see plugindb_provision.go for the model). pool is the core pool: it runs only core-authored SQL (provisioning, credentials, kv). Plugin SQL runs only on the plugin's own pool.

func NewPluginDB

func NewPluginDB(pool *pgxpool.Pool, log *slog.Logger) *PluginDB

NewPluginDB returns a PluginDB over the core pool.

func (*PluginDB) Close

func (d *PluginDB) Close()

Close closes every plugin pool.

func (*PluginDB) Drop

func (d *PluginDB) Drop(ctx context.Context, plugin string) error

Drop removes a plugin's data, schema, role and credentials (tests; uninstall comes with v1-18).

func (*PluginDB) Exec

func (d *PluginDB) Exec(ctx context.Context, plugin, sql string, args ...any) (int64, error)

Exec runs one guarded statement as the plugin and returns the rows it touched.

func (*PluginDB) KVDelete

func (d *PluginDB) KVDelete(ctx context.Context, plugin, key string) error

KVDelete removes key.

func (*PluginDB) KVGet

func (d *PluginDB) KVGet(ctx context.Context, plugin, key string) (json.RawMessage, bool, error)

KVGet returns the value of key, if present and not expired.

func (*PluginDB) KVList

func (d *PluginDB) KVList(ctx context.Context, plugin, prefix string, limit int) ([]string, error)

KVList returns up to limit live keys with the prefix, sorted.

func (*PluginDB) KVSet

func (d *PluginDB) KVSet(ctx context.Context, plugin, key string, value json.RawMessage, ttl time.Duration) error

KVSet stores value under key; ttl > 0 expires it. A plugin holds at most maxKVKeys live keys and maxKVTotalBytes of values.

func (*PluginDB) Migrate

func (d *PluginDB) Migrate(ctx context.Context, plugin string, migs []Migration) (int, error)

Migrate applies the pending migrations through goose on the plugin's own pool, so they run as the plugin role with its session user, with a version table inside the plugin's own schema (plugin_<name>.goose_db_version). The SQL guard vets every statement first; the database is the boundary.

func (*PluginDB) Provision

func (d *PluginDB) Provision(ctx context.Context, plugin string) error

Provision creates the plugin's role and schema (idempotent) and opens its pool lazily. An error means provisioning failed outright; a role that cannot be created is not an error here (see RoleError).

func (*PluginDB) PublicObjects

func (d *PluginDB) PublicObjects(ctx context.Context) ([]string, error)

PublicObjects lists what PUBLIC can use that the application role cannot close (see above). Empty is the healthy answer.

func (*PluginDB) Query

func (d *PluginDB) Query(ctx context.Context, plugin, sql string, args ...any) (*QueryResult, error)

Query runs one guarded statement as the plugin and returns its rows.

func (*PluginDB) Reprovision

func (d *PluginDB) Reprovision(ctx context.Context, plugin string) error

Reprovision rotates the plugin's password, replaces its pool and provisions it again, so a role that was missing (no CREATEROLE yet) is retried when the plugin loads or its grants change.

func (*PluginDB) RoleError

func (d *PluginDB) RoleError(plugin string) error

RoleError reports why db.* is unavailable for plugin (nil when usable).

func (*PluginDB) WarnPublicObjects

func (d *PluginDB) WarnPublicObjects(ctx context.Context) []string

WarnPublicObjects logs the objects PublicObjects finds (boot-time check) and returns them.

type QueryResult

type QueryResult struct {
	Columns   []string `json:"columns"`
	Rows      [][]any  `json:"rows"`
	Truncated bool     `json:"truncated,omitempty"`
}

QueryResult is the result of db.query.

type RouteRegistry

type RouteRegistry struct {
	// contains filtered or unexported fields
}

RouteRegistry holds the per-plugin routers of one mount (admin or public).

func NewRouteRegistry

func NewRouteRegistry(call Caller, public bool, log *slog.Logger) *RouteRegistry

NewRouteRegistry returns a registry serving either the admin or the public routes of the plugins.

func (*RouteRegistry) Plugins

func (g *RouteRegistry) Plugins() []string

Plugins lists the plugins that have routes here, sorted.

func (*RouteRegistry) Serve

func (g *RouteRegistry) Serve(w http.ResponseWriter, r *http.Request, plugin, rest string, user *sdk.HTTPUser)

Serve handles the request for plugin; rest is the path below the mount (without the leading slash). user is nil on the public mount.

func (*RouteRegistry) Set

func (g *RouteRegistry) Set(plugin string, routes []sdk.Route)

Set replaces plugin's router with one built from routes (only the routes of this registry's mount are used). No routes removes it. It is safe to call while requests are being served.

type Runtime

type Runtime struct {
	// contains filtered or unexported fields
}

Runtime loads plugins into a wasmhost.Host and registers their hook handlers on the engine.

func NewRuntime

func NewRuntime(log *slog.Logger, engine *hooks.Engine, cacheDir string) (*Runtime, error)

NewRuntime returns a Runtime. cacheDir is the compiled-code cache ("" = memory only).

func (*Runtime) AdminRoutes

func (r *Runtime) AdminRoutes() *RouteRegistry

AdminRoutes and PublicRoutes are the two route registries the server mounts.

func (*Runtime) CallHost

func (r *Runtime) CallHost(ctx context.Context, plugin string, req sdk.CallRequest) []byte

CallHost runs one host function on behalf of plugin, with the plugin's permission check, and returns the sdk.CallResponse JSON. It is the path the admin's plugin-UI bridge uses.

func (*Runtime) Close

func (r *Runtime) Close(ctx context.Context)

Close unloads every plugin.

func (*Runtime) Configure

func (r *Runtime) Configure(c Config)

Configure sets the capabilities. Call it before loading plugins.

func (*Runtime) ExtSourceList

func (r *Runtime) ExtSourceList() []builder.ExtSource

ExtSourceList returns the builder data sources of every loaded plugin that holds the builder.data grant, for the editor vocabulary.

func (*Runtime) Grant

func (r *Runtime) Grant(ctx context.Context, name string, perms []sdk.Permission) (Status, error)

Grant sets the capabilities the owner allows plugin: perms must all be listed in its manifest, and replace any earlier grant (grant fewer to revoke). Data the new grants need is prepared at once. It returns the plugin's resulting status.

func (*Runtime) Grants

func (r *Runtime) Grants() Grants

Grants returns the grant store.

func (*Runtime) Host

func (r *Runtime) Host() *wasmhost.Host

Host exposes the underlying runtime (tests).

func (*Runtime) Load

func (r *Runtime) Load(ctx context.Context, m sdk.Manifest, wasm []byte) Status

Load is LoadWith without assets.

func (*Runtime) LoadDir

func (r *Runtime) LoadDir(ctx context.Context, dir string) []Status

LoadDir loads every <dir>/<name>/ that holds a plugin.json, in name order. A plugin that fails to load is recorded and logged; the others and the server are unaffected. A missing dir means no plugins.

func (*Runtime) LoadWith

func (r *Runtime) LoadWith(ctx context.Context, m sdk.Manifest, wasm []byte, assets Assets) Status

LoadWith validates, compiles and registers one plugin with its assets (migrations, partials, sections). Never returns a Go error: the result, including a failure, is the Status.

func (*Runtime) PublicRoutes

func (r *Runtime) PublicRoutes() *RouteRegistry

func (*Runtime) ScriptsDropped

func (r *Runtime) ScriptsDropped(plugin string) bool

ScriptsDropped reports whether the plugin has asked for a script tag that was dropped because it lacks the site.scripts grant.

func (*Runtime) SetGrantPolicy

func (r *Runtime) SetGrantPolicy(f func(plugin string, perms []sdk.Permission) error)

SetGrantPolicy installs a check Grant runs before it stores a grant; the server wires the package policy here (pluginhost cannot import packages).

func (*Runtime) SetScriptsDeny

func (r *Runtime) SetScriptsDeny(f func(plugin string) bool)

SetScriptsDeny installs a policy that refuses a plugin's scripts whatever is granted (a stored grant outlives a policy change); the server wires it.

func (*Runtime) Statuses

func (r *Runtime) Statuses() []Status

Statuses returns the load results so far: loaded plugins and failures.

func (*Runtime) Unload

func (r *Runtime) Unload(ctx context.Context, name string)

Unload removes the plugin's hooks and frees its module.

type Scheduler

type Scheduler interface {
	Schedule(id string, every time.Duration, fn func(context.Context) error) error
	Unschedule(prefix string)
}

Scheduler runs a function every interval (worker.Scheduler). Scheduling an id again replaces the job; Unschedule stops every job whose id starts with prefix.

type Services

type Services struct {
	Entities  *entity.Service
	Paths     EntityPaths // optional: without it entities carry no path/url and cannot be read by path
	Terms     *taxonomy.Service
	Menus     *menu.Service
	Media     *media.Service
	Redirects *redirect.Service
	Forms     *form.Service
	Mail      Mailer
	Settings  *SettingsStore
	DB        *PluginDB
	Fetcher   *Fetcher
	// FetchPerMinute caps one plugin's outbound http.fetch requests per
	// minute; 0 means DefaultFetchPerMinute.
	FetchPerMinute int
}

Services are the core services host functions call. A nil service makes its functions fail with "unavailable".

type SettingsStore

type SettingsStore struct {
	// contains filtered or unexported fields
}

SettingsStore persists plugin settings in hatamoto_plugin_settings.

func NewSettingsStore

func NewSettingsStore(pool *pgxpool.Pool) *SettingsStore

NewSettingsStore returns a store over pool.

func (*SettingsStore) Get

func (s *SettingsStore) Get(ctx context.Context, plugin string) (json.RawMessage, error)

Get returns the stored settings object, or {} when none were saved.

func (*SettingsStore) Put

func (s *SettingsStore) Put(ctx context.Context, plugin string, v json.RawMessage) error

Put upserts the full settings object.

type Status

type Status struct {
	Name     string
	Manifest sdk.Manifest
	Loaded   bool
	Error    string   // why it did not load
	Hooks    []string // hook names the module exports a handler for
	// DataError says why the plugin's own data (schema, migrations) is not
	// ready; the plugin itself is loaded.
	DataError string
}

Status is the outcome of loading one plugin; it is what GET /api/v1/plugins reports.

func ListDir

func ListDir(dir string) ([]Status, error)

ListDir reads the manifests under dir without compiling anything. A plugin whose manifest cannot be read is listed with its Error. A missing dir is no plugins; any other failure to read dir is returned.

Directories

Path Synopsis
Package plugintest builds the test plugins under internal/pluginhost/testdata for tests in any package.
Package plugintest builds the test plugins under internal/pluginhost/testdata for tests in any package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL