// SPDX-License-Identifier: EUPL-1.2 // Package api provides a service provider that wraps go-scm marketplace, // manifest, and registry functionality as REST endpoints with WebSocket // event streaming. package api import ( "context" "crypto/ed25519" "encoding/hex" "net/http" "net/url" "strings" "dappco.re/go/core/api" "dappco.re/go/core/api/pkg/provider" "dappco.re/go/core/io" "dappco.re/go/core/scm/agentci" "dappco.re/go/core/scm/manifest" "dappco.re/go/core/scm/marketplace" "dappco.re/go/core/scm/repos" "dappco.re/go/core/ws" "github.com/gin-gonic/gin" ) // ScmProvider wraps go-scm marketplace, manifest, and registry operations // as a service provider. It implements Provider, Streamable, Describable, // and Renderable. type ScmProvider struct { index *marketplace.Index installer marketplaceInstaller registry *repos.Registry hub *ws.Hub medium io.Medium } type marketplaceInstaller interface { Install(context.Context, marketplace.Module) error Remove(string) error Update(context.Context, string) error Installed() ([]marketplace.InstalledModule, error) } // compile-time interface checks var ( _ provider.Provider = (*ScmProvider)(nil) _ provider.Streamable = (*ScmProvider)(nil) _ provider.Describable = (*ScmProvider)(nil) _ provider.Renderable = (*ScmProvider)(nil) ) // NewProvider creates an SCM provider backed by the given marketplace index, // installer, and registry. The WS hub is used to emit real-time events. // Pass nil for any dependency that is not available. // Usage: NewProvider(...) func NewProvider(idx *marketplace.Index, inst marketplaceInstaller, reg *repos.Registry, hub *ws.Hub) *ScmProvider { return &ScmProvider{ index: idx, installer: inst, registry: reg, hub: hub, medium: io.Local, } } // Name implements api.RouteGroup. // Usage: Name(...) func (p *ScmProvider) Name() string { return "scm" } // BasePath implements api.RouteGroup. // Usage: BasePath(...) func (p *ScmProvider) BasePath() string { return "/api/v1/scm" } // Element implements provider.Renderable. // Usage: Element(...) func (p *ScmProvider) Element() provider.ElementSpec { return provider.ElementSpec{ Tag: "core-scm-panel", Source: "/assets/core-scm.js", } } // Channels implements provider.Streamable. // Usage: Channels(...) func (p *ScmProvider) Channels() []string { return []string{ "scm.marketplace.refreshed", "scm.marketplace.installed", "scm.marketplace.removed", "scm.installed.changed", "scm.manifest.verified", "scm.registry.changed", } } // RegisterRoutes implements api.RouteGroup. // Usage: RegisterRoutes(...) func (p *ScmProvider) RegisterRoutes(rg *gin.RouterGroup) { // Marketplace rg.GET("/marketplace", p.listMarketplace) rg.GET("/marketplace/:code", p.getMarketplaceItem) rg.POST("/marketplace/:code/install", p.installItem) rg.DELETE("/marketplace/:code", p.removeItem) rg.POST("/marketplace/refresh", p.refreshMarketplace) // Manifest rg.GET("/manifest", p.getManifest) rg.POST("/manifest/verify", p.verifyManifest) rg.POST("/manifest/sign", p.signManifest) rg.GET("/manifest/permissions", p.getPermissions) // Installed rg.GET("/installed", p.listInstalled) rg.POST("/installed/:code/update", p.updateInstalled) // Registry rg.GET("/registry", p.listRegistry) } // Describe implements api.DescribableGroup. // Usage: Describe(...) func (p *ScmProvider) Describe() []api.RouteDescription { return []api.RouteDescription{ { Method: "GET", Path: "/marketplace", Summary: "List available providers", Description: "Returns all providers from the marketplace index, optionally filtered by query and category.", Tags: []string{"scm", "marketplace"}, }, { Method: "GET", Path: "/marketplace/:code", Summary: "Get provider details", Description: "Returns a single provider entry from the marketplace by its code.", Tags: []string{"scm", "marketplace"}, }, { Method: "POST", Path: "/marketplace/:code/install", Summary: "Install a provider", Description: "Clones the provider repository, verifies its manifest signature, and registers it.", Tags: []string{"scm", "marketplace"}, }, { Method: "DELETE", Path: "/marketplace/:code", Summary: "Remove an installed provider", Description: "Uninstalls a provider by removing its files and store entry.", Tags: []string{"scm", "marketplace"}, }, { Method: "POST", Path: "/marketplace/refresh", Summary: "Refresh marketplace index", Description: "Reloads an index.json file and replaces the in-memory marketplace catalogue.", Tags: []string{"scm", "marketplace"}, RequestBody: map[string]any{ "type": "object", "properties": map[string]any{ "index_path": map[string]any{"type": "string", "description": "Path to an index.json file", "default": "index.json"}, }, }, }, { Method: "GET", Path: "/manifest", Summary: "Read manifest", Description: "Reads and parses the .core/manifest.yaml from the current directory.", Tags: []string{"scm", "manifest"}, }, { Method: "POST", Path: "/manifest/verify", Summary: "Verify manifest signature", Description: "Verifies the Ed25519 signature of the manifest using the provided public key.", Tags: []string{"scm", "manifest"}, RequestBody: map[string]any{ "type": "object", "properties": map[string]any{ "public_key": map[string]any{"type": "string", "description": "Hex-encoded Ed25519 public key"}, }, }, }, { Method: "POST", Path: "/manifest/sign", Summary: "Sign manifest", Description: "Signs the manifest with the provided Ed25519 private key.", Tags: []string{"scm", "manifest"}, RequestBody: map[string]any{ "type": "object", "properties": map[string]any{ "private_key": map[string]any{"type": "string", "description": "Hex-encoded Ed25519 private key"}, }, }, }, { Method: "GET", Path: "/manifest/permissions", Summary: "List manifest permissions", Description: "Returns the declared permissions from the current manifest.", Tags: []string{"scm", "manifest"}, }, { Method: "GET", Path: "/installed", Summary: "List installed providers", Description: "Returns all installed provider metadata.", Tags: []string{"scm", "installed"}, }, { Method: "POST", Path: "/installed/:code/update", Summary: "Update an installed provider", Description: "Pulls latest changes from git and re-verifies the manifest.", Tags: []string{"scm", "installed"}, }, { Method: "GET", Path: "/registry", Summary: "List registry repos", Description: "Returns all repositories from the repos.yaml registry.", Tags: []string{"scm", "registry"}, }, } } // -- Marketplace Handlers ----------------------------------------------------- func (p *ScmProvider) listMarketplace(c *gin.Context) { if p.index == nil { c.JSON(http.StatusOK, api.OK([]marketplace.Module{})) return } query := c.Query("q") category := c.Query("category") modules := p.index.Modules if category != "" { modules = p.index.ByCategory(category) } if query != "" { filtered := make([]marketplace.Module, 0, len(modules)) for _, mod := range modules { if moduleMatchesQuery(mod, query) { filtered = append(filtered, mod) } } modules = filtered } if modules == nil { modules = []marketplace.Module{} } c.JSON(http.StatusOK, api.OK(modules)) } func (p *ScmProvider) getMarketplaceItem(c *gin.Context) { if p.index == nil { c.JSON(http.StatusNotFound, api.Fail("not_found", "marketplace index not loaded")) return } code, ok := marketplaceCodeParam(c) if !ok { return } mod, ok := p.index.Find(code) if !ok { c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) return } c.JSON(http.StatusOK, api.OK(mod)) } func (p *ScmProvider) installItem(c *gin.Context) { if p.index == nil || p.installer == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "marketplace not configured")) return } code, ok := marketplaceCodeParam(c) if !ok { return } mod, ok := p.index.Find(code) if !ok { c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) return } if err := p.installer.Install(context.Background(), mod); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("install_failed", err.Error())) return } p.emitEvent("scm.marketplace.installed", map[string]any{ "code": mod.Code, "name": mod.Name, }) p.emitEvent("scm.installed.changed", map[string]any{ "action": "installed", "code": mod.Code, "name": mod.Name, }) c.JSON(http.StatusOK, api.OK(map[string]any{"installed": true, "code": mod.Code})) } func (p *ScmProvider) removeItem(c *gin.Context) { if p.installer == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "installer not configured")) return } code, ok := marketplaceCodeParam(c) if !ok { return } if err := p.installer.Remove(code); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("remove_failed", err.Error())) return } p.emitEvent("scm.marketplace.removed", map[string]any{"code": code}) p.emitEvent("scm.installed.changed", map[string]any{ "action": "removed", "code": code, }) c.JSON(http.StatusOK, api.OK(map[string]any{"removed": true, "code": code})) } type refreshRequest struct { IndexPath string `json:"index_path"` } func (p *ScmProvider) refreshMarketplace(c *gin.Context) { var req refreshRequest if c.Request.ContentLength != 0 { if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "index_path is invalid")) return } } if req.IndexPath == "" { req.IndexPath = "index.json" } idx, err := marketplace.LoadIndex(p.medium, req.IndexPath) if err != nil { c.JSON(http.StatusNotFound, api.Fail("index_not_found", err.Error())) return } p.index = idx p.emitEvent("scm.marketplace.refreshed", map[string]any{ "index_path": req.IndexPath, "modules": len(idx.Modules), }) c.JSON(http.StatusOK, api.OK(map[string]any{ "refreshed": true, "index_path": req.IndexPath, "modules": len(idx.Modules), })) } // -- Manifest Handlers -------------------------------------------------------- func (p *ScmProvider) getManifest(c *gin.Context) { m, err := manifest.Load(p.medium, ".") if err != nil { c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) return } c.JSON(http.StatusOK, api.OK(m)) } type verifyRequest struct { PublicKey string `json:"public_key" binding:"required"` } func (p *ScmProvider) verifyManifest(c *gin.Context) { var req verifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "public_key is required")) return } m, err := manifest.Load(p.medium, ".") if err != nil { c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) return } pubBytes, err := hex.DecodeString(req.PublicKey) if err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_key", "public key must be hex-encoded")) return } valid, err := manifest.Verify(m, ed25519.PublicKey(pubBytes)) if err != nil { c.JSON(http.StatusUnprocessableEntity, api.Fail("verify_failed", err.Error())) return } p.emitEvent("scm.manifest.verified", map[string]any{ "code": m.Code, "valid": valid, }) c.JSON(http.StatusOK, api.OK(map[string]any{"valid": valid, "code": m.Code})) } type signRequest struct { PrivateKey string `json:"private_key" binding:"required"` } func (p *ScmProvider) signManifest(c *gin.Context) { var req signRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "private_key is required")) return } m, err := manifest.Load(p.medium, ".") if err != nil { c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) return } privBytes, err := hex.DecodeString(req.PrivateKey) if err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_key", "private key must be hex-encoded")) return } if err := manifest.Sign(m, ed25519.PrivateKey(privBytes)); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("sign_failed", err.Error())) return } c.JSON(http.StatusOK, api.OK(map[string]any{"signed": true, "code": m.Code, "signature": m.Sign})) } func (p *ScmProvider) getPermissions(c *gin.Context) { m, err := manifest.Load(p.medium, ".") if err != nil { c.JSON(http.StatusNotFound, api.Fail("manifest_not_found", err.Error())) return } c.JSON(http.StatusOK, api.OK(m.Permissions)) } // -- Installed Handlers ------------------------------------------------------- func (p *ScmProvider) listInstalled(c *gin.Context) { if p.installer == nil { c.JSON(http.StatusOK, api.OK([]marketplace.InstalledModule{})) return } installed, err := p.installer.Installed() if err != nil { c.JSON(http.StatusInternalServerError, api.Fail("list_failed", err.Error())) return } if installed == nil { installed = []marketplace.InstalledModule{} } c.JSON(http.StatusOK, api.OK(installed)) } func (p *ScmProvider) updateInstalled(c *gin.Context) { if p.installer == nil { c.JSON(http.StatusServiceUnavailable, api.Fail("unavailable", "installer not configured")) return } code, ok := marketplaceCodeParam(c) if !ok { return } if err := p.installer.Update(context.Background(), code); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("update_failed", err.Error())) return } p.emitEvent("scm.installed.changed", map[string]any{ "action": "updated", "code": code, }) c.JSON(http.StatusOK, api.OK(map[string]any{"updated": true, "code": code})) } // -- Registry Handlers -------------------------------------------------------- // repoSummary is a JSON-friendly representation of a registry repo. type repoSummary struct { Name string `json:"name"` Type string `json:"type"` Description string `json:"description,omitempty"` DependsOn []string `json:"depends_on,omitempty"` Path string `json:"path,omitempty"` Exists bool `json:"exists"` } func (p *ScmProvider) listRegistry(c *gin.Context) { if p.registry == nil { c.JSON(http.StatusOK, api.OK([]repoSummary{})) return } repoList, err := p.registry.TopologicalOrder() if err != nil { // Keep the endpoint usable if the registry is malformed. repoList = p.registry.List() } summaries := make([]repoSummary, 0, len(repoList)) for _, r := range repoList { summaries = append(summaries, repoSummary{ Name: r.Name, Type: r.Type, Description: r.Description, DependsOn: r.DependsOn, Path: r.Path, Exists: r.Exists(), }) } c.JSON(http.StatusOK, api.OK(summaries)) } // -- Internal Helpers --------------------------------------------------------- // emitEvent sends a WS event if the hub is available. func (p *ScmProvider) emitEvent(channel string, data any) { if p.hub == nil { return } _ = p.hub.SendToChannel(channel, ws.Message{ Type: ws.TypeEvent, Data: data, }) } func marketplaceCodeParam(c *gin.Context) (string, bool) { code, err := normaliseMarketplaceCode(c.Param("code")) if err != nil { c.JSON(http.StatusBadRequest, api.Fail("invalid_code", "invalid marketplace code")) return "", false } return code, true } func normaliseMarketplaceCode(raw string) (string, error) { decoded, err := url.PathUnescape(raw) if err != nil { return "", err } return agentci.ValidatePathElement(decoded) } func moduleMatchesQuery(mod marketplace.Module, query string) bool { q := strings.ToLower(query) return strings.Contains(strings.ToLower(mod.Code), q) || strings.Contains(strings.ToLower(mod.Name), q) || strings.Contains(strings.ToLower(mod.Category), q) }