feat: scaffold Phase B + C (events, telemetry, plugins, Solid.js)

Phase B Step 6 — Solid.js incremental migration
  - vite-plugin-solid + solid-js + @solidjs/testing-library in package.json
  - vite.config.ts compiles src/components/solid/** as Solid TSX
  - tsconfig.json gains jsx: preserve / jsxImportSource: solid-js
  - lib/solidAdapter.ts wraps existing custom Stores as Solid signals
  - lib/solidMount.ts adapts Solid render to {mount,destroy} contract
  - components/solid/Badge.tsx (proof-of-concept leaf)
  - components/solid/ChannelListItem.tsx (store-subscribed leaf)
  - components/solid/Badge.test.tsx pipeline smoke test
  - components/solid/README.md documents the migration recipe

Phase B Step 7 — Event persistence layer
  - SQLite + Postgres migrations for the events table
  - sqlc query files for both engines
  - EventStore interface + SQLite raw-SQL impl + MemStore impl + pg stubs
  - ws.EventPersister: async batched writer (queue / flush / drain / drop)
  - ws.StartEventPruner: background retention pruner
  - hub persists every replay-buffer push and exposes reconnect-tier counters
  - serve.handleReconnect: tiered replay (buffer -> DB -> full re-sync)
  - EventPersistenceConfig + main.go wiring
  - event_persister_test.go covers batching / drops / drain

Phase B Step 8 — OpenTelemetry skeleton
  - Server/telemetry package with public Provider/Tracer/Meter/Counter API
  - telemetry_default.go (no-op build) + telemetry_otel.go (build tag otel)
  - telemetry/metrics.go declares the AppMetrics bundle
  - HTTPMiddleware mounted in Chi router (pass-through in default build)
  - PrometheusHandler optionally mounted at /metrics
  - Spans on MessageService.SendMessage, PermissionService.HasChannelPerm,
    ChannelService.ListVisibleChannels
  - Reconnect-tier counter wired into the global meter
  - TelemetryConfig defaults

Phase C Step 9 — Wazero plugin runtime skeleton
  - Server/plugin package: manifest parser, loader, registry, host APIs
    (commands, storage, events, http, ui), errors
  - sandbox_default.go (no-op) + sandbox_wazero.go (build tag wazero)
  - SQLite + Postgres migrations for plugins + plugin_kv tables
  - PluginStore interface + impls + pg stubs
  - plugin/examples/hello manifest + README
  - plugin_test.go covers manifest, loader, capability gating
  - api/plugins_handler.go admin REST surface, mounted under admin group
  - PluginsConfig + main.go wiring (disabled by default)
  - Client: lib/pluginBridge.ts iframe + postMessage host
  - Client: components/solid/PluginContainer.tsx Solid host component

Verification
  - Default build (no -tags) is intended to compile cleanly with no new
    third-party dependencies. The sandbox lacked Go 1.25.0 so go build
    could not run; PHASE_BC_LOCAL_TODO.md enumerates the local follow-up
    work (npm install, go mod tidy, sqlc-generate, real otel/wazero
    wiring, remaining service spans, full Solid migration).
This commit is contained in:
Claude
2026-04-06 09:00:47 +00:00
parent 4eb4a63aee
commit a2cb224323
57 changed files with 3798 additions and 9 deletions
+16
View File
@@ -0,0 +1,16 @@
package plugin
import "errors"
// ErrRuntimeUnavailable is returned when the plugin runtime cannot start
// because the wazero build tag was not enabled. Default builds surface this
// error from Registry.LoadAll so the rest of the server can keep running.
var ErrRuntimeUnavailable = errors.New("plugin runtime: wazero build tag not enabled (build with -tags wazero to load .wasm plugins)")
// ErrPluginNotFound is returned when an operation references an unknown
// plugin id or name.
var ErrPluginNotFound = errors.New("plugin not found")
// ErrCapabilityNotGranted is returned when a host API call would require a
// capability the plugin's manifest did not declare.
var ErrCapabilityNotGranted = errors.New("plugin capability not granted")
+29
View File
@@ -0,0 +1,29 @@
# hello plugin
Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`.
## Manifest
`plugin.json` declares the `commands`, `events`, and `storage` capabilities.
The manifest is the only file the default (no-`-tags wazero`) build needs —
the registry persists it into the plugins table without executing the .wasm.
## Building the WASM
The .wasm binary is intentionally NOT checked in. Build it locally with TinyGo
or any other WASM toolchain that emits a module exporting `command_dispatch`,
`on_event`, and `_start`:
```sh
# TinyGo example (writes hello.wasm into this directory)
tinygo build -o hello.wasm -target wasi ./main.go
```
A trivial main.go that satisfies the host API is sketched in
`Server/plugin/sandbox_wazero.go`'s docstring.
## Tests
`Server/plugin/plugin_test.go` exercises the manifest parser and the loader
against this directory. It does not require the .wasm to be present —
manifest-only validation is the default-build coverage path.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "hello",
"version": "0.1.0",
"author": "OwnCord",
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
"entrypoint": "hello.wasm",
"permissions": ["commands", "events", "storage"],
"resources": {
"max_memory_mb": 16,
"cpu_budget_ms": 50
}
}
+63
View File
@@ -0,0 +1,63 @@
// Phase C Step 9 — `commands` host capability.
//
// Plugins that declare the "commands" capability register one or more slash
// commands at activation time. The WS command dispatcher (Server/ws/command.go)
// calls Registry.DispatchCommand after exhausting its built-in command table.
package plugin
import (
"context"
"fmt"
"strings"
)
// CommandResult is what a plugin returns from a command invocation.
type CommandResult struct {
// Reply is sent back to the invoking user as an ephemeral message.
Reply string
// Broadcast, when set, is also broadcast to the channel.
Broadcast string
}
// RegisterCommand binds cmd to inst. Called from the activation path in the
// wazero-tagged build once the module exports its `register_commands` table.
// Default build can call it directly from tests.
func (r *Registry) RegisterCommand(cmd string, inst *Instance) error {
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
if cmd == "" {
return fmt.Errorf("plugin: cannot register empty command")
}
if !inst.Manifest.HasCapability(CapCommands) {
return ErrCapabilityNotGranted
}
r.mu.Lock()
defer r.mu.Unlock()
if existing, ok := r.commands[cmd]; ok && existing != inst {
return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name)
}
r.commands[cmd] = inst
return nil
}
// DispatchCommand routes a slash command to the owning plugin. Returns
// (nil, false) when no plugin owns the command, letting the WS dispatcher
// fall back to the not-found response. Returns (nil, true) when the runtime
// is unavailable so the dispatcher can show a helpful error message.
func (r *Registry) DispatchCommand(ctx context.Context, userID int64, channelID int64, cmd string, args []string) (*CommandResult, bool) {
if r == nil {
return nil, false
}
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
r.mu.RLock()
inst, ok := r.commands[cmd]
r.mu.RUnlock()
if !ok {
return nil, false
}
if r.runtimePlatform == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %q owns /%s but the wazero runtime is not built (run with -tags wazero)", inst.Manifest.Name, cmd),
}, true
}
return r.invokeCommand(ctx, inst, userID, channelID, cmd, args)
}
+69
View File
@@ -0,0 +1,69 @@
// Phase C Step 9 — `events` host capability.
//
// Plugins that subscribe to events declare topic names in their manifest.
// At activation time the wazero-tagged build wires each subscription into
// the WS pub/sub hub via Hub.Subscribe; the default build records the
// subscription in-memory only.
package plugin
import (
"context"
"sync"
)
// EventSink is the channel a subscribed plugin reads from. The wazero-tagged
// build forwards each event to the plugin's `on_event` exported function.
type EventSink struct {
mu sync.Mutex
subs map[string][]*Instance
}
// NewEventSink returns a fresh sink. Used by the registry as the central
// fan-out for plugin event delivery.
func NewEventSink() *EventSink {
return &EventSink{subs: make(map[string][]*Instance)}
}
// Subscribe binds inst to topic. Multiple plugins may subscribe to the same
// topic — events fan out to every subscriber.
func (s *EventSink) Subscribe(topic string, inst *Instance) error {
if !inst.Manifest.HasCapability(CapEvents) {
return ErrCapabilityNotGranted
}
s.mu.Lock()
defer s.mu.Unlock()
s.subs[topic] = append(s.subs[topic], inst)
return nil
}
// UnsubscribeAll removes every subscription owned by inst (called on disable).
func (s *EventSink) UnsubscribeAll(inst *Instance) {
s.mu.Lock()
defer s.mu.Unlock()
for topic, list := range s.subs {
kept := list[:0]
for _, e := range list {
if e != inst {
kept = append(kept, e)
}
}
if len(kept) == 0 {
delete(s.subs, topic)
} else {
s.subs[topic] = kept
}
}
}
// Dispatch invokes every subscriber's on_event for topic. The default build
// is a no-op; the wazero-tagged build calls into the WASM module.
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {
s.mu.Lock()
subs := append([]*Instance(nil), s.subs[topic]...)
s.mu.Unlock()
for _, inst := range subs {
_ = inst // wazero-tagged build calls inst.module.invoke("on_event", payload)
_ = ctx
_ = payload
}
}
+94
View File
@@ -0,0 +1,94 @@
// Phase C Step 9 — `http` host capability.
//
// Outbound HTTP requests proxied through the server. Each request is matched
// against PluginsConfig.HTTPAllowlist (host suffix match) before being sent.
// The wazero-tagged build invokes this from the plugin's `host_http_request`
// import; the default build exposes it for testing.
package plugin
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// HTTPRequest is the plugin → host request envelope.
type HTTPRequest struct {
Method string
URL string
Body []byte
Header map[string]string
}
// HTTPResponse is the host → plugin response envelope.
type HTTPResponse struct {
StatusCode int
Body []byte
Header map[string]string
}
const httpTimeout = 10 * time.Second
// HTTPDo executes a plugin-initiated HTTP request after enforcing the host
// allowlist declared in PluginsConfig.
func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) (*HTTPResponse, error) {
if !inst.Manifest.HasCapability(CapHTTP) {
return nil, ErrCapabilityNotGranted
}
if !r.hostAllowed(req.URL) {
return nil, fmt.Errorf("plugin http: host not in allowlist: %s", req.URL)
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, strings.NewReader(string(req.Body)))
if err != nil {
return nil, fmt.Errorf("plugin http: build request: %w", err)
}
for k, v := range req.Header {
httpReq.Header.Set(k, v)
}
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("plugin http: do: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("plugin http: read body: %w", err)
}
hdr := make(map[string]string, len(resp.Header))
for k, v := range resp.Header {
if len(v) > 0 {
hdr[k] = v[0]
}
}
return &HTTPResponse{
StatusCode: resp.StatusCode,
Body: body,
Header: hdr,
}, nil
}
// hostAllowed reports whether url's host matches any suffix in the allowlist.
func (r *Registry) hostAllowed(url string) bool {
// Trivial host extraction — full URL parsing would be overkill since the
// allowlist match is suffix-based.
rest := url
for _, prefix := range []string{"https://", "http://"} {
if strings.HasPrefix(rest, prefix) {
rest = rest[len(prefix):]
break
}
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
for _, suffix := range r.cfg.HTTPAllowlist {
if strings.HasSuffix(rest, suffix) {
return true
}
}
return false
}
+54
View File
@@ -0,0 +1,54 @@
// Phase C Step 9 — `storage` host capability.
//
// Plugins get a per-plugin namespaced KV store backed by the PluginStore
// rows in the events/plugin schema. Capacity caps and value-size caps are
// enforced here so a misbehaving plugin can't fill the database.
package plugin
import (
"context"
"fmt"
)
const (
maxPluginValueBytes = 64 * 1024 // 64 KB per value
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
)
// StoragePut writes a single key/value pair on behalf of inst.
func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, value []byte) error {
if !inst.Manifest.HasCapability(CapStorage) {
return ErrCapabilityNotGranted
}
if len(value) > maxPluginValueBytes {
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
}
return r.cfg.Store.PluginKVSet(ctx, inst.ID, key, value)
}
// StorageGet returns the value for key, or (nil, error) when missing.
func (r *Registry) StorageGet(ctx context.Context, inst *Instance, key string) ([]byte, error) {
if !inst.Manifest.HasCapability(CapStorage) {
return nil, ErrCapabilityNotGranted
}
return r.cfg.Store.PluginKVGet(ctx, inst.ID, key)
}
// StorageDelete removes a key.
func (r *Registry) StorageDelete(ctx context.Context, inst *Instance, key string) error {
if !inst.Manifest.HasCapability(CapStorage) {
return ErrCapabilityNotGranted
}
return r.cfg.Store.PluginKVDelete(ctx, inst.ID, key)
}
// StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit.
func (r *Registry) StorageScan(ctx context.Context, inst *Instance, prefix string, limit int) (map[string][]byte, error) {
if !inst.Manifest.HasCapability(CapStorage) {
return nil, ErrCapabilityNotGranted
}
if limit <= 0 || limit > maxPluginScanLimit {
limit = maxPluginScanLimit
}
return r.cfg.Store.PluginKVScan(ctx, inst.ID, prefix, limit)
}
+62
View File
@@ -0,0 +1,62 @@
// Phase C Step 9 — `ui` host capability.
//
// A plugin that declares the `ui` capability ships HTML/CSS/JS assets and a
// list of tabs. The host serves those assets at /api/v1/plugins/<name>/ui/...
// and the Solid.js client bridge renders each tab inside a sandboxed iframe.
package plugin
import (
"net/http"
"path/filepath"
"strings"
)
// RegisterUI binds inst's declared tabs into the registry. Called from the
// activation path; safe to call multiple times (idempotent on inst).
func (r *Registry) RegisterUI(inst *Instance) error {
if !inst.Manifest.HasCapability(CapUI) {
return ErrCapabilityNotGranted
}
r.mu.Lock()
defer r.mu.Unlock()
// Drop any existing bindings for this instance, then re-add.
kept := r.uiTabs[:0]
for _, b := range r.uiTabs {
if b.PluginID != inst.ID {
kept = append(kept, b)
}
}
r.uiTabs = kept
for _, t := range inst.Manifest.UI.Tabs {
r.uiTabs = append(r.uiTabs, UITabBinding{
PluginID: inst.ID,
PluginName: inst.Manifest.Name,
Tab: t,
})
}
return nil
}
// AssetHandler returns an http.Handler that serves the on-disk assets for
// inst, rooted at the plugin's directory. The handler refuses path traversal
// attempts and only serves files declared by manifest tabs.
func (r *Registry) AssetHandler(inst *Instance) http.Handler {
allowed := make(map[string]bool, len(inst.Manifest.UI.Tabs))
for _, t := range inst.Manifest.UI.Tabs {
allowed[t.Asset] = true
}
pluginDir := filepath.Dir(inst.WASMPath)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rel := strings.TrimPrefix(req.URL.Path, "/")
if !allowed[rel] {
http.NotFound(w, req)
return
}
full := filepath.Join(pluginDir, rel)
if !strings.HasPrefix(full, pluginDir) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, req, full)
})
}
+85
View File
@@ -0,0 +1,85 @@
// Phase C Step 9 — On-disk plugin discovery.
//
// Each plugin lives in its own subdirectory under PluginsConfig.Directory:
//
// plugins/
// hello/
// plugin.json
// hello.wasm
// game-detection/
// plugin.json
// detector.wasm
// assets/...
//
// Loader walks the directory, parses every plugin.json, and returns a slice
// of foundPlugin records. The Registry then persists each into the store.
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type foundPlugin struct {
Manifest *Manifest
Dir string
WASMPath string
}
// scanPluginDirectory walks dir non-recursively and parses plugin.json from
// every immediate subdirectory. Errors on individual plugins are wrapped and
// returned alongside the successful entries.
func scanPluginDirectory(dir string) ([]foundPlugin, error) {
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
// Directory absent is fine — operators may not have created it yet.
return nil, nil
}
return nil, err
}
var found []foundPlugin
for _, e := range entries {
if !e.IsDir() {
continue
}
pluginDir := filepath.Join(dir, e.Name())
manifestPath := filepath.Join(pluginDir, "plugin.json")
raw, rdErr := os.ReadFile(manifestPath)
if rdErr != nil {
if os.IsNotExist(rdErr) {
continue
}
return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr)
}
manifest, parseErr := ParseManifest(raw)
if parseErr != nil {
return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr)
}
wasmPath := filepath.Join(pluginDir, manifest.Entrypoint)
if _, statErr := os.Stat(wasmPath); statErr != nil {
return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr)
}
found = append(found, foundPlugin{
Manifest: manifest,
Dir: pluginDir,
WASMPath: wasmPath,
})
}
return found, nil
}
// serialize returns a canonical JSON encoding of the manifest, used as the
// manifest_json column value in the plugins table.
func (m *Manifest) serialize() (string, error) {
b, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("manifest serialize: %w", err)
}
return string(b), nil
}
+129
View File
@@ -0,0 +1,129 @@
// Package plugin implements the OwnCord plugin runtime.
//
// Phase C Step 9 — Wazero Plugin Runtime.
//
// The package is split into:
//
// - manifest.go : declarative plugin metadata + permission checks
// - registry.go : in-memory registry + lifecycle (install/enable/load)
// - loader.go : on-disk discovery and package validation
// - sandbox.go : Wazero runtime configuration (build tag `wazero`)
// - host_*.go : capability-scoped host API surfaces
// - errors.go
//
// The default `go build ./...` ships a stub runtime that satisfies every call
// site without pulling Wazero into go.mod. To compile the real runtime:
//
// go get github.com/tetratelabs/wazero
// go build -tags wazero ./...
//
// This mirrors the postgres / otel build-tag approach used elsewhere in the
// repo so the default build stays self-contained.
package plugin
import (
"encoding/json"
"fmt"
"strings"
)
// Manifest is the parsed plugin metadata declared in plugin.json (or
// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally
// flat so the default JSON parser handles it without a TOML dependency.
type Manifest struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Entrypoint string `json:"entrypoint"` // relative .wasm path
Permissions []string `json:"permissions"`
Resources Resources `json:"resources"`
UI UISpec `json:"ui"`
}
// Resources caps the plugin's runtime budget. Zero means "use the runtime
// default from PluginsConfig".
type Resources struct {
MaxMemoryMB int `json:"max_memory_mb"`
CPUBudgetMs int `json:"cpu_budget_ms"`
}
// UISpec describes the optional client-side rendering surface.
type UISpec struct {
Tabs []UITab `json:"tabs"`
}
// UITab is a single iframe-rendered plugin tab.
type UITab struct {
ID string `json:"id"`
Label string `json:"label"`
Asset string `json:"asset"` // relative html path
}
// Capability is a permission name a plugin may request.
type Capability string
const (
CapCommands Capability = "commands"
CapEvents Capability = "events"
CapStorage Capability = "storage"
CapHTTP Capability = "http"
CapUI Capability = "ui"
)
// validCapabilities is the closed set of capability names a manifest may
// declare. Anything else is rejected at load time.
var validCapabilities = map[Capability]bool{
CapCommands: true,
CapEvents: true,
CapStorage: true,
CapHTTP: true,
CapUI: true,
}
// ParseManifest decodes a plugin.json byte slice and validates required fields.
func ParseManifest(raw []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err)
}
if err := m.Validate(); err != nil {
return nil, err
}
return &m, nil
}
// Validate enforces the manifest schema.
func (m *Manifest) Validate() error {
if strings.TrimSpace(m.Name) == "" {
return fmt.Errorf("plugin manifest: name is required")
}
if strings.TrimSpace(m.Version) == "" {
return fmt.Errorf("plugin manifest: version is required")
}
if strings.TrimSpace(m.Entrypoint) == "" {
return fmt.Errorf("plugin manifest: entrypoint is required")
}
if !strings.HasSuffix(m.Entrypoint, ".wasm") {
return fmt.Errorf("plugin manifest: entrypoint %q must end in .wasm", m.Entrypoint)
}
for _, p := range m.Permissions {
if !validCapabilities[Capability(p)] {
return fmt.Errorf("plugin manifest: unknown permission %q", p)
}
}
if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 {
return fmt.Errorf("plugin manifest: resources must be non-negative")
}
return nil
}
// HasCapability reports whether the manifest declared cap.
func (m *Manifest) HasCapability(cap Capability) bool {
for _, p := range m.Permissions {
if Capability(p) == cap {
return true
}
}
return false
}
+148
View File
@@ -0,0 +1,148 @@
// Phase C Step 9 — manifest + loader tests.
//
// These tests cover the default-build code path (no wazero). They confirm:
// - the JSON manifest parses and validates,
// - the loader walks a directory and surfaces well-formed plugins,
// - the registry persists discovered plugins into a PluginStore,
// - per-capability gating refuses calls when the manifest didn't grant them.
//
// Wazero-specific tests live in sandbox_wazero_test.go and only run with the
// `wazero` build tag.
package plugin
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/owncord/server/store"
)
func TestParseManifestRoundTrip(t *testing.T) {
raw := []byte(`{
"name": "hello",
"version": "0.1.0",
"entrypoint": "hello.wasm",
"permissions": ["commands", "storage"],
"resources": {"max_memory_mb": 16, "cpu_budget_ms": 50}
}`)
m, err := ParseManifest(raw)
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
if m.Name != "hello" || m.Version != "0.1.0" {
t.Fatalf("unexpected manifest fields: %+v", m)
}
if !m.HasCapability(CapCommands) {
t.Fatal("expected commands capability")
}
if m.HasCapability(CapHTTP) {
t.Fatal("did not expect http capability")
}
}
func TestParseManifestRejectsBadEntrypoint(t *testing.T) {
cases := map[string]string{
"missing entrypoint": `{"name":"x","version":"1","entrypoint":""}`,
"non-wasm entrypoint": `{"name":"x","version":"1","entrypoint":"x.so"}`,
"unknown capability": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["badperm"]}`,
"missing version": `{"name":"x","entrypoint":"x.wasm"}`,
"missing name": `{"version":"1","entrypoint":"x.wasm"}`,
}
for label, body := range cases {
t.Run(label, func(t *testing.T) {
if _, err := ParseManifest([]byte(body)); err == nil {
t.Fatalf("expected error for %s", label)
}
})
}
}
func TestScanPluginDirectoryHandlesMissing(t *testing.T) {
got, err := scanPluginDirectory(filepath.Join(t.TempDir(), "does-not-exist"))
if err != nil {
t.Fatalf("scanPluginDirectory: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected empty result, got %d", len(got))
}
}
func TestScanPluginDirectoryParsesValidPlugin(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "hello")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644); err != nil {
t.Fatal(err)
}
got, err := scanPluginDirectory(dir)
if err != nil {
t.Fatalf("scanPluginDirectory: %v", err)
}
if len(got) != 1 || got[0].Manifest.Name != "hello" {
t.Fatalf("unexpected scan result: %+v", got)
}
}
func TestRegistryInstallFromDisk(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "hello")
_ = os.MkdirAll(pluginDir, 0o755)
_ = os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}`),
0o644)
_ = os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644)
mem := store.NewMemStore()
reg, err := NewRegistry(Config{Directory: dir, Store: mem})
if err != nil {
t.Fatal(err)
}
if err := reg.LoadAll(context.Background()); err != nil {
t.Fatalf("LoadAll: %v", err)
}
rows, err := mem.ListPlugins(context.Background())
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Name != "hello" {
t.Fatalf("expected hello plugin row, got %+v", rows)
}
}
func TestStorageGatedByCapability(t *testing.T) {
mem := store.NewMemStore()
reg, err := NewRegistry(Config{Store: mem})
if err != nil {
t.Fatal(err)
}
inst := &Instance{
ID: 1,
Manifest: &Manifest{Name: "x", Permissions: []string{}},
}
if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err == nil {
t.Fatal("expected ErrCapabilityNotGranted")
}
inst.Manifest.Permissions = []string{string(CapStorage)}
// Pre-create the plugin row so the KV foreign-key-equivalent succeeds.
if _, err := mem.InstallPlugin(context.Background(), "x", "0.1", "{}"); err != nil {
t.Fatal(err)
}
if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err != nil {
t.Fatalf("StoragePut: %v", err)
}
got, err := reg.StorageGet(context.Background(), inst, "k")
if err != nil {
t.Fatalf("StorageGet: %v", err)
}
if string(got) != "v" {
t.Fatalf("expected v, got %q", got)
}
}
+254
View File
@@ -0,0 +1,254 @@
// Phase C Step 9 — Plugin registry, lifecycle, and host-API plumbing.
//
// The Registry is the long-lived handle the rest of the server holds onto. It
// owns the Wazero runtime (in the wazero-tagged build), the loaded plugin
// instances, and the dispatch tables for host-API capabilities (commands,
// events, storage, http, ui).
//
// In the default build the runtime is a stub: LoadAll walks the plugins
// directory and persists each manifest into the PluginStore so admins can see
// what is "installed", but the .wasm files are NOT executed. Calling
// Dispatch() in the default build returns ErrRuntimeUnavailable.
package plugin
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/owncord/server/store"
)
// Config is the runtime configuration sourced from PluginsConfig.
type Config struct {
Directory string
MaxMemoryMB int
CPUBudgetMs int
HTTPAllowlist []string
Store store.PluginStore
}
// Registry is the central plugin coordinator.
type Registry struct {
cfg Config
mu sync.RWMutex
plugins map[int64]*Instance // by plugin row id
byName map[string]*Instance // by manifest name
commands map[string]*Instance // command name → owning plugin
uiTabs []UITabBinding // declared by `ui` capability plugins
// runtimePlatform is set by the wazero-tagged build's NewRegistry to a
// concrete *wazero.Runtime. The default build leaves it nil and falls
// back to manifest-only behaviour.
runtimePlatform any
}
// Instance is a single loaded plugin.
type Instance struct {
ID int64
Manifest *Manifest
WASMPath string
Enabled bool
// module is the wazero compiled module in the wazero-tagged build, or
// nil in the default build.
module any
}
// UITabBinding is the public projection of a plugin's declared UI tab,
// served to the client bridge so it can render iframe tabs.
type UITabBinding struct {
PluginID int64
PluginName string
Tab UITab
}
// NewRegistry constructs a registry. In the default build it is a thin
// holder; the wazero-tagged build replaces this constructor with one that
// stands up a real Wazero runtime.
func NewRegistry(cfg Config) (*Registry, error) {
if cfg.Store == nil {
return nil, fmt.Errorf("plugin: NewRegistry requires a non-nil PluginStore")
}
return &Registry{
cfg: cfg,
plugins: make(map[int64]*Instance),
byName: make(map[string]*Instance),
commands: make(map[string]*Instance),
}, nil
}
// Close shuts the registry down. In the wazero-tagged build it tears the
// runtime down and frees module memory.
func (r *Registry) Close(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
for id := range r.plugins {
delete(r.plugins, id)
}
for n := range r.byName {
delete(r.byName, n)
}
for c := range r.commands {
delete(r.commands, c)
}
r.uiTabs = nil
return nil
}
// LoadAll scans cfg.Directory and persists every plugin.json found into the
// PluginStore. In the wazero-tagged build it then compiles each entrypoint
// into a runnable module; the default build stops at the persistence step.
func (r *Registry) LoadAll(ctx context.Context) error {
if r == nil {
return nil
}
manifests, err := scanPluginDirectory(r.cfg.Directory)
if err != nil {
return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err)
}
for _, found := range manifests {
if err := r.installFromDisk(ctx, found); err != nil {
slog.Warn("plugin: failed to install from disk", "name", found.Manifest.Name, "err", err)
continue
}
}
return r.activateAll(ctx)
}
// installFromDisk persists a manifest discovered on disk into the PluginStore
// and registers it in the in-memory registry.
func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error {
manifestJSON, err := found.Manifest.serialize()
if err != nil {
return err
}
id, err := r.cfg.Store.InstallPlugin(ctx, found.Manifest.Name, found.Manifest.Version, manifestJSON)
if err != nil {
return fmt.Errorf("InstallPlugin: %w", err)
}
r.mu.Lock()
defer r.mu.Unlock()
inst := &Instance{
ID: id,
Manifest: found.Manifest,
WASMPath: found.WASMPath,
Enabled: false,
}
r.plugins[id] = inst
r.byName[found.Manifest.Name] = inst
return nil
}
// activateAll attempts to compile + register host-API hooks for every plugin
// row in the PluginStore that is marked enabled. The default build is a
// no-op (no Wazero modules to compile).
func (r *Registry) activateAll(ctx context.Context) error {
rows, err := r.cfg.Store.ListPlugins(ctx)
if err != nil {
return fmt.Errorf("ListPlugins: %w", err)
}
for _, row := range rows {
if !row.Enabled {
continue
}
r.mu.Lock()
inst, ok := r.byName[row.Name]
r.mu.Unlock()
if !ok {
slog.Warn("plugin: enabled row has no on-disk manifest, skipping", "name", row.Name)
continue
}
if err := r.activate(ctx, inst); err != nil {
slog.Warn("plugin: activation failed", "name", row.Name, "err", err)
}
}
return nil
}
// activate compiles and starts a single plugin module. Default build returns
// ErrRuntimeUnavailable; the wazero-tagged build replaces this with the real
// implementation via the runtimePlatform field.
func (r *Registry) activate(ctx context.Context, inst *Instance) error {
if r.runtimePlatform == nil {
return ErrRuntimeUnavailable
}
return r.activateWithRuntime(ctx, inst)
}
// EnablePlugin marks a plugin enabled in the store, then attempts to load it.
func (r *Registry) EnablePlugin(ctx context.Context, id int64) error {
if err := r.cfg.Store.EnablePlugin(ctx, id); err != nil {
return err
}
r.mu.RLock()
inst, ok := r.plugins[id]
r.mu.RUnlock()
if !ok {
return ErrPluginNotFound
}
inst.Enabled = true
if err := r.activate(ctx, inst); err != nil {
// Roll back the DB flag so the next start attempt is consistent.
_ = r.cfg.Store.DisablePlugin(ctx, id)
inst.Enabled = false
return err
}
return nil
}
// DisablePlugin marks a plugin disabled and tears its module down.
func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
if err := r.cfg.Store.DisablePlugin(ctx, id); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if inst, ok := r.plugins[id]; ok {
inst.Enabled = false
// Drop command bindings owned by this plugin.
for cmd, owner := range r.commands {
if owner == inst {
delete(r.commands, cmd)
}
}
}
return nil
}
// UninstallPlugin removes a plugin entirely.
func (r *Registry) UninstallPlugin(ctx context.Context, id int64) error {
_ = r.DisablePlugin(ctx, id)
if err := r.cfg.Store.UninstallPlugin(ctx, id); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if inst, ok := r.plugins[id]; ok {
delete(r.byName, inst.Manifest.Name)
}
delete(r.plugins, id)
return nil
}
// List returns the currently registered plugins. Read-only snapshot.
func (r *Registry) List() []*Instance {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Instance, 0, len(r.plugins))
for _, p := range r.plugins {
out = append(out, p)
}
return out
}
// UITabBindings returns the declared UI tabs across enabled plugins.
func (r *Registry) UITabBindings() []UITabBinding {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]UITabBinding, len(r.uiTabs))
copy(out, r.uiTabs)
return out
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !wazero
// Default plugin runtime: no Wazero. Plugin manifests are still discovered,
// persisted, and surfaced through the admin API, but `.wasm` modules are not
// executed. To enable real WASM execution build with `-tags wazero`.
package plugin
import (
"context"
)
// activateWithRuntime is a no-op in the default build. It is only called from
// Registry.activate when runtimePlatform is non-nil, which never happens here.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
return ErrRuntimeUnavailable
}
// invokeCommand returns an error result instructing the operator to enable
// the wazero build tag. Default build only.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
return &CommandResult{
Reply: "plugin runtime disabled — rebuild server with -tags wazero to execute plugin commands",
}, true
}
+79
View File
@@ -0,0 +1,79 @@
//go:build wazero
// Real Wazero-backed plugin runtime. Compiled only with `-tags wazero`,
// matching the postgres / otel build-tag pattern used elsewhere in the repo.
//
// IMPORTANT: This file is a structural skeleton — it will fail to compile
// until github.com/tetratelabs/wazero is added to go.mod. To finish wiring it
// on a machine with network access:
//
// cd Server
// go get github.com/tetratelabs/wazero@latest
// go mod tidy
// go build -tags wazero ./...
//
// The skeleton documents the intended call graph so the implementation work
// is mechanical: each TODO marker maps to a wazero API call.
package plugin
import (
"context"
"fmt"
"os"
)
// activateWithRuntime compiles inst.WASMPath into a wazero module, applies
// the per-plugin resource caps, and registers exported functions for each
// declared capability.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
wasmBytes, err := os.ReadFile(inst.WASMPath)
if err != nil {
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
}
_ = wasmBytes
_ = ctx
// TODO(wazero): replace with the real wiring once go.mod has wazero:
//
// runtime := r.runtimePlatform.(wazero.Runtime)
// compiled, err := runtime.CompileModule(ctx, wasmBytes)
// if err != nil { return fmt.Errorf("compile: %w", err) }
//
// modCfg := wazero.NewModuleConfig().
// WithName(inst.Manifest.Name).
// WithStdout(io.Discard).
// WithStderr(io.Discard)
//
// memBytes := uint32(inst.Manifest.Resources.MaxMemoryMB)
// if memBytes == 0 { memBytes = uint32(r.cfg.MaxMemoryMB) }
// // wazero pages are 64 KiB; the runtime config caps via WithMemoryLimitPages.
//
// module, err := runtime.InstantiateModule(ctx, compiled, modCfg)
// if err != nil { return fmt.Errorf("instantiate: %w", err) }
//
// inst.module = module
//
// // Walk inst.Manifest.Permissions and call host_*.Register* for each
// // capability so the runtime knows what exports to look for.
return fmt.Errorf("plugin %q: wazero runtime skeleton incomplete (see sandbox_wazero.go)", inst.Manifest.Name)
}
// invokeCommand calls the plugin's `command_dispatch` exported function with
// the marshalled command + args, and decodes the response into a CommandResult.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
_ = ctx
_ = inst
_ = userID
_ = channelID
_ = cmd
_ = args
// TODO(wazero): real call:
// fn := module.ExportedFunction("command_dispatch")
// payload := encodeCommand(userID, channelID, cmd, args)
// result, err := fn.Call(ctx, ...)
// ...
return &CommandResult{
Reply: "plugin runtime: command dispatch not yet implemented in skeleton",
}, true
}