feat: WO-PS-009 SDK plugin types

- plugin/types.go: Pool, EmailSender, JobHandlerFunc, Dependency,
  AdminPage, AIAction, MasterPageDefinition, status/source constants,
  MediaAnalyzedEvent, ModerationDecisionEvent, MediaHooksProvider,
  DirectoryExtensions
- plugin/deps.go: ServiceDeps with typed capability interfaces
  (Content, Settings, Gating, Crypto, ToolRegistry, JobRunner,
  EmbeddingService, RAGService)
- plugin/registration.go: PluginRegistration, RegisterFunc
- plugin/service.go: ConnectServiceBinding with generics, ServiceMount,
  ServiceRegistration
- plugin/provisioner.go: Provisioner interface with all config types
- plugin/css_manifest.go: CSSManifest, MergedCSSManifest, MergeCSSManifests
- plugin/version.go: ParseModVersion, CompareVersions
- plugin/topo_sort.go: TopologicalSort (Kahn's algorithm)
- plugin/block_registry.go: PluginBlockRegistry (auto-prefixing wrapper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-04-30 22:41:52 +08:00
parent 79c558a968
commit 7c20538a4e
12 changed files with 626 additions and 1 deletions

10
go.mod
View File

@ -3,6 +3,16 @@ module git.dev.alexdunmow.com/ninja/core
go 1.26
require (
connectrpc.com/connect v1.19.2
github.com/a-h/templ v0.3.1001
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/mod v0.27.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

32
go.sum
View File

@ -1,6 +1,38 @@
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

32
plugin/block_registry.go Normal file
View File

@ -0,0 +1,32 @@
package plugin
import (
"io/fs"
"git.dev.alexdunmow.com/ninja/core/blocks"
)
// PluginBlockRegistry wraps a BlockRegistry to auto-prefix block keys with the plugin name.
type PluginBlockRegistry struct {
inner blocks.BlockRegistry
prefix string
}
// NewPluginBlockRegistry creates a block registry that auto-prefixes keys.
func NewPluginBlockRegistry(inner blocks.BlockRegistry, pluginName string) *PluginBlockRegistry {
return &PluginBlockRegistry{inner: inner, prefix: pluginName}
}
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
meta.Key = r.prefix + ":" + meta.Key
meta.Source = r.prefix
r.inner.Register(meta, fn)
}
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
r.inner.RegisterTemplateOverride(templateKey, blockKey, fn)
}
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
return r.inner.LoadSchemasFromFS(fsys)
}

44
plugin/css_manifest.go Normal file
View File

@ -0,0 +1,44 @@
package plugin
import "maps"
// CSSManifest declares a plugin's CSS dependencies and customizations.
type CSSManifest struct {
NPMPackages map[string]string `json:"npm_packages,omitempty"`
CSSDirectives []string `json:"css_directives,omitempty"`
InputCSSAppend string `json:"input_css_append,omitempty"`
}
// MergedCSSManifest aggregates CSS manifests from all plugins.
type MergedCSSManifest struct {
NPMPackages map[string]string `json:"npm_packages"`
CSSDirectives []string `json:"css_directives"`
InputCSSAppend string `json:"input_css_append"`
Sources []string `json:"sources"`
}
// MergeCSSManifests combines multiple plugin CSS manifests into one.
func MergeCSSManifests(manifests map[string]*CSSManifest) *MergedCSSManifest {
merged := &MergedCSSManifest{
NPMPackages: make(map[string]string),
}
for name, m := range manifests {
if m == nil {
continue
}
merged.Sources = append(merged.Sources, name)
maps.Copy(merged.NPMPackages, m.NPMPackages)
merged.CSSDirectives = append(merged.CSSDirectives, m.CSSDirectives...)
if m.InputCSSAppend != "" {
if merged.InputCSSAppend != "" {
merged.InputCSSAppend += "\n"
}
merged.InputCSSAppend += "/* === Plugin: " + name + " === */\n"
merged.InputCSSAppend += m.InputCSSAppend
}
}
return merged
}

65
plugin/deps.go Normal file
View File

@ -0,0 +1,65 @@
package plugin
import (
"context"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/ninja/core/ai"
"git.dev.alexdunmow.com/ninja/core/content"
"git.dev.alexdunmow.com/ninja/core/crypto"
"git.dev.alexdunmow.com/ninja/core/gating"
"git.dev.alexdunmow.com/ninja/core/settings"
)
// ServiceDeps provides dependencies that plugins need for RPC service handlers.
type ServiceDeps struct {
// Capability interfaces — typed access to CMS functionality
Content content.Content
Settings settings.Settings
Gating gating.Gating
Crypto crypto.Crypto
// Database — for plugin's own sqlc queries
Pool Pool
// RPC interceptors — core-provided Connect handler options
Interceptors connect.Option
// Site configuration
MediaPath string
AppURL string
// AI
ToolRegistry ai.ToolRegistry
AITextCall func(ctx context.Context, taskKey, systemPrompt, userMessage string) (string, error)
// Email
EmailSender EmailSender
// Extension points — typed as narrow interfaces where possible
JobRunner JobRunner
EmbeddingService EmbeddingService
RAGService RAGService
}
// JobRunner submits background jobs for async processing.
type JobRunner interface {
Submit(ctx context.Context, jobType string, config []byte) error
}
// EmbeddingService generates and manages text embeddings.
type EmbeddingService interface {
GenerateEmbedding(ctx context.Context, text string) ([]float32, error)
}
// RAGService provides retrieval-augmented generation for AI agents.
type RAGService interface {
Query(ctx context.Context, query string, limit int) ([]RAGResult, error)
}
// RAGResult is a single result from a RAG query.
type RAGResult struct {
Content string
Score float64
Metadata map[string]string
}

View File

@ -1 +0,0 @@
package plugin

107
plugin/provisioner.go Normal file
View File

@ -0,0 +1,107 @@
package plugin
import (
"context"
"encoding/json"
"github.com/google/uuid"
)
// Provisioner allows plugins to ensure required resources exist on startup.
// All methods are idempotent — they check first and only create if missing.
type Provisioner interface {
EnsureDataTable(config DataTableConfig) error
MergeSiteSettings(defaults map[string]any) error
EnsureSetting(key string, defaultValue map[string]any) error
EnsurePage(config PageConfig) error
OverrideSiteSettings(overrides map[string]any) error
EnsureMenuItem(menuName string, config MenuItemConfig) error
RegisterEmbeddingConfig(config EmbeddingConfigDef) error
EnsureEmbed(config EmbedConfig) error
EnsureJobSchedule(config JobScheduleConfig) error
UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value map[string]any) error
DisableOrphanedJobSchedules(registeredTypes []string) error
EnsurePlugin(name string) error
EnsureCustomColor(config CustomColorConfig) error
}
// DataTableConfig defines a data table to provision.
type DataTableConfig struct {
Key string
Name string
Description string
Schema json.RawMessage
PrimaryKey string
}
// PageConfig defines a page to provision.
type PageConfig struct {
Slug string
ParentSlug string
Title string
TemplateKey string
Blocks []PageBlockConfig
DetailSourceType string
DetailSourceKey string
DetailSlugField string
ReconcileBlocks bool
ReconcileTemplate bool
}
// PageBlockConfig defines a block within a provisioned page.
type PageBlockConfig struct {
BlockKey string
Title string
Content map[string]any
HtmlContent *string
Slot string
SortOrder int32
}
// EmbeddingConfigDef defines embedding text generation for a data table.
type EmbeddingConfigDef struct {
TableKey string
TextTemplate string
Enabled bool
}
// MenuItemConfig defines a menu item to provision.
type MenuItemConfig struct {
Label string
URL string
PageSlug string
SortOrder int32
}
// EmbedConfig defines an embeddable component template.
type EmbedConfig struct {
Key string
Title string
Description string
Icon string
LabelField string
Template string
RenderFunc func(ctx context.Context, content map[string]any) string
DataSource EmbedDataSource
}
// EmbedDataSource specifies where embed data comes from.
type EmbedDataSource struct {
Type string
TableKey string
}
// JobScheduleConfig defines a cron schedule for a background job.
type JobScheduleConfig struct {
JobType string
CronExpression string
Config json.RawMessage
}
// CustomColorConfig defines a custom theme color variable.
type CustomColorConfig struct {
Name string
LightValue string
DarkValue string
Source string
}

44
plugin/registration.go Normal file
View File

@ -0,0 +1,44 @@
package plugin
import (
"io/fs"
"net/http"
"git.dev.alexdunmow.com/ninja/core/blocks"
"git.dev.alexdunmow.com/ninja/core/templates"
)
// RegisterFunc is the function signature for plugin registration.
// Plugins register their templates and blocks through the provided registries.
type RegisterFunc func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error
// PluginRegistration defines a compiled-in plugin's entry points.
type PluginRegistration struct {
Name string
Register RegisterFunc
RegisterWithProvisioner func(tr templates.TemplateRegistry, br blocks.BlockRegistry, p Provisioner) error
Assets func() http.Handler
Schemas func() fs.FS
SettingsSchema func() []byte
ThemePresets func() []byte
BundledFonts func() []byte
MasterPages func() []MasterPageDefinition
HTTPHandler func(deps ServiceDeps) http.Handler
SettingsPanel func() string
AdminPages func() []AdminPage
CSSManifest func() *CSSManifest
ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error)
JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc
AIActions func() []AIAction
DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider
Dependencies []Dependency
Migrations func() fs.FS
Version string
}

61
plugin/service.go Normal file
View File

@ -0,0 +1,61 @@
package plugin
import (
"maps"
"net/http"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/ninja/core/rbac"
)
// ServiceMount is a path + handler pair for an RPC service.
type ServiceMount struct {
Path string
Handler http.Handler
}
// ConnectServiceBinding describes a plugin RPC service mounted with the core interceptor chain.
type ConnectServiceBinding struct {
name string
build func(opts ...connect.HandlerOption) (string, http.Handler)
methodRoles map[string]rbac.Role
}
// NewConnectServiceBinding creates a service binding whose handler is always
// constructed by core with the supplied Connect handler options.
func NewConnectServiceBinding[T any](
name string,
svc T,
constructor func(T, ...connect.HandlerOption) (string, http.Handler),
methodRoles map[string]rbac.Role,
) ConnectServiceBinding {
clonedRoles := make(map[string]rbac.Role, len(methodRoles))
maps.Copy(clonedRoles, methodRoles)
return ConnectServiceBinding{
name: name,
build: func(opts ...connect.HandlerOption) (string, http.Handler) {
return constructor(svc, opts...)
},
methodRoles: clonedRoles,
}
}
func (b ConnectServiceBinding) Name() string {
return b.name
}
func (b ConnectServiceBinding) Build(opts ...connect.HandlerOption) (string, http.Handler) {
return b.build(opts...)
}
func (b ConnectServiceBinding) MethodRoles() map[string]rbac.Role {
cloned := make(map[string]rbac.Role, len(b.methodRoles))
maps.Copy(cloned, b.methodRoles)
return cloned
}
// ServiceRegistration bundles plugin RPC services with their RBAC method roles.
type ServiceRegistration struct {
Services []ConnectServiceBinding
}

64
plugin/topo_sort.go Normal file
View File

@ -0,0 +1,64 @@
package plugin
import "fmt"
// TopologicalSort orders plugins so that dependencies come before dependents.
// Returns error on circular dependencies or missing required dependencies.
func TopologicalSort(plugins []PluginRegistration) ([]PluginRegistration, error) {
byName := make(map[string]*PluginRegistration, len(plugins))
for i := range plugins {
byName[plugins[i].Name] = &plugins[i]
}
for _, p := range plugins {
for _, dep := range p.Dependencies {
if dep.Required {
if _, ok := byName[dep.Plugin]; !ok {
return nil, fmt.Errorf("plugin %q requires %q, but it is not available", p.Name, dep.Plugin)
}
}
}
}
inDegree := make(map[string]int, len(plugins))
dependents := make(map[string][]string)
for _, p := range plugins {
if _, ok := inDegree[p.Name]; !ok {
inDegree[p.Name] = 0
}
for _, dep := range p.Dependencies {
if _, ok := byName[dep.Plugin]; ok {
inDegree[p.Name]++
dependents[dep.Plugin] = append(dependents[dep.Plugin], p.Name)
}
}
}
var queue []string
for _, p := range plugins {
if inDegree[p.Name] == 0 {
queue = append(queue, p.Name)
}
}
var result []PluginRegistration
for len(queue) > 0 {
name := queue[0]
queue = queue[1:]
result = append(result, *byName[name])
for _, dep := range dependents[name] {
inDegree[dep]--
if inDegree[dep] == 0 {
queue = append(queue, dep)
}
}
}
if len(result) != len(plugins) {
return nil, fmt.Errorf("circular dependency detected among plugins")
}
return result, nil
}

121
plugin/types.go Normal file
View File

@ -0,0 +1,121 @@
package plugin
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Pool interface for database transactions (satisfied by *pgxpool.Pool).
type Pool interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
// JobHandlerFunc is the signature for background job handlers.
type JobHandlerFunc func(ctx context.Context, config json.RawMessage, progress func(current, total int, message string)) (json.RawMessage, error)
// Dependency declares that a plugin requires another plugin.
type Dependency struct {
Plugin string
MinVersion string
Required bool
}
// EmailSender is the narrow plugin-facing interface for sending emails.
type EmailSender interface {
Send(to, subject, body string) error
}
// AdminPage defines a page that a plugin registers in the admin sidebar.
type AdminPage struct {
Key string
Title string
Icon string
Route string
}
// AIAction declares an AI task registered in the AI Action Matrix.
type AIAction struct {
Key string
Title string
Description string
DefaultProvider string
DefaultModel string
}
// MasterPageBlock defines a block to add to a master page during provisioning.
type MasterPageBlock struct {
BlockKey string
Title string
Content map[string]any
HtmlContent string
Slot string
SortOrder int32
}
// MasterPageDefinition defines a default master page that a plugin provisions.
type MasterPageDefinition struct {
Key string
Title string
PageTemplates []string
Blocks []MasterPageBlock
}
// Plugin status constants.
const (
StatusLoaded = "loaded"
StatusFailed = "failed"
StatusDisabled = "disabled"
StatusRebuilding = "rebuilding"
StatusPendingRestart = "pending_restart"
)
// Plugin source constants.
const (
SourceBundled = "bundled"
SourceInstalled = "installed"
)
// MediaAnalyzedEvent is emitted after media scanning completes.
type MediaAnalyzedEvent struct {
MediaID uuid.UUID
AnalysisID uuid.UUID
ContentHash string
Status string
SourcePlugin string
SourceType string
SourceRefID uuid.UUID
SafeAdult string
SafeViolence string
SafeRacy string
}
// ModerationDecisionEvent is emitted when a moderation decision is made.
type ModerationDecisionEvent struct {
MediaID uuid.UUID
AnalysisID uuid.UUID
Status string
PreviousStatus string
SourcePlugin string
SourceType string
SourceRefID uuid.UUID
ModeratedBy uuid.UUID
Note string
}
// MediaHooksProvider is the interface plugins implement to receive media lifecycle events.
type MediaHooksProvider interface {
OnMediaAnalyzed(ctx context.Context, event MediaAnalyzedEvent) error
OnModerationDecision(ctx context.Context, event ModerationDecisionEvent) error
}
// DirectoryExtensions allows plugins to customize the directory handler UI.
type DirectoryExtensions struct {
PanelSections []func(data map[string]any) string
PinDecorators []func(pin map[string]any, data map[string]any)
BooleanFilterFields []string
SelectFilterFields []string
BadgeLabels map[string][2]string
}

46
plugin/version.go Normal file
View File

@ -0,0 +1,46 @@
package plugin
import (
"bufio"
"bytes"
"strings"
"golang.org/x/mod/semver"
)
// ParseModVersion extracts the version string from an embedded plugin.mod file.
// Returns "0.0.0" if parsing fails or version is not found.
func ParseModVersion(data []byte) string {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "version") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"`)
if val != "" {
return val
}
}
}
return "0.0.0"
}
// CompareVersions compares two semver strings.
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
// Returns 0 if either version is invalid.
func CompareVersions(v1, v2 string) int {
if !strings.HasPrefix(v1, "v") {
v1 = "v" + v1
}
if !strings.HasPrefix(v2, "v") {
v2 = "v" + v2
}
if !semver.IsValid(v1) || !semver.IsValid(v2) {
return 0
}
return semver.Compare(v1, v2)
}