From 19800917a6a4c60f42362e37688aed2da809217c Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 21 Dec 2025 21:29:45 -0500 Subject: [PATCH] feat(plugins): add capability detection for plugins based on exported functions Signed-off-by: Deluan --- Makefile | 2 +- plugins/README.md | 49 ++++---- plugins/capabilities.go | 62 ++++++++++ plugins/capabilities_test.go | 81 +++++++++++++ plugins/manager.go | 41 ++++--- plugins/manifest.go | 178 +---------------------------- plugins/manifest.json | 82 ++++++++++++++ plugins/manifest_gen.go | 85 ++++++++++++++ plugins/manifest_test.go | 212 +++++++++++------------------------ 9 files changed, 427 insertions(+), 365 deletions(-) create mode 100644 plugins/capabilities.go create mode 100644 plugins/capabilities_test.go create mode 100644 plugins/manifest.json create mode 100644 plugins/manifest_gen.go diff --git a/Makefile b/Makefile index d6e8d449b..45e43f73e 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code - PATH=$$PATH:./bin golangci-lint run -v --timeout 5m + PATH=$$PATH:./bin golangci-lint run --timeout 5m .PHONY: lint lintall: lint ##@Development Lint Go and JS code diff --git a/plugins/README.md b/plugins/README.md index 876e119e7..33e09b3c6 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -44,34 +44,35 @@ Plugins must export an `nd_manifest` function that returns JSON: "version": "1.0.0", "description": "Plugin description", "website": "https://example.com", - "capabilities": ["MetadataAgent"], "permissions": { "http": { "reason": "Fetch metadata from external API", - "allowedUrls": { - "https://api.example.com/*": ["GET"] - } + "allowedHosts": ["api.example.com", "*.musicbrainz.org"] } } } ``` +**Note**: Capabilities are auto-detected based on which functions the plugin exports. You don't need to declare them in the manifest. + ## Capabilities +Capabilities are automatically detected by examining which functions a plugin exports. There's no need to declare capabilities in the manifest. + ### MetadataAgent -Provides artist and album metadata. Implement one or more of these functions: +Provides artist and album metadata. A plugin has this capability if it exports one or more of these functions: -| Function | Input | Output | Description | -|----------|-------|--------|-------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | -| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | -| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | -| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | -| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | -| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | -| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | +| Function | Input | Output | Description | +|---------------------------|----------------------------|----------------------------------|----------------------| +| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | +| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | +| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | +| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | +| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | +| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | +| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | +| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | ## Developing Plugins @@ -88,19 +89,17 @@ import ( ) type Manifest struct { - Name string `json:"name"` - Author string `json:"author"` - Version string `json:"version"` - Capabilities []string `json:"capabilities"` + Name string `json:"name"` + Author string `json:"author"` + Version string `json:"version"` } //go:wasmexport nd_manifest func ndManifest() int32 { manifest := Manifest{ - Name: "My Plugin", - Author: "Me", - Version: "1.0.0", - Capabilities: []string{"MetadataAgent"}, + Name: "My Plugin", + Author: "Me", + Version: "1.0.0", } out, _ := json.Marshal(manifest) pdk.Output(out) @@ -143,7 +142,7 @@ tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go ### Using HTTP -Plugins can make HTTP requests using the Extism PDK. The host controls which URLs are allowed via the `permissions.http.allowedUrls` manifest field. +Plugins can make HTTP requests using the Extism PDK. The host controls which hosts are allowed via the `permissions.http.allowedHosts` manifest field. ```go //go:wasmexport nd_get_artist_biography @@ -177,7 +176,7 @@ if !ok { Plugins run in a secure WebAssembly sandbox with these restrictions: -1. **URL Allowlisting**: Only URLs listed in `permissions.http.allowedUrls` are accessible +1. **Host Allowlisting**: Only hosts listed in `permissions.http.allowedHosts` are accessible 2. **No File System Access**: Plugins cannot access the file system 3. **No Network Listeners**: Plugins cannot bind ports or create servers 4. **Config Isolation**: Plugins receive only their own config section diff --git a/plugins/capabilities.go b/plugins/capabilities.go new file mode 100644 index 000000000..09d2ca93c --- /dev/null +++ b/plugins/capabilities.go @@ -0,0 +1,62 @@ +package plugins + +// Capability represents a plugin capability type. +// Capabilities are detected by checking which functions a plugin exports. +type Capability string + +const ( + // CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. + // Detected when the plugin exports at least one of the metadata agent functions. + CapabilityMetadataAgent Capability = "MetadataAgent" + + // Future capabilities: + // CapabilityScrobbler Capability = "Scrobbler" +) + +// capabilityFunctions maps each capability to its required/optional functions. +// A plugin has a capability if it exports at least one of these functions. +var capabilityFunctions = map[Capability][]string{ + CapabilityMetadataAgent: { + FuncGetArtistMBID, + FuncGetArtistURL, + FuncGetArtistBiography, + FuncGetSimilarArtists, + FuncGetArtistImages, + FuncGetArtistTopSongs, + FuncGetAlbumInfo, + FuncGetAlbumImages, + }, +} + +// functionExistsChecker is an interface for checking if a function exists in a plugin. +// This allows for testing without a real plugin instance. +type functionExistsChecker interface { + FunctionExists(name string) bool +} + +// detectCapabilities detects which capabilities a plugin has by checking +// which functions it exports. +func detectCapabilities(plugin functionExistsChecker) []Capability { + var capabilities []Capability + + for cap, functions := range capabilityFunctions { + for _, fn := range functions { + if plugin.FunctionExists(fn) { + capabilities = append(capabilities, cap) + break // Found at least one function, plugin has this capability + } + } + } + + return capabilities +} + +// hasCapability checks if the given capabilities slice contains a specific capability. +func hasCapability(capabilities []Capability, cap Capability) bool { + for _, c := range capabilities { + if c == cap { + return true + } + } + return false +} diff --git a/plugins/capabilities_test.go b/plugins/capabilities_test.go new file mode 100644 index 000000000..9946073e4 --- /dev/null +++ b/plugins/capabilities_test.go @@ -0,0 +1,81 @@ +package plugins + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// mockFunctionChecker implements functionExistsChecker for testing +type mockFunctionChecker struct { + functions map[string]bool +} + +func (m *mockFunctionChecker) FunctionExists(name string) bool { + return m.functions[name] +} + +var _ = Describe("Capabilities", func() { + Describe("detectCapabilities", func() { + It("detects MetadataAgent capability when plugin exports artist biography function", func() { + checker := &mockFunctionChecker{ + functions: map[string]bool{ + FuncGetArtistBiography: true, + }, + } + + caps := detectCapabilities(checker) + Expect(caps).To(ContainElement(CapabilityMetadataAgent)) + }) + + It("detects MetadataAgent capability when plugin exports multiple functions", func() { + checker := &mockFunctionChecker{ + functions: map[string]bool{ + FuncGetArtistMBID: true, + FuncGetArtistURL: true, + FuncGetAlbumInfo: true, + FuncGetAlbumImages: true, + }, + } + + caps := detectCapabilities(checker) + Expect(caps).To(ContainElement(CapabilityMetadataAgent)) + Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability + }) + + It("returns empty slice when no capability functions are exported", func() { + checker := &mockFunctionChecker{ + functions: map[string]bool{ + "some_other_function": true, + }, + } + + caps := detectCapabilities(checker) + Expect(caps).To(BeEmpty()) + }) + + It("returns empty slice when plugin exports no functions", func() { + checker := &mockFunctionChecker{ + functions: map[string]bool{}, + } + + caps := detectCapabilities(checker) + Expect(caps).To(BeEmpty()) + }) + }) + + Describe("hasCapability", func() { + It("returns true when capability exists", func() { + caps := []Capability{CapabilityMetadataAgent} + Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue()) + }) + + It("returns false when capability does not exist", func() { + caps := []Capability{} + Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse()) + }) + + It("returns false when capabilities slice is nil", func() { + Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse()) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index 8545e24c5..9d43cc74d 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -2,6 +2,8 @@ package plugins import ( "context" + "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -38,10 +40,11 @@ type Manager struct { // pluginInstance represents a loaded plugin type pluginInstance struct { - name string // Plugin name (from filename) - path string // Path to the wasm file - manifest *Manifest - compiled *extism.CompiledPlugin + name string // Plugin name (from filename) + path string // Path to the wasm file + manifest *Manifest + compiled *extism.CompiledPlugin + capabilities []Capability // Auto-detected capabilities based on exported functions } // GetManager returns a singleton instance of the plugin manager. @@ -121,6 +124,7 @@ func (m *Manager) Stop() error { // PluginNames returns the names of all plugins that implement a particular capability. // This is used by both agents and scrobbler systems to discover available plugins. +// Capabilities are auto-detected from the plugin's exported functions. func (m *Manager) PluginNames(capability string) []string { m.mu.RLock() defer m.mu.RUnlock() @@ -128,7 +132,7 @@ func (m *Manager) PluginNames(capability string) []string { var names []string cap := Capability(capability) for name, instance := range m.plugins { - if instance.manifest.HasCapability(cap) { + if hasCapability(instance.capabilities, cap) { names = append(names, name) } } @@ -142,7 +146,7 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { instance, ok := m.plugins[name] m.mu.RUnlock() - if !ok || !instance.manifest.HasCapability(CapabilityMetadataAgent) { + if !ok || !hasCapability(instance.capabilities, CapabilityMetadataAgent) { return nil, false } @@ -256,7 +260,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache), } - // Create temporary plugin to read manifest + // Create temporary plugin to read manifest and detect capabilities tempPlugin, err := extism.NewPlugin(m.ctx, tempManifest, tempConfig, nil) if err != nil { return err @@ -272,15 +276,15 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { return err } - // Parse and validate manifest - manifest, err := ParseManifest(manifestBytes) - if err != nil { - return err - } - if err := manifest.Validate(); err != nil { - return err + // Parse manifest (validation happens during unmarshal via generated code) + var manifest Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return fmt.Errorf("invalid plugin manifest: %w", err) } + // Detect capabilities based on exported functions + capabilities := detectCapabilities(tempPlugin) + // Now create the final compiled plugin with proper AllowedHosts finalManifest := extism.Manifest{ Wasm: []extism.Wasm{ @@ -306,10 +310,11 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { m.mu.Lock() m.plugins[name] = &pluginInstance{ - name: name, - path: wasmPath, - manifest: manifest, - compiled: compiled, + name: name, + path: wasmPath, + manifest: &manifest, + compiled: compiled, + capabilities: capabilities, } m.mu.Unlock() diff --git a/plugins/manifest.go b/plugins/manifest.go index d4f080be2..249160197 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -1,182 +1,12 @@ package plugins -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" -) - -// Capability represents a plugin capability type -type Capability string - -const ( - CapabilityMetadataAgent Capability = "MetadataAgent" - // Future capabilities: - // CapabilityScrobbler Capability = "Scrobbler" -) - -// Manifest represents the plugin manifest exported by the nd_manifest function. -// The manifest describes the plugin's metadata, capabilities, and permissions. -type Manifest struct { - Name string `json:"name"` - Author string `json:"author"` - Version string `json:"version"` - Description string `json:"description,omitempty"` - Website string `json:"website,omitempty"` - Capabilities []Capability `json:"capabilities"` - Permissions Permissions `json:"permissions,omitempty"` -} - -// Permissions defines the plugin's required permissions -type Permissions struct { - HTTP *HTTPPermission `json:"http,omitempty"` - Config *ConfigPermission `json:"config,omitempty"` -} - -// HTTPPermission defines HTTP access permissions for a plugin -type HTTPPermission struct { - Reason string `json:"reason,omitempty"` - AllowedURLs map[string][]string `json:"allowedUrls,omitempty"` -} - -// ConfigPermission defines config access permissions for a plugin -type ConfigPermission struct { - Reason string `json:"reason,omitempty"` -} - -// Validate checks if the manifest is valid -func (m *Manifest) Validate() error { - if m.Name == "" { - return errors.New("plugin manifest: name is required") - } - if m.Author == "" { - return errors.New("plugin manifest: author is required") - } - if m.Version == "" { - return errors.New("plugin manifest: version is required") - } - if len(m.Capabilities) == 0 { - return errors.New("plugin manifest: at least one capability is required") - } - - // Validate capabilities - for _, cap := range m.Capabilities { - if !isValidCapability(cap) { - return fmt.Errorf("plugin manifest: unknown capability %q", cap) - } - } - - // Validate HTTP permissions if present - if m.Permissions.HTTP != nil { - if err := m.validateHTTPPermissions(); err != nil { - return err - } - } - - return nil -} - -// HasCapability checks if the plugin has a specific capability -func (m *Manifest) HasCapability(cap Capability) bool { - for _, c := range m.Capabilities { - if c == cap { - return true - } - } - return false -} +//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest.json // AllowedHosts returns a list of allowed hosts for HTTP requests. -// This extracts hostnames from the AllowedURLs patterns. +// Returns the hosts directly from the manifest's permissions. func (m *Manifest) AllowedHosts() []string { - if m.Permissions.HTTP == nil || len(m.Permissions.HTTP.AllowedURLs) == 0 { + if m.Permissions == nil || m.Permissions.Http == nil { return nil } - - hosts := make([]string, 0, len(m.Permissions.HTTP.AllowedURLs)) - for urlPattern := range m.Permissions.HTTP.AllowedURLs { - host := extractHost(urlPattern) - if host != "" { - hosts = append(hosts, host) - } - } - return hosts -} - -// ParseManifest parses JSON data into a Manifest struct -func ParseManifest(data []byte) (*Manifest, error) { - var m Manifest - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err) - } - return &m, nil -} - -// validateHTTPPermissions validates the HTTP permission configuration -func (m *Manifest) validateHTTPPermissions() error { - for urlPattern := range m.Permissions.HTTP.AllowedURLs { - if !isValidURLPattern(urlPattern) { - return fmt.Errorf("plugin manifest: invalid URL pattern %q", urlPattern) - } - } - return nil -} - -// isValidCapability checks if a capability is known -func isValidCapability(cap Capability) bool { - switch cap { - case CapabilityMetadataAgent: - return true - default: - return false - } -} - -// isValidURLPattern checks if a URL pattern is valid. -// Valid patterns are URLs that may contain wildcards (*). -// Examples: -// - https://api.example.com/* -// - https://*.example.com/api/* -// - https://example.com/v1/endpoint -func isValidURLPattern(pattern string) bool { - // Remove wildcards temporarily to validate the base URL - testURL := strings.ReplaceAll(pattern, "*", "wildcard") - - u, err := url.Parse(testURL) - if err != nil { - return false - } - - // Must have a scheme (http or https) - if u.Scheme != "http" && u.Scheme != "https" { - return false - } - - // Must have a host - if u.Host == "" { - return false - } - - return true -} - -// extractHost extracts the hostname from a URL pattern. -// For patterns with wildcards in the host, it returns the pattern as-is for glob matching. -// Examples: -// - https://api.example.com/* -> api.example.com -// - https://*.example.com/api/* -> *.example.com -func extractHost(pattern string) string { - // Remove wildcards temporarily to parse the URL - testURL := strings.ReplaceAll(pattern, "*", "wildcard") - - u, err := url.Parse(testURL) - if err != nil { - return "" - } - - // Restore wildcards in the host - host := strings.ReplaceAll(u.Hostname(), "wildcard", "*") - return host + return m.Permissions.Http.AllowedHosts } diff --git a/plugins/manifest.json b/plugins/manifest.json new file mode 100644 index 000000000..5f903f855 --- /dev/null +++ b/plugins/manifest.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://navidrome.org/schemas/Manifest.json", + "title": "Manifest", + "description": "Plugin manifest for Navidrome plugins", + "type": "object", + "additionalProperties": false, + "required": ["name", "author", "version"], + "properties": { + "name": { + "type": "string", + "description": "The display name of the plugin", + "minLength": 1 + }, + "author": { + "type": "string", + "description": "The author of the plugin", + "minLength": 1 + }, + "version": { + "type": "string", + "description": "The version of the plugin (semver recommended)", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "A brief description of what the plugin does" + }, + "website": { + "type": "string", + "description": "URL to the plugin's website or repository", + "format": "uri" + }, + "permissions": { + "$ref": "#/$defs/Permissions" + } + }, + "$defs": { + "Permissions": { + "type": "object", + "description": "Permissions required by the plugin", + "additionalProperties": false, + "properties": { + "http": { + "$ref": "#/$defs/HTTPPermission" + }, + "config": { + "$ref": "#/$defs/ConfigPermission" + } + } + }, + "HTTPPermission": { + "type": "object", + "description": "HTTP access permissions for a plugin", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why HTTP access is needed" + }, + "allowedHosts": { + "type": "array", + "description": "List of allowed host patterns for HTTP requests (e.g., 'api.example.com', '*.spotify.com')", + "items": { + "type": "string" + } + } + } + }, + "ConfigPermission": { + "type": "object", + "description": "Configuration access permissions for a plugin", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why config access is needed" + } + } + } + } +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go new file mode 100644 index 000000000..398a85148 --- /dev/null +++ b/plugins/manifest_gen.go @@ -0,0 +1,85 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package plugins + +import "encoding/json" +import "fmt" + +// Configuration access permissions for a plugin +type ConfigPermission struct { + // Explanation for why config access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + +// HTTP access permissions for a plugin +type HTTPPermission struct { + // List of allowed host patterns for HTTP requests (e.g., 'api.example.com', + // '*.spotify.com') + AllowedHosts []string `json:"allowedHosts,omitempty" yaml:"allowedHosts,omitempty" mapstructure:"allowedHosts,omitempty"` + + // Explanation for why HTTP access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + +// Plugin manifest for Navidrome plugins +type Manifest struct { + // The author of the plugin + Author string `json:"author" yaml:"author" mapstructure:"author"` + + // A brief description of what the plugin does + Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + + // The display name of the plugin + Name string `json:"name" yaml:"name" mapstructure:"name"` + + // Permissions corresponds to the JSON schema field "permissions". + Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty" mapstructure:"permissions,omitempty"` + + // The version of the plugin (semver recommended) + Version string `json:"version" yaml:"version" mapstructure:"version"` + + // URL to the plugin's website or repository + Website *string `json:"website,omitempty" yaml:"website,omitempty" mapstructure:"website,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *Manifest) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["author"]; raw != nil && !ok { + return fmt.Errorf("field author in Manifest: required") + } + if _, ok := raw["name"]; raw != nil && !ok { + return fmt.Errorf("field name in Manifest: required") + } + if _, ok := raw["version"]; raw != nil && !ok { + return fmt.Errorf("field version in Manifest: required") + } + type Plain Manifest + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Author) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "author", 1) + } + if len(plain.Name) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "name", 1) + } + if len(plain.Version) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "version", 1) + } + *j = Manifest(plain) + return nil +} + +// Permissions required by the plugin +type Permissions struct { + // Config corresponds to the JSON schema field "config". + Config *ConfigPermission `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"` + + // Http corresponds to the JSON schema field "http". + Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` +} diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 9f23644ae..a91759324 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -1,12 +1,14 @@ package plugins import ( + "encoding/json" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Manifest", func() { - Describe("ParseManifest", func() { + Describe("UnmarshalJSON", func() { It("parses a valid manifest", func() { data := []byte(`{ "name": "Test Plugin", @@ -14,217 +16,133 @@ var _ = Describe("Manifest", func() { "version": "1.0.0", "description": "A test plugin", "website": "https://example.com", - "capabilities": ["MetadataAgent"], "permissions": { "http": { "reason": "Fetch metadata", - "allowedUrls": { - "https://api.example.com/*": ["GET"] - } + "allowedHosts": ["api.example.com", "*.spotify.com"] } } }`) - m, err := ParseManifest(data) + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).ToNot(HaveOccurred()) Expect(m.Name).To(Equal("Test Plugin")) Expect(m.Author).To(Equal("Test Author")) Expect(m.Version).To(Equal("1.0.0")) - Expect(m.Description).To(Equal("A test plugin")) - Expect(m.Website).To(Equal("https://example.com")) - Expect(m.Capabilities).To(ContainElement(CapabilityMetadataAgent)) - Expect(m.Permissions.HTTP).ToNot(BeNil()) - Expect(m.Permissions.HTTP.Reason).To(Equal("Fetch metadata")) - Expect(m.Permissions.HTTP.AllowedURLs).To(HaveKey("https://api.example.com/*")) + Expect(*m.Description).To(Equal("A test plugin")) + Expect(*m.Website).To(Equal("https://example.com")) + Expect(m.Permissions.Http).ToNot(BeNil()) + Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata")) + Expect(m.Permissions.Http.AllowedHosts).To(ContainElements("api.example.com", "*.spotify.com")) + }) + + It("parses a minimal manifest", func() { + data := []byte(`{ + "name": "Minimal Plugin", + "author": "Author", + "version": "1.0.0" + }`) + + var m Manifest + err := json.Unmarshal(data, &m) + Expect(err).ToNot(HaveOccurred()) + Expect(m.Name).To(Equal("Minimal Plugin")) + Expect(m.Author).To(Equal("Author")) + Expect(m.Version).To(Equal("1.0.0")) + Expect(m.Description).To(BeNil()) + Expect(m.Permissions).To(BeNil()) }) It("returns an error for invalid JSON", func() { data := []byte(`{invalid json}`) - _, err := ParseManifest(data) + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid JSON")) }) - }) - Describe("Validate", func() { It("returns an error when name is missing", func() { - m := &Manifest{ - Author: "Test Author", - Version: "1.0.0", - Capabilities: []Capability{CapabilityMetadataAgent}, - } + data := []byte(`{"author": "Test Author", "version": "1.0.0"}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("name is required")) + Expect(err.Error()).To(ContainSubstring("name")) }) It("returns an error when author is missing", func() { - m := &Manifest{ - Name: "Test Plugin", - Version: "1.0.0", - Capabilities: []Capability{CapabilityMetadataAgent}, - } + data := []byte(`{"name": "Test Plugin", "version": "1.0.0"}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("author is required")) + Expect(err.Error()).To(ContainSubstring("author")) }) It("returns an error when version is missing", func() { - m := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Capabilities: []Capability{CapabilityMetadataAgent}, - } + data := []byte(`{"name": "Test Plugin", "author": "Test Author"}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("version is required")) + Expect(err.Error()).To(ContainSubstring("version")) }) - It("returns an error when capabilities are missing", func() { - m := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - } + It("returns an error when name is empty", func() { + data := []byte(`{"name": "", "author": "Test Author", "version": "1.0.0"}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("at least one capability is required")) + Expect(err.Error()).To(ContainSubstring("name")) }) - It("returns an error for unknown capability", func() { - m := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - Capabilities: []Capability{"UnknownCapability"}, - } + It("returns an error when author is empty", func() { + data := []byte(`{"name": "Test Plugin", "author": "", "version": "1.0.0"}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unknown capability")) + Expect(err.Error()).To(ContainSubstring("author")) }) - It("returns an error for invalid URL pattern", func() { - m := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - Capabilities: []Capability{CapabilityMetadataAgent}, - Permissions: Permissions{ - HTTP: &HTTPPermission{ - AllowedURLs: map[string][]string{ - "not-a-valid-url": {"GET"}, - }, - }, - }, - } + It("returns an error when version is empty", func() { + data := []byte(`{"name": "Test Plugin", "author": "Test Author", "version": ""}`) - err := m.Validate() + var m Manifest + err := json.Unmarshal(data, &m) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid URL pattern")) - }) - - It("validates a valid manifest", func() { - m := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - Capabilities: []Capability{CapabilityMetadataAgent}, - Permissions: Permissions{ - HTTP: &HTTPPermission{ - AllowedURLs: map[string][]string{ - "https://api.example.com/*": {"GET"}, - }, - }, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("HasCapability", func() { - It("returns true when capability exists", func() { - m := &Manifest{ - Capabilities: []Capability{CapabilityMetadataAgent}, - } - - Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeTrue()) - }) - - It("returns false when capability does not exist", func() { - m := &Manifest{ - Capabilities: []Capability{}, - } - - Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeFalse()) + Expect(err.Error()).To(ContainSubstring("version")) }) }) Describe("AllowedHosts", func() { - It("returns nil when no HTTP permissions", func() { + It("returns nil when no permissions", func() { m := &Manifest{} Expect(m.AllowedHosts()).To(BeNil()) }) - It("returns nil when no allowed URLs", func() { + It("returns nil when no HTTP permissions", func() { m := &Manifest{ - Permissions: Permissions{ - HTTP: &HTTPPermission{}, - }, + Permissions: &Permissions{}, } Expect(m.AllowedHosts()).To(BeNil()) }) - It("extracts hosts from URL patterns", func() { + It("returns hosts from permissions", func() { m := &Manifest{ - Permissions: Permissions{ - HTTP: &HTTPPermission{ - AllowedURLs: map[string][]string{ - "https://api.example.com/*": {"GET"}, - "https://*.spotify.com/api/*": {"GET"}, - }, + Permissions: &Permissions{ + Http: &HTTPPermission{ + AllowedHosts: []string{"api.example.com", "*.spotify.com"}, }, }, } hosts := m.AllowedHosts() - Expect(hosts).To(ContainElements("api.example.com", "*.spotify.com")) + Expect(hosts).To(Equal([]string{"api.example.com", "*.spotify.com"})) }) }) - - Describe("isValidURLPattern", func() { - DescribeTable("validates URL patterns", - func(pattern string, expected bool) { - Expect(isValidURLPattern(pattern)).To(Equal(expected)) - }, - Entry("valid HTTPS URL", "https://api.example.com/path", true), - Entry("valid HTTP URL", "http://api.example.com/path", true), - Entry("URL with wildcard in path", "https://api.example.com/*", true), - Entry("URL with wildcard in host", "https://*.example.com/api/*", true), - Entry("missing scheme", "api.example.com/path", false), - Entry("invalid scheme", "ftp://api.example.com/path", false), - Entry("missing host", "https:///path", false), - ) - }) - - Describe("extractHost", func() { - DescribeTable("extracts hosts from URL patterns", - func(pattern string, expected string) { - Expect(extractHost(pattern)).To(Equal(expected)) - }, - Entry("simple host", "https://api.example.com/path", "api.example.com"), - Entry("host with wildcard", "https://*.example.com/api/*", "*.example.com"), - Entry("host with port", "https://api.example.com:8080/path", "api.example.com"), - Entry("invalid URL", "not-a-url", ""), - ) - }) })