diff --git a/go.mod b/go.mod
index 5b34ddf..8259630 100644
--- a/go.mod
+++ b/go.mod
@@ -5,11 +5,14 @@ go 1.26.0
require (
code.gitea.io/sdk/gitea v0.23.2
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
+ forge.lthn.ai/core/api v0.1.0
forge.lthn.ai/core/cli v0.1.0
forge.lthn.ai/core/config v0.1.0
forge.lthn.ai/core/go-i18n v0.1.0
forge.lthn.ai/core/go-io v0.0.3
forge.lthn.ai/core/go-log v0.0.1
+ forge.lthn.ai/core/go-ws v0.1.0
+ github.com/gin-gonic/gin v1.11.0
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.50.0
gopkg.in/yaml.v3 v3.0.1
diff --git a/pkg/api/embed.go b/pkg/api/embed.go
new file mode 100644
index 0000000..981cfb7
--- /dev/null
+++ b/pkg/api/embed.go
@@ -0,0 +1,11 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package api
+
+import "embed"
+
+// Assets holds the built UI bundle (core-scm.js and related files).
+// The directory is populated by running `npm run build` in the ui/ directory.
+//
+//go:embed all:ui/dist
+var Assets embed.FS
diff --git a/pkg/api/provider.go b/pkg/api/provider.go
new file mode 100644
index 0000000..11d8b52
--- /dev/null
+++ b/pkg/api/provider.go
@@ -0,0 +1,450 @@
+// 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,
+ })
+}
diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go
new file mode 100644
index 0000000..643cdce
--- /dev/null
+++ b/pkg/api/provider_test.go
@@ -0,0 +1,237 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ goapi "forge.lthn.ai/core/api"
+ "forge.lthn.ai/core/go-scm/marketplace"
+ scmapi "forge.lthn.ai/core/go-scm/pkg/api"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+// -- Provider Identity --------------------------------------------------------
+
+func TestScmProvider_Name_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+ assert.Equal(t, "scm", p.Name())
+}
+
+func TestScmProvider_BasePath_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+ assert.Equal(t, "/api/v1/scm", p.BasePath())
+}
+
+func TestScmProvider_Channels_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+ channels := p.Channels()
+ assert.Contains(t, channels, "scm.marketplace.refreshed")
+ assert.Contains(t, channels, "scm.marketplace.installed")
+ assert.Contains(t, channels, "scm.marketplace.removed")
+ assert.Contains(t, channels, "scm.manifest.verified")
+ assert.Contains(t, channels, "scm.registry.changed")
+}
+
+func TestScmProvider_Element_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+ el := p.Element()
+ assert.Equal(t, "core-scm-panel", el.Tag)
+ assert.Equal(t, "/assets/core-scm.js", el.Source)
+}
+
+func TestScmProvider_Describe_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+ descs := p.Describe()
+ assert.GreaterOrEqual(t, len(descs), 11)
+
+ for _, d := range descs {
+ assert.NotEmpty(t, d.Method)
+ assert.NotEmpty(t, d.Path)
+ assert.NotEmpty(t, d.Summary)
+ assert.NotEmpty(t, d.Tags)
+ }
+}
+
+// -- Marketplace Endpoints ----------------------------------------------------
+
+func TestScmProvider_ListMarketplace_Good(t *testing.T) {
+ idx := &marketplace.Index{
+ Version: 1,
+ Modules: []marketplace.Module{
+ {Code: "analytics", Name: "Analytics", Category: "product"},
+ {Code: "bio", Name: "Bio Links", Category: "product"},
+ },
+ Categories: []string{"product"},
+ }
+ p := scmapi.NewProvider(idx, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[[]marketplace.Module]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ assert.Len(t, resp.Data, 2)
+}
+
+func TestScmProvider_ListMarketplace_Search_Good(t *testing.T) {
+ idx := &marketplace.Index{
+ Version: 1,
+ Modules: []marketplace.Module{
+ {Code: "analytics", Name: "Analytics", Category: "product"},
+ {Code: "bio", Name: "Bio Links", Category: "product"},
+ },
+ }
+ p := scmapi.NewProvider(idx, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace?q=bio", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[[]marketplace.Module]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.Len(t, resp.Data, 1)
+ assert.Equal(t, "bio", resp.Data[0].Code)
+}
+
+func TestScmProvider_ListMarketplace_NilIndex_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[[]marketplace.Module]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ assert.Empty(t, resp.Data)
+}
+
+func TestScmProvider_GetMarketplaceItem_Good(t *testing.T) {
+ idx := &marketplace.Index{
+ Version: 1,
+ Modules: []marketplace.Module{
+ {Code: "analytics", Name: "Analytics", Category: "product"},
+ },
+ }
+ p := scmapi.NewProvider(idx, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/analytics", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[marketplace.Module]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.Equal(t, "analytics", resp.Data.Code)
+}
+
+func TestScmProvider_GetMarketplaceItem_Bad(t *testing.T) {
+ idx := &marketplace.Index{Version: 1}
+ p := scmapi.NewProvider(idx, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/nonexistent", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+// -- Installed Endpoints ------------------------------------------------------
+
+func TestScmProvider_ListInstalled_NilInstaller_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/installed", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[[]marketplace.InstalledModule]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ assert.Empty(t, resp.Data)
+}
+
+// -- Registry Endpoints -------------------------------------------------------
+
+func TestScmProvider_ListRegistry_NilRegistry_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/registry", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp goapi.Response[[]any]
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ assert.Empty(t, resp.Data)
+}
+
+// -- Route Registration -------------------------------------------------------
+
+func TestScmProvider_RegistersAsRouteGroup_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+
+ engine, err := goapi.New()
+ require.NoError(t, err)
+
+ engine.Register(p)
+ assert.Len(t, engine.Groups(), 1)
+ assert.Equal(t, "scm", engine.Groups()[0].Name())
+}
+
+func TestScmProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) {
+ p := scmapi.NewProvider(nil, nil, nil, nil)
+
+ engine, err := goapi.New()
+ require.NoError(t, err)
+
+ engine.Register(p)
+
+ channels := engine.Channels()
+ assert.Contains(t, channels, "scm.marketplace.refreshed")
+}
+
+// -- Test helpers -------------------------------------------------------------
+
+func setupRouter(p *scmapi.ScmProvider) *gin.Engine {
+ r := gin.New()
+ rg := r.Group(p.BasePath())
+ p.RegisterRoutes(rg)
+ return r
+}
diff --git a/pkg/api/ui/dist/.gitkeep b/pkg/api/ui/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..cf687ba
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ Core SCM — Demo
+
+
+
+
+ Core SCM — Custom Element Demo
+
+
+
+
+
+ Standalone Elements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000..7ad9e18
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@core/scm-ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "lit": "^3.2.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/ui/src/index.ts b/ui/src/index.ts
new file mode 100644
index 0000000..cfe43bc
--- /dev/null
+++ b/ui/src/index.ts
@@ -0,0 +1,11 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+// Bundle entry — exports all SCM custom elements.
+
+export { ScmPanel } from './scm-panel.js';
+export { ScmMarketplace } from './scm-marketplace.js';
+export { ScmManifest } from './scm-manifest.js';
+export { ScmInstalled } from './scm-installed.js';
+export { ScmRegistry } from './scm-registry.js';
+export { ScmApi } from './shared/api.js';
+export { connectScmEvents } from './shared/events.js';
diff --git a/ui/src/scm-installed.ts b/ui/src/scm-installed.ts
new file mode 100644
index 0000000..165c67a
--- /dev/null
+++ b/ui/src/scm-installed.ts
@@ -0,0 +1,250 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { ScmApi } from './shared/api.js';
+
+interface InstalledModule {
+ code: string;
+ name: string;
+ version: string;
+ repo: string;
+ entry_point: string;
+ permissions: {
+ read?: string[];
+ write?: string[];
+ net?: string[];
+ run?: string[];
+ };
+ sign_key?: string;
+ installed_at: string;
+}
+
+/**
+ * — Manage installed providers.
+ * Displays installed provider list with update/remove actions.
+ */
+@customElement('core-scm-installed')
+export class ScmInstalled extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ font-family: system-ui, -apple-system, sans-serif;
+ }
+
+ .list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .item {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ background: #fff;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: box-shadow 0.15s;
+ }
+
+ .item:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ }
+
+ .item-info {
+ flex: 1;
+ }
+
+ .item-name {
+ font-weight: 600;
+ font-size: 0.9375rem;
+ }
+
+ .item-meta {
+ font-size: 0.75rem;
+ colour: #6b7280;
+ margin-top: 0.25rem;
+ display: flex;
+ gap: 1rem;
+ }
+
+ .item-code {
+ font-family: monospace;
+ }
+
+ .item-actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ button {
+ padding: 0.375rem 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+
+ button.update {
+ background: #fff;
+ colour: #6366f1;
+ border: 1px solid #6366f1;
+ }
+
+ button.update:hover {
+ background: #eef2ff;
+ }
+
+ button.update:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ button.remove {
+ background: #fff;
+ colour: #dc2626;
+ border: 1px solid #dc2626;
+ }
+
+ button.remove:hover {
+ background: #fef2f2;
+ }
+
+ .empty {
+ text-align: center;
+ padding: 2rem;
+ colour: #9ca3af;
+ font-size: 0.875rem;
+ }
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ colour: #6b7280;
+ }
+
+ .error {
+ colour: #dc2626;
+ padding: 0.75rem;
+ background: #fef2f2;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ }
+ `;
+
+ @property({ attribute: 'api-url' }) apiUrl = '';
+
+ @state() private modules: InstalledModule[] = [];
+ @state() private loading = true;
+ @state() private error = '';
+ @state() private updating = new Set();
+
+ private api!: ScmApi;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.api = new ScmApi(this.apiUrl);
+ this.loadInstalled();
+ }
+
+ async loadInstalled() {
+ this.loading = true;
+ this.error = '';
+ try {
+ this.modules = await this.api.installed();
+ } catch (e: any) {
+ this.error = e.message ?? 'Failed to load installed providers';
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private async handleUpdate(code: string) {
+ this.updating = new Set([...this.updating, code]);
+ try {
+ await this.api.updateInstalled(code);
+ await this.loadInstalled();
+ } catch (e: any) {
+ this.error = e.message ?? 'Update failed';
+ } finally {
+ const next = new Set(this.updating);
+ next.delete(code);
+ this.updating = next;
+ }
+ }
+
+ private async handleRemove(code: string) {
+ try {
+ await this.api.remove(code);
+ this.dispatchEvent(
+ new CustomEvent('scm-removed', { detail: { code }, bubbles: true }),
+ );
+ await this.loadInstalled();
+ } catch (e: any) {
+ this.error = e.message ?? 'Removal failed';
+ }
+ }
+
+ private formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+ } catch {
+ return iso;
+ }
+ }
+
+ render() {
+ if (this.loading) {
+ return html`Loading installed providers\u2026
`;
+ }
+
+ return html`
+ ${this.error ? html`${this.error}
` : nothing}
+ ${this.modules.length === 0
+ ? html`No providers installed.
`
+ : html`
+
+ ${this.modules.map(
+ (mod) => html`
+
+
+
${mod.name}
+
+ ${mod.code}
+ v${mod.version}
+ Installed ${this.formatDate(mod.installed_at)}
+
+
+
+
+
+
+
+ `,
+ )}
+
+ `}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'core-scm-installed': ScmInstalled;
+ }
+}
diff --git a/ui/src/scm-manifest.ts b/ui/src/scm-manifest.ts
new file mode 100644
index 0000000..ab4c0f0
--- /dev/null
+++ b/ui/src/scm-manifest.ts
@@ -0,0 +1,411 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { ScmApi } from './shared/api.js';
+
+interface ManifestData {
+ code: string;
+ name: string;
+ description?: string;
+ version: string;
+ sign?: string;
+ layout?: string;
+ slots?: Record;
+ permissions?: {
+ read?: string[];
+ write?: string[];
+ net?: string[];
+ run?: string[];
+ };
+ modules?: string[];
+}
+
+/**
+ * — View and verify a .core/manifest.yaml file.
+ */
+@customElement('core-scm-manifest')
+export class ScmManifest extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ font-family: system-ui, -apple-system, sans-serif;
+ }
+
+ .manifest {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 1.25rem;
+ background: #fff;
+ }
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+ }
+
+ h3 {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ }
+
+ .version {
+ font-size: 0.75rem;
+ padding: 0.125rem 0.5rem;
+ background: #f3f4f6;
+ border-radius: 1rem;
+ colour: #6b7280;
+ }
+
+ .field {
+ margin-bottom: 0.75rem;
+ }
+
+ .field-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ colour: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ margin-bottom: 0.25rem;
+ }
+
+ .field-value {
+ font-size: 0.875rem;
+ }
+
+ .code {
+ font-family: monospace;
+ font-size: 0.8125rem;
+ background: #f9fafb;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ }
+
+ .slots {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.25rem 1rem;
+ font-size: 0.8125rem;
+ }
+
+ .slot-key {
+ font-weight: 600;
+ colour: #374151;
+ }
+
+ .slot-value {
+ font-family: monospace;
+ colour: #6b7280;
+ }
+
+ .permissions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.5rem;
+ }
+
+ .perm-group {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ padding: 0.5rem;
+ }
+
+ .perm-group-label {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ colour: #6b7280;
+ text-transform: uppercase;
+ margin-bottom: 0.25rem;
+ }
+
+ .perm-item {
+ font-size: 0.8125rem;
+ font-family: monospace;
+ colour: #374151;
+ }
+
+ .signature {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 1rem;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ }
+
+ .signature.signed {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ }
+
+ .signature.unsigned {
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ }
+
+ .signature.invalid {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ }
+
+ .badge {
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+ }
+
+ .badge.verified {
+ background: #dcfce7;
+ colour: #166534;
+ }
+
+ .badge.unsigned {
+ background: #fef3c7;
+ colour: #92400e;
+ }
+
+ .badge.invalid {
+ background: #fee2e2;
+ colour: #991b1b;
+ }
+
+ .actions {
+ margin-top: 1rem;
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .verify-input {
+ flex: 1;
+ padding: 0.375rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ font-family: monospace;
+ }
+
+ button {
+ padding: 0.375rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ border: 1px solid #d1d5db;
+ background: #fff;
+ transition: background 0.15s;
+ }
+
+ button:hover {
+ background: #f3f4f6;
+ }
+
+ button.primary {
+ background: #6366f1;
+ colour: #fff;
+ border-colour: #6366f1;
+ }
+
+ button.primary:hover {
+ background: #4f46e5;
+ }
+
+ .empty {
+ text-align: center;
+ padding: 2rem;
+ colour: #9ca3af;
+ font-size: 0.875rem;
+ }
+
+ .error {
+ colour: #dc2626;
+ padding: 0.75rem;
+ background: #fef2f2;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ }
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ colour: #6b7280;
+ }
+ `;
+
+ @property({ attribute: 'api-url' }) apiUrl = '';
+ @property() path = '';
+
+ @state() private manifest: ManifestData | null = null;
+ @state() private loading = true;
+ @state() private error = '';
+ @state() private verifyKey = '';
+ @state() private verifyResult: { valid: boolean } | null = null;
+
+ private api!: ScmApi;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.api = new ScmApi(this.apiUrl);
+ this.loadManifest();
+ }
+
+ async loadManifest() {
+ this.loading = true;
+ this.error = '';
+ try {
+ this.manifest = await this.api.manifest();
+ } catch (e: any) {
+ this.error = e.message ?? 'Failed to load manifest';
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private async handleVerify() {
+ if (!this.verifyKey.trim()) return;
+ try {
+ this.verifyResult = await this.api.verify(this.verifyKey.trim());
+ } catch (e: any) {
+ this.error = e.message ?? 'Verification failed';
+ }
+ }
+
+ private async handleSign() {
+ const key = prompt('Enter hex-encoded Ed25519 private key:');
+ if (!key) return;
+ try {
+ await this.api.sign(key);
+ await this.loadManifest();
+ } catch (e: any) {
+ this.error = e.message ?? 'Signing failed';
+ }
+ }
+
+ private renderPermissions(perms: ManifestData['permissions']) {
+ if (!perms) return nothing;
+
+ const groups = [
+ { label: 'Read', items: perms.read },
+ { label: 'Write', items: perms.write },
+ { label: 'Network', items: perms.net },
+ { label: 'Run', items: perms.run },
+ ].filter((g) => g.items && g.items.length > 0);
+
+ if (groups.length === 0) return nothing;
+
+ return html`
+
+
Permissions
+
+ ${groups.map(
+ (g) => html`
+
+
${g.label}
+ ${g.items!.map((item) => html`
${item}
`)}
+
+ `,
+ )}
+
+
+ `;
+ }
+
+ render() {
+ if (this.loading) {
+ return html`Loading manifest\u2026
`;
+ }
+ if (this.error && !this.manifest) {
+ return html`${this.error}
`;
+ }
+ if (!this.manifest) {
+ return html`No manifest found. Create a .core/manifest.yaml to get started.
`;
+ }
+
+ const m = this.manifest;
+ const hasSig = !!m.sign;
+
+ return html`
+ ${this.error ? html`${this.error}
` : nothing}
+
+
+
+ ${m.description
+ ? html`
+
+
Description
+
${m.description}
+
+ `
+ : nothing}
+ ${m.layout
+ ? html`
+
+ `
+ : nothing}
+ ${m.slots && Object.keys(m.slots).length > 0
+ ? html`
+
+
Slots
+
+ ${Object.entries(m.slots).map(
+ ([k, v]) => html`
+ ${k}
+ ${v}
+ `,
+ )}
+
+
+ `
+ : nothing}
+
+ ${this.renderPermissions(m.permissions)}
+ ${m.modules && m.modules.length > 0
+ ? html`
+
+
Modules
+ ${m.modules.map((mod) => html`
${mod}
`)}
+
+ `
+ : nothing}
+
+
+
+ ${hasSig ? (this.verifyResult ? (this.verifyResult.valid ? 'Verified' : 'Invalid') : 'Signed') : 'Unsigned'}
+
+ ${hasSig ? html`Signature present` : html`No signature`}
+
+
+
+ (this.verifyKey = (e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'core-scm-manifest': ScmManifest;
+ }
+}
diff --git a/ui/src/scm-marketplace.ts b/ui/src/scm-marketplace.ts
new file mode 100644
index 0000000..5270d21
--- /dev/null
+++ b/ui/src/scm-marketplace.ts
@@ -0,0 +1,331 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { ScmApi } from './shared/api.js';
+
+interface Module {
+ code: string;
+ name: string;
+ repo: string;
+ sign_key: string;
+ category: string;
+}
+
+/**
+ * — Browse, search, and install providers
+ * from the SCM marketplace.
+ */
+@customElement('core-scm-marketplace')
+export class ScmMarketplace extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ font-family: system-ui, -apple-system, sans-serif;
+ }
+
+ .toolbar {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 1rem;
+ }
+
+ .search {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ outline: none;
+ }
+
+ .search:focus {
+ border-colour: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+ }
+
+ .categories {
+ display: flex;
+ gap: 0.25rem;
+ flex-wrap: wrap;
+ }
+
+ .category-btn {
+ padding: 0.25rem 0.75rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 1rem;
+ background: #fff;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.15s;
+ }
+
+ .category-btn:hover {
+ background: #f3f4f6;
+ }
+
+ .category-btn.active {
+ background: #6366f1;
+ colour: #fff;
+ border-colour: #6366f1;
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+ margin-top: 1rem;
+ }
+
+ .card {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ background: #fff;
+ transition: box-shadow 0.15s;
+ }
+
+ .card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ }
+
+ .card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 0.5rem;
+ }
+
+ .card-name {
+ font-weight: 600;
+ font-size: 0.9375rem;
+ }
+
+ .card-code {
+ font-size: 0.75rem;
+ colour: #6b7280;
+ font-family: monospace;
+ }
+
+ .card-category {
+ font-size: 0.6875rem;
+ padding: 0.125rem 0.5rem;
+ background: #f3f4f6;
+ border-radius: 1rem;
+ colour: #6b7280;
+ }
+
+ .card-actions {
+ margin-top: 0.75rem;
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ button.install {
+ padding: 0.375rem 1rem;
+ background: #6366f1;
+ colour: #fff;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+
+ button.install:hover {
+ background: #4f46e5;
+ }
+
+ button.install:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ button.remove {
+ padding: 0.375rem 1rem;
+ background: #fff;
+ colour: #dc2626;
+ border: 1px solid #dc2626;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ }
+
+ button.remove:hover {
+ background: #fef2f2;
+ }
+
+ .empty {
+ text-align: center;
+ padding: 2rem;
+ colour: #9ca3af;
+ font-size: 0.875rem;
+ }
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ colour: #6b7280;
+ }
+
+ .error {
+ colour: #dc2626;
+ padding: 0.75rem;
+ background: #fef2f2;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ }
+ `;
+
+ @property({ attribute: 'api-url' }) apiUrl = '';
+ @property() category = '';
+
+ @state() private modules: Module[] = [];
+ @state() private categories: string[] = [];
+ @state() private searchQuery = '';
+ @state() private activeCategory = '';
+ @state() private loading = true;
+ @state() private error = '';
+ @state() private installing = new Set();
+
+ private api!: ScmApi;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.api = new ScmApi(this.apiUrl);
+ this.activeCategory = this.category;
+ this.loadModules();
+ }
+
+ async loadModules() {
+ this.loading = true;
+ this.error = '';
+ try {
+ this.modules = await this.api.marketplace(
+ this.searchQuery || undefined,
+ this.activeCategory || undefined,
+ );
+ // Extract unique categories
+ const cats = new Set();
+ this.modules.forEach((m) => {
+ if (m.category) cats.add(m.category);
+ });
+ this.categories = Array.from(cats).sort();
+ } catch (e: any) {
+ this.error = e.message ?? 'Failed to load marketplace';
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private handleSearch(e: Event) {
+ this.searchQuery = (e.target as HTMLInputElement).value;
+ this.loadModules();
+ }
+
+ private handleCategoryClick(cat: string) {
+ this.activeCategory = this.activeCategory === cat ? '' : cat;
+ this.loadModules();
+ }
+
+ private async handleInstall(code: string) {
+ this.installing = new Set([...this.installing, code]);
+ try {
+ await this.api.install(code);
+ this.dispatchEvent(
+ new CustomEvent('scm-installed', { detail: { code }, bubbles: true }),
+ );
+ } catch (e: any) {
+ this.error = e.message ?? 'Installation failed';
+ } finally {
+ const next = new Set(this.installing);
+ next.delete(code);
+ this.installing = next;
+ }
+ }
+
+ private async handleRemove(code: string) {
+ try {
+ await this.api.remove(code);
+ this.dispatchEvent(
+ new CustomEvent('scm-removed', { detail: { code }, bubbles: true }),
+ );
+ } catch (e: any) {
+ this.error = e.message ?? 'Removal failed';
+ }
+ }
+
+ render() {
+ return html`
+
+
+
+
+ ${this.categories.length > 0
+ ? html`
+
+ ${this.categories.map(
+ (cat) => html`
+
+ `,
+ )}
+
+ `
+ : nothing}
+ ${this.error ? html`${this.error}
` : nothing}
+ ${this.loading
+ ? html`Loading marketplace\u2026
`
+ : this.modules.length === 0
+ ? html`No providers found.
`
+ : html`
+
+ ${this.modules.map(
+ (mod) => html`
+
+
+
+
+
+
+
+ `,
+ )}
+
+ `}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'core-scm-marketplace': ScmMarketplace;
+ }
+}
diff --git a/ui/src/scm-panel.ts b/ui/src/scm-panel.ts
new file mode 100644
index 0000000..9aa88d6
--- /dev/null
+++ b/ui/src/scm-panel.ts
@@ -0,0 +1,264 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { connectScmEvents, type ScmEvent } from './shared/events.js';
+
+// Side-effect imports to register child elements
+import './scm-marketplace.js';
+import './scm-installed.js';
+import './scm-manifest.js';
+import './scm-registry.js';
+
+type TabId = 'marketplace' | 'installed' | 'manifest' | 'registry';
+
+/**
+ * — Top-level HLCRF panel with tabs.
+ *
+ * Arranges child elements in HLCRF layout:
+ * - H: Title bar with refresh button
+ * - H-L: Navigation tabs
+ * - C: Active tab content (one of the child elements)
+ * - F: Status bar (connection state, last refresh)
+ */
+@customElement('core-scm-panel')
+export class ScmPanel extends LitElement {
+ static styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ font-family: system-ui, -apple-system, sans-serif;
+ height: 100%;
+ background: #fafafa;
+ }
+
+ /* H — Header */
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: #fff;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .title {
+ font-weight: 700;
+ font-size: 1rem;
+ colour: #111827;
+ }
+
+ .refresh-btn {
+ padding: 0.375rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ background: #fff;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+
+ .refresh-btn:hover {
+ background: #f3f4f6;
+ }
+
+ /* H-L — Tabs */
+ .tabs {
+ display: flex;
+ gap: 0;
+ background: #fff;
+ border-bottom: 1px solid #e5e7eb;
+ padding: 0 1rem;
+ }
+
+ .tab {
+ padding: 0.625rem 1rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ colour: #6b7280;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.15s;
+ background: none;
+ border-top: none;
+ border-left: none;
+ border-right: none;
+ }
+
+ .tab:hover {
+ colour: #374151;
+ }
+
+ .tab.active {
+ colour: #6366f1;
+ border-bottom-colour: #6366f1;
+ }
+
+ /* C — Content */
+ .content {
+ flex: 1;
+ padding: 1rem;
+ overflow-y: auto;
+ }
+
+ /* F — Footer / Status bar */
+ .footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ background: #fff;
+ border-top: 1px solid #e5e7eb;
+ font-size: 0.75rem;
+ colour: #9ca3af;
+ }
+
+ .ws-status {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ }
+
+ .ws-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ }
+
+ .ws-dot.connected {
+ background: #22c55e;
+ }
+
+ .ws-dot.disconnected {
+ background: #ef4444;
+ }
+
+ .ws-dot.idle {
+ background: #d1d5db;
+ }
+ `;
+
+ @property({ attribute: 'api-url' }) apiUrl = '';
+ @property({ attribute: 'ws-url' }) wsUrl = '';
+
+ @state() private activeTab: TabId = 'marketplace';
+ @state() private wsConnected = false;
+ @state() private lastEvent = '';
+
+ private ws: WebSocket | null = null;
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.wsUrl) {
+ this.connectWs();
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ private connectWs() {
+ this.ws = connectScmEvents(this.wsUrl, (event: ScmEvent) => {
+ this.lastEvent = event.channel ?? event.type ?? '';
+ this.requestUpdate();
+ });
+ this.ws.onopen = () => {
+ this.wsConnected = true;
+ };
+ this.ws.onclose = () => {
+ this.wsConnected = false;
+ };
+ }
+
+ private handleTabClick(tab: TabId) {
+ this.activeTab = tab;
+ }
+
+ private handleRefresh() {
+ // Force re-render of active child by toggling a key
+ const content = this.shadowRoot?.querySelector('.content');
+ if (content) {
+ const child = content.firstElementChild;
+ if (child && 'loadModules' in child) {
+ (child as any).loadModules();
+ } else if (child && 'loadInstalled' in child) {
+ (child as any).loadInstalled();
+ } else if (child && 'loadManifest' in child) {
+ (child as any).loadManifest();
+ } else if (child && 'loadRegistry' in child) {
+ (child as any).loadRegistry();
+ }
+ }
+ }
+
+ private renderContent() {
+ switch (this.activeTab) {
+ case 'marketplace':
+ return html``;
+ case 'installed':
+ return html``;
+ case 'manifest':
+ return html``;
+ case 'registry':
+ return html``;
+ default:
+ return nothing;
+ }
+ }
+
+ private tabs: { id: TabId; label: string }[] = [
+ { id: 'marketplace', label: 'Marketplace' },
+ { id: 'installed', label: 'Installed' },
+ { id: 'manifest', label: 'Manifest' },
+ { id: 'registry', label: 'Registry' },
+ ];
+
+ render() {
+ const wsState = this.wsUrl
+ ? this.wsConnected
+ ? 'connected'
+ : 'disconnected'
+ : 'idle';
+
+ return html`
+
+
+
+ ${this.tabs.map(
+ (tab) => html`
+
+ `,
+ )}
+
+
+ ${this.renderContent()}
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'core-scm-panel': ScmPanel;
+ }
+}
diff --git a/ui/src/scm-registry.ts b/ui/src/scm-registry.ts
new file mode 100644
index 0000000..6eaac0c
--- /dev/null
+++ b/ui/src/scm-registry.ts
@@ -0,0 +1,216 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { ScmApi } from './shared/api.js';
+
+interface RepoSummary {
+ name: string;
+ type: string;
+ description?: string;
+ depends_on?: string[];
+ path?: string;
+ exists: boolean;
+}
+
+/**
+ * — Show repos.yaml registry status.
+ * Read-only display of repository status.
+ */
+@customElement('core-scm-registry')
+export class ScmRegistry extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ font-family: system-ui, -apple-system, sans-serif;
+ }
+
+ .list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+
+ .repo {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 0.75rem 1rem;
+ background: #fff;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .repo-info {
+ flex: 1;
+ }
+
+ .repo-name {
+ font-weight: 600;
+ font-size: 0.9375rem;
+ font-family: monospace;
+ }
+
+ .repo-desc {
+ font-size: 0.8125rem;
+ colour: #6b7280;
+ margin-top: 0.125rem;
+ }
+
+ .repo-meta {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin-top: 0.25rem;
+ }
+
+ .type-badge {
+ font-size: 0.6875rem;
+ padding: 0.0625rem 0.5rem;
+ border-radius: 1rem;
+ font-weight: 600;
+ }
+
+ .type-badge.foundation {
+ background: #dbeafe;
+ colour: #1e40af;
+ }
+
+ .type-badge.module {
+ background: #f3e8ff;
+ colour: #6b21a8;
+ }
+
+ .type-badge.product {
+ background: #dcfce7;
+ colour: #166534;
+ }
+
+ .type-badge.template {
+ background: #fef3c7;
+ colour: #92400e;
+ }
+
+ .deps {
+ font-size: 0.75rem;
+ colour: #9ca3af;
+ }
+
+ .status {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ }
+
+ .status-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ }
+
+ .status-dot.present {
+ background: #22c55e;
+ }
+
+ .status-dot.missing {
+ background: #ef4444;
+ }
+
+ .status-label {
+ font-size: 0.75rem;
+ colour: #6b7280;
+ }
+
+ .empty {
+ text-align: center;
+ padding: 2rem;
+ colour: #9ca3af;
+ font-size: 0.875rem;
+ }
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ colour: #6b7280;
+ }
+
+ .error {
+ colour: #dc2626;
+ padding: 0.75rem;
+ background: #fef2f2;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ }
+ `;
+
+ @property({ attribute: 'api-url' }) apiUrl = '';
+
+ @state() private repos: RepoSummary[] = [];
+ @state() private loading = true;
+ @state() private error = '';
+
+ private api!: ScmApi;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.api = new ScmApi(this.apiUrl);
+ this.loadRegistry();
+ }
+
+ async loadRegistry() {
+ this.loading = true;
+ this.error = '';
+ try {
+ this.repos = await this.api.registry();
+ } catch (e: any) {
+ this.error = e.message ?? 'Failed to load registry';
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ render() {
+ if (this.loading) {
+ return html`Loading registry\u2026
`;
+ }
+
+ return html`
+ ${this.error ? html`${this.error}
` : nothing}
+ ${this.repos.length === 0
+ ? html`No repositories in registry.
`
+ : html`
+
+ ${this.repos.map(
+ (repo) => html`
+
+
+
${repo.name}
+ ${repo.description
+ ? html`
${repo.description}
`
+ : nothing}
+
+ ${repo.type}
+ ${repo.depends_on && repo.depends_on.length > 0
+ ? html`depends: ${repo.depends_on.join(', ')}`
+ : nothing}
+
+
+
+
+ ${repo.exists ? 'Present' : 'Missing'}
+
+
+ `,
+ )}
+
+ `}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'core-scm-registry': ScmRegistry;
+ }
+}
diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts
new file mode 100644
index 0000000..6a0034f
--- /dev/null
+++ b/ui/src/shared/api.ts
@@ -0,0 +1,77 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+/**
+ * ScmApi provides a typed fetch wrapper for the /api/v1/scm/* endpoints.
+ */
+export class ScmApi {
+ constructor(private baseUrl: string = '') {}
+
+ private get base(): string {
+ return `${this.baseUrl}/api/v1/scm`;
+ }
+
+ private async request(path: string, opts?: RequestInit): Promise {
+ const res = await fetch(`${this.base}${path}`, opts);
+ const json = await res.json();
+ if (!json.success) {
+ throw new Error(json.error?.message ?? 'Request failed');
+ }
+ return json.data as T;
+ }
+
+ marketplace(query?: string, category?: string) {
+ const params = new URLSearchParams();
+ if (query) params.set('q', query);
+ if (category) params.set('category', category);
+ const qs = params.toString();
+ return this.request(`/marketplace${qs ? `?${qs}` : ''}`);
+ }
+
+ marketplaceItem(code: string) {
+ return this.request(`/marketplace/${code}`);
+ }
+
+ install(code: string) {
+ return this.request(`/marketplace/${code}/install`, { method: 'POST' });
+ }
+
+ remove(code: string) {
+ return this.request(`/marketplace/${code}`, { method: 'DELETE' });
+ }
+
+ installed() {
+ return this.request('/installed');
+ }
+
+ updateInstalled(code: string) {
+ return this.request(`/installed/${code}/update`, { method: 'POST' });
+ }
+
+ manifest() {
+ return this.request('/manifest');
+ }
+
+ verify(publicKey: string) {
+ return this.request('/manifest/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ public_key: publicKey }),
+ });
+ }
+
+ sign(privateKey: string) {
+ return this.request('/manifest/sign', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ private_key: privateKey }),
+ });
+ }
+
+ permissions() {
+ return this.request('/manifest/permissions');
+ }
+
+ registry() {
+ return this.request('/registry');
+ }
+}
diff --git a/ui/src/shared/events.ts b/ui/src/shared/events.ts
new file mode 100644
index 0000000..a9a474c
--- /dev/null
+++ b/ui/src/shared/events.ts
@@ -0,0 +1,32 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+export interface ScmEvent {
+ type: string;
+ channel?: string;
+ data?: any;
+ timestamp?: string;
+}
+
+/**
+ * Connects to a WebSocket endpoint and dispatches SCM events to a handler.
+ * Returns the WebSocket instance for lifecycle management.
+ */
+export function connectScmEvents(
+ wsUrl: string,
+ handler: (event: ScmEvent) => void,
+): WebSocket {
+ const ws = new WebSocket(wsUrl);
+
+ ws.onmessage = (e: MessageEvent) => {
+ try {
+ const event: ScmEvent = JSON.parse(e.data);
+ if (event.type?.startsWith?.('scm.') || event.channel?.startsWith?.('scm.')) {
+ handler(event);
+ }
+ } catch {
+ // Ignore malformed messages
+ }
+ };
+
+ return ws;
+}
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
new file mode 100644
index 0000000..e7540a6
--- /dev/null
+++ b/ui/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2021",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2021", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "experimentalDecorators": true,
+ "useDefineForClassFields": false,
+ "declaration": true,
+ "sourceMap": true,
+ "isolatedModules": true
+ },
+ "include": ["src"]
+}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
new file mode 100644
index 0000000..312f000
--- /dev/null
+++ b/ui/vite.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ name: 'CoreScm',
+ fileName: 'core-scm',
+ formats: ['es'],
+ },
+ outDir: resolve(__dirname, '../pkg/api/ui/dist'),
+ emptyOutDir: true,
+ rollupOptions: {
+ output: {
+ entryFileNames: 'core-scm.js',
+ },
+ },
+ },
+});