go-scm/pkg/api/provider.go
Snider 81deee8598
Some checks failed
Security Scan / security (push) Failing after 7s
Test / test (push) Failing after 1m16s
feat(api): add SCM service provider with Lit custom elements
ScmProvider implements Provider + Streamable + Describable + Renderable,
wrapping marketplace, manifest, installed, and registry endpoints as REST
API with WS event streaming. Includes Lit custom element bundle with
panel, marketplace browser, manifest viewer, installed manager, and
registry status display. All 14 tests pass.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-14 10:42:37 +00:00

450 lines
13 KiB
Go

// SPDX-Licence-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"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/manifest"
"forge.lthn.ai/core/go-scm/marketplace"
"forge.lthn.ai/core/go-scm/repos"
"forge.lthn.ai/core/go-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 *marketplace.Installer
registry *repos.Registry
hub *ws.Hub
medium io.Medium
}
// 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.
func NewProvider(idx *marketplace.Index, inst *marketplace.Installer, reg *repos.Registry, hub *ws.Hub) *ScmProvider {
return &ScmProvider{
index: idx,
installer: inst,
registry: reg,
hub: hub,
medium: io.Local,
}
}
// Name implements api.RouteGroup.
func (p *ScmProvider) Name() string { return "scm" }
// BasePath implements api.RouteGroup.
func (p *ScmProvider) BasePath() string { return "/api/v1/scm" }
// Element implements provider.Renderable.
func (p *ScmProvider) Element() provider.ElementSpec {
return provider.ElementSpec{
Tag: "core-scm-panel",
Source: "/assets/core-scm.js",
}
}
// Channels implements provider.Streamable.
func (p *ScmProvider) Channels() []string {
return []string{
"scm.marketplace.refreshed",
"scm.marketplace.installed",
"scm.marketplace.removed",
"scm.manifest.verified",
"scm.registry.changed",
}
}
// RegisterRoutes implements api.RouteGroup.
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)
// 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.
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 or 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: "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")
var modules []marketplace.Module
switch {
case query != "":
modules = p.index.Search(query)
case category != "":
modules = p.index.ByCategory(category)
default:
modules = p.index.Modules
}
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 := c.Param("code")
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 := c.Param("code")
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,
})
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 := c.Param("code")
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})
c.JSON(http.StatusOK, api.OK(map[string]any{"removed": true, "code": code}))
}
// -- 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 := c.Param("code")
if err := p.installer.Update(context.Background(), code); err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("update_failed", err.Error()))
return
}
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 := 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,
})
}