v0.8.5: Extension composability — slots, contributes, lib.require relaxation
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 5s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m33s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 5s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m33s
Manifest declarations for slots (host surfaces declare injection points) and contributes (extensions declare UI contributions). lib.require() relaxed from library-only to any package with exports. Admin slots aggregation endpoint. SDK renderAll() helper. 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
96
server/handlers/admin_slots.go
Normal file
96
server/handlers/admin_slots.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// slotInfo describes a declared slot and its contributors.
|
||||
type slotInfo struct {
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
Contributors []slotContributor `json:"contributors"`
|
||||
}
|
||||
|
||||
// slotContributor describes one package's contribution to a slot.
|
||||
type slotContributor struct {
|
||||
Package string `json:"package"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ListSlots returns an aggregated map of all declared slots across installed
|
||||
// packages together with their contributors. Built on-the-fly from manifests.
|
||||
// GET /api/v1/admin/slots
|
||||
func (h *PackageHandler) ListSlots(c *gin.Context) {
|
||||
pkgs, err := h.stores.Packages.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||
return
|
||||
}
|
||||
|
||||
slots := map[string]*slotInfo{}
|
||||
|
||||
// Pass 1: collect declared slots from host surfaces
|
||||
for _, pkg := range pkgs {
|
||||
slotsRaw, ok := pkg.Manifest["slots"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, v := range slotsRaw {
|
||||
fullName := pkg.ID + ":" + name
|
||||
entry, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc, _ := entry["description"].(string)
|
||||
var ctx map[string]any
|
||||
if c, ok := entry["context"].(map[string]any); ok {
|
||||
ctx = c
|
||||
}
|
||||
slots[fullName] = &slotInfo{
|
||||
Host: pkg.ID,
|
||||
Description: desc,
|
||||
Context: ctx,
|
||||
Contributors: []slotContributor{},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: collect contributions
|
||||
for _, pkg := range pkgs {
|
||||
if !pkg.Enabled {
|
||||
continue
|
||||
}
|
||||
contribs, ok := pkg.Manifest["contributes"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for slotKey, v := range contribs {
|
||||
// Ensure the slot entry exists (contributor can arrive before host)
|
||||
if _, exists := slots[slotKey]; !exists {
|
||||
host := slotKey
|
||||
if idx := strings.Index(slotKey, ":"); idx >= 0 {
|
||||
host = slotKey[:idx]
|
||||
}
|
||||
slots[slotKey] = &slotInfo{
|
||||
Host: host,
|
||||
Contributors: []slotContributor{},
|
||||
}
|
||||
}
|
||||
contrib := slotContributor{Package: pkg.ID}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
contrib.Label, _ = m["label"].(string)
|
||||
contrib.Icon, _ = m["icon"].(string)
|
||||
contrib.Description, _ = m["description"].(string)
|
||||
}
|
||||
slots[slotKey].Contributors = append(slots[slotKey].Contributors, contrib)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": slots})
|
||||
}
|
||||
97
server/handlers/composability_test.go
Normal file
97
server/handlers/composability_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateComposabilityFields_ValidSlots(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"slots": map[string]any{
|
||||
"toolbar-actions": map[string]any{
|
||||
"description": "Toolbar action buttons",
|
||||
"context": map[string]any{
|
||||
"noteId": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err != "" {
|
||||
t.Errorf("expected valid, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_SlotMissingDescription(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"slots": map[string]any{
|
||||
"toolbar": map[string]any{
|
||||
"context": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err == "" {
|
||||
t.Error("expected error for slot missing description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_SlotsNotObject(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"slots": "bad",
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err == "" {
|
||||
t.Error("expected error for non-object slots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_ValidContributes(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"contributes": map[string]any{
|
||||
"notes:toolbar-actions": map[string]any{
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err != "" {
|
||||
t.Errorf("expected valid, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_ContributesMissingColon(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"contributes": map[string]any{
|
||||
"toolbar-actions": map[string]any{
|
||||
"label": "Bad",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err == "" {
|
||||
t.Error("expected error for contributes key without colon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_Empty(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"id": "test",
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err != "" {
|
||||
t.Errorf("expected valid for manifest without slots/contributes, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateComposabilityFields_BothValid(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"slots": map[string]any{
|
||||
"my-slot": map[string]any{
|
||||
"description": "A slot",
|
||||
},
|
||||
},
|
||||
"contributes": map[string]any{
|
||||
"other:their-slot": map[string]any{
|
||||
"label": "Hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateComposabilityFields(m); err != "" {
|
||||
t.Errorf("expected valid, got: %s", err)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,44 @@ var validTiers = map[string]bool{
|
||||
models.ExtTierSidecar: true,
|
||||
}
|
||||
|
||||
// ValidateComposabilityFields checks that manifest slots and contributes
|
||||
// fields follow the expected conventions. Slots values must have a
|
||||
// "description" string. Contributes keys must follow "pkg:slot" naming.
|
||||
// Returns nil if valid or missing; returns an error string otherwise.
|
||||
func ValidateComposabilityFields(manifest map[string]any) string {
|
||||
// Validate slots: map of name → {description, ...}
|
||||
if slotsRaw, ok := manifest["slots"]; ok {
|
||||
slots, ok := slotsRaw.(map[string]any)
|
||||
if !ok {
|
||||
return "slots must be an object"
|
||||
}
|
||||
for name, v := range slots {
|
||||
entry, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return "slots." + name + " must be an object"
|
||||
}
|
||||
if _, ok := entry["description"]; !ok {
|
||||
return "slots." + name + " must have a description"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate contributes: map of "pkg:slot" → {label, ...}
|
||||
if contribRaw, ok := manifest["contributes"]; ok {
|
||||
contribs, ok := contribRaw.(map[string]any)
|
||||
if !ok {
|
||||
return "contributes must be an object"
|
||||
}
|
||||
for key := range contribs {
|
||||
if !strings.Contains(key, ":") {
|
||||
return "contributes key " + key + " must follow pkg:slot convention"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── User endpoints ──────────────────────────────
|
||||
|
||||
// ListUserExtensions returns all enabled extensions for the current user,
|
||||
@@ -208,6 +246,12 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||
manifestMap = map[string]any{}
|
||||
}
|
||||
|
||||
// Validate composability fields (slots / contributes)
|
||||
if errMsg := ValidateComposabilityFields(manifestMap); errMsg != "" {
|
||||
c.JSON(400, gin.H{"error": "manifest: " + errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
pkg := &store.PackageRegistration{
|
||||
ID: body.ExtID,
|
||||
Title: body.Name,
|
||||
|
||||
@@ -212,10 +212,48 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
||||
resp := gin.H{"id": id, "deleted": true}
|
||||
|
||||
// Warn about orphaned slot contributions
|
||||
if orphaned := findOrphanedContributors(c, h.stores, id); len(orphaned) > 0 {
|
||||
resp["warning"] = "slot contributions orphaned in: " + strings.Join(orphaned, ", ")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("deleted", id)
|
||||
}
|
||||
|
||||
// findOrphanedContributors returns package IDs that declare contributes
|
||||
// targeting slots owned by the given package (e.g. "notes:toolbar-actions"
|
||||
// targets the "notes" package). This is a warning, not a blocker.
|
||||
func findOrphanedContributors(c *gin.Context, stores store.Stores, deletedPkgID string) []string {
|
||||
allPkgs, err := stores.Packages.List(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prefix := deletedPkgID + ":"
|
||||
seen := map[string]bool{}
|
||||
for _, p := range allPkgs {
|
||||
if p.ID == deletedPkgID || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
contribs, ok := p.Manifest["contributes"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for key := range contribs {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
seen[p.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// InstallPackage uploads and installs a .pkg/.surface archive.
|
||||
// POST /api/v1/admin/packages/install
|
||||
//
|
||||
@@ -466,6 +504,13 @@ func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Validate composability fields (slots / contributes)
|
||||
if errMsg := ValidateComposabilityFields(manifest); errMsg != "" {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest: " + errMsg})
|
||||
return nil, nil, nil, fmt.Errorf("invalid composability fields")
|
||||
}
|
||||
|
||||
// Signing hook (reserved)
|
||||
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
||||
if mInfo.Signature != "" {
|
||||
|
||||
@@ -859,6 +859,7 @@ func main() {
|
||||
admin.POST("/packages/:id/update", pkgAdm.UpdatePackage)
|
||||
admin.GET("/packages/:id/export", pkgAdm.ExportPackage)
|
||||
admin.GET("/dependencies", pkgAdm.ListAllDependencies)
|
||||
admin.GET("/slots", pkgAdm.ListSlots)
|
||||
|
||||
// Workflow package export
|
||||
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
|
||||
|
||||
@@ -79,19 +79,21 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru
|
||||
return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID)
|
||||
}
|
||||
|
||||
// 4. Load library's PackageRegistration
|
||||
// 4. Load target PackageRegistration
|
||||
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err)
|
||||
return nil, fmt.Errorf("lib.require: package %q not found: %w", libraryID, err)
|
||||
}
|
||||
if libPkg.Type != "library" {
|
||||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||||
// Any package with exports is callable — not just type "library"
|
||||
exports := extractExports(libPkg.Manifest)
|
||||
if len(exports) == 0 {
|
||||
return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID)
|
||||
}
|
||||
if libPkg.Status != models.PackageStatusActive {
|
||||
return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status)
|
||||
return nil, fmt.Errorf("lib.require: package %q is %s, not active", libraryID, libPkg.Status)
|
||||
}
|
||||
if libPkg.Tier != models.ExtTierStarlark {
|
||||
return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier)
|
||||
return nil, fmt.Errorf("lib.require: package %q is tier %s, not starlark", libraryID, libPkg.Tier)
|
||||
}
|
||||
|
||||
// 5. Build library's module set (library's own permissions)
|
||||
@@ -113,12 +115,7 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru
|
||||
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
|
||||
}
|
||||
|
||||
// 8. Extract exports from manifest
|
||||
exports := extractExports(libPkg.Manifest)
|
||||
if len(exports) == 0 {
|
||||
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
|
||||
}
|
||||
|
||||
// 8. Build exported members (exports already extracted above)
|
||||
members := make(starlark.StringDict, len(exports))
|
||||
for _, name := range exports {
|
||||
val, ok := result.Globals[name]
|
||||
|
||||
Reference in New Issue
Block a user