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:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to Armature are documented here.
|
||||
|
||||
## v0.8.5 — Extension Composability
|
||||
|
||||
Extensions can now compose with each other through declared slots, UI
|
||||
contributions, and cross-package function calls. This is the last kernel
|
||||
feature before 1.0 — the platform now supports the "extensions extending
|
||||
extensions" pattern.
|
||||
|
||||
**Manifest declarations**
|
||||
|
||||
- Surfaces declare named `slots` in their manifest (e.g., `toolbar-actions`,
|
||||
`note-footer`) with context documentation for extension authors.
|
||||
- Extensions declare `contributes` entries targeting `{host}:{slot}` names
|
||||
(e.g., `notes:toolbar-actions`). Coupling is soft — install order doesn't
|
||||
matter.
|
||||
- The kernel validates slot/contribution conventions at install time.
|
||||
|
||||
**Backend: `lib.require()` relaxation**
|
||||
|
||||
- `lib.require()` now works with any package that declares `exports`, not
|
||||
just `type: "library"` packages. A full package (with surfaces, settings,
|
||||
UI) can export callable functions for cross-package use.
|
||||
- The existing `depends` and permission model is unchanged — the called
|
||||
function runs with the target package's permissions.
|
||||
|
||||
**Admin slots endpoint**
|
||||
|
||||
- `GET /api/v1/admin/slots` returns an aggregated map of all declared slots
|
||||
across installed packages with their contributors.
|
||||
- Uninstalling a package that declares slots warns about orphaned
|
||||
contributions in other packages.
|
||||
|
||||
**SDK additions**
|
||||
|
||||
- `sw.slots.renderAll(name, context)` — convenience helper for host surfaces
|
||||
to render all components in a slot with error isolation.
|
||||
- `sw.slots.declare(name, description)` — runtime slot declaration for
|
||||
discoverability and debugging.
|
||||
|
||||
**Documentation**
|
||||
|
||||
- `PACKAGE-FORMAT.md` — added `slots`, `contributes`, `depends` field docs.
|
||||
- `EXTENSION-GUIDE.md` — new "Extension Composability" section with slot
|
||||
naming conventions, contribution patterns, and cross-package call examples.
|
||||
- `STARLARK-REFERENCE.md` — updated `lib` module to reflect exports-based
|
||||
calling (not library-type-only).
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `server/sandbox/lib_module.go` — type check → exports check
|
||||
- `server/handlers/extensions.go` — composability field validation
|
||||
- `server/handlers/packages.go` — orphaned contribution warning
|
||||
- `server/main.go` — admin slots route registration
|
||||
- `src/js/sw/sdk/slots.js` — `renderAll()`, `declare()`, `declarations()`
|
||||
- `docs/PACKAGE-FORMAT.md` — slots, contributes, depends
|
||||
- `docs/EXTENSION-GUIDE.md` — composability section
|
||||
- `docs/STARLARK-REFERENCE.md` — lib module update
|
||||
- `docs/DESIGN-extension-composability.md` — status Draft → Implemented
|
||||
|
||||
**New files:**
|
||||
|
||||
- `server/handlers/admin_slots.go` — admin slot aggregation endpoint
|
||||
|
||||
## v0.8.4 — Documentation Refresh + Surface Sizing Fix
|
||||
|
||||
Eight versions of module additions (v0.7.5–v0.8.3) shipped without a
|
||||
|
||||
12
ROADMAP.md
12
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Armature — Roadmap
|
||||
|
||||
## Current: v0.8.5 — Extension Composability
|
||||
## Current: v0.8.5 — Extension Composability (Kernel Complete)
|
||||
|
||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||
@@ -444,7 +444,7 @@ sizing fix: `.surface-inner` converted to flex column layout so the shell
|
||||
topbar and surface container share vertical space correctly — eliminates
|
||||
44px scroll cutoff on all surfaces.
|
||||
|
||||
**v0.8.5 — Extension Composability**
|
||||
**v0.8.5 — Extension Composability** ✅
|
||||
|
||||
Design doc: `docs/DESIGN-extension-composability.md`
|
||||
|
||||
@@ -453,14 +453,16 @@ and `contributes` (extensions declare UI contributions to other packages'
|
||||
slots). `lib.require()` relaxed from library-only to any package with
|
||||
`exports` — enables full packages (with surfaces, settings, UI) to also
|
||||
expose callable backend functions. `sw.slots.renderAll()` SDK helper.
|
||||
Admin slot aggregation endpoint.
|
||||
Admin slot aggregation endpoint. Uninstall orphan warnings.
|
||||
|
||||
The composability primitive that enables: LLM tool registration, UI
|
||||
contribution (toolbar buttons, image actions), and cross-extension
|
||||
function calls. No new tables, migrations, or permissions.
|
||||
|
||||
Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`,
|
||||
`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.go`.
|
||||
Modified: `sandbox/lib_module.go`, `handlers/extensions.go`,
|
||||
`handlers/packages.go`, `src/js/sw/sdk/slots.js`, `server/main.go`.
|
||||
New: `handlers/admin_slots.go`. Docs: PACKAGE-FORMAT, EXTENSION-GUIDE,
|
||||
STARLARK-REFERENCE updated.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# DESIGN — Extension Composability
|
||||
|
||||
**Version:** v0.8.4
|
||||
**Status:** Draft
|
||||
**Version:** v0.8.5
|
||||
**Status:** Implemented
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
@@ -77,7 +77,10 @@ Every package has a `manifest.json` at its root. Example for a surface:
|
||||
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
||||
| `db_tables` | no | Table definitions (see below) |
|
||||
| `settings` | no | User-configurable settings schema |
|
||||
| `exports` | libraries | Functions exported for other packages |
|
||||
| `exports` | no | Functions exported for cross-package calls via `lib.require()` |
|
||||
| `depends` | no | Array of package IDs this package depends on |
|
||||
| `slots` | no | Named UI injection points for host surfaces |
|
||||
| `contributes` | no | Slot contributions into other surfaces |
|
||||
| `hooks` | no | Event bus subscriptions |
|
||||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||||
| `capabilities` | no | Environment requirements (see below) |
|
||||
@@ -194,6 +197,117 @@ sandbox permission model.
|
||||
In Starlark, check permissions inline via `req["permissions"]` or call
|
||||
`permissions.check(user_id, "image-gen.use")`.
|
||||
|
||||
## Extension Composability
|
||||
|
||||
Extensions compose with each other through three mechanisms: manifest-declared
|
||||
**slots** (UI injection points), **contributions** (UI components injected into
|
||||
those slots), and cross-package **function calls** via `lib.require()`.
|
||||
|
||||
### Slots — Host Surfaces Declare Injection Points
|
||||
|
||||
A surface declares named slots in its manifest where other extensions can
|
||||
inject UI components:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"type": "surface",
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string",
|
||||
"getContent": "function — returns note body",
|
||||
"setContent": "function — replaces note body"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The surface renders slot contents using the SDK helper:
|
||||
|
||||
```javascript
|
||||
html`<div class="toolbar">
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: note.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text),
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
### Contributions — Extensions Inject UI
|
||||
|
||||
An extension declares which slots it contributes to:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-dictate",
|
||||
"type": "extension",
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text dictation"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In its JavaScript, it registers the component:
|
||||
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-dictate',
|
||||
priority: 200,
|
||||
component: ({ noteId, setContent, getContent }) => {
|
||||
// ... component implementation
|
||||
return html`<button class="sw-btn sw-btn--ghost">🎤</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Contributions are soft-coupled — install order doesn't matter. The admin
|
||||
can view all slots and contributors at **Admin > Packages** or via
|
||||
`GET /api/v1/admin/slots`.
|
||||
|
||||
### Cross-Package Function Calls
|
||||
|
||||
Any package that declares `exports` in its manifest can be called by other
|
||||
packages via `lib.require()`. The caller declares the dependency:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-ai",
|
||||
"depends": ["llm-bridge"],
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
In Starlark:
|
||||
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
result = llm.complete([{"role": "user", "content": prompt}])
|
||||
```
|
||||
|
||||
The called function runs with the *target* package's permissions, not the
|
||||
caller's. This is the same security model as library packages.
|
||||
|
||||
### Slot Naming Convention
|
||||
|
||||
Slot names follow `{host-package-id}:{slot-name}`. The colon separates the
|
||||
namespace from the slot. Standard slots for first-party packages:
|
||||
|
||||
| Slot | Host | Use Case |
|
||||
|------|------|----------|
|
||||
| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting |
|
||||
| `notes:note-footer` | notes | Related items, AI summary |
|
||||
| `chat:composer-tools` | chat | Image gen, file attach |
|
||||
| `chat:message-actions` | chat | Reactions, translate, bookmark |
|
||||
| `chat:image-actions` | chat | Regen, edit, upscale |
|
||||
|
||||
## Starlark Sandbox API
|
||||
|
||||
Starlark scripts run server-side with a 1M operation budget and no
|
||||
|
||||
@@ -68,10 +68,66 @@ Only `manifest.json` is required. All other directories are optional and include
|
||||
| `db_tables` | Table definitions with columns and indexes |
|
||||
| `settings` | User-configurable settings with type, label, description, default |
|
||||
| `hooks` | Event bus subscription patterns |
|
||||
| `exports` | Functions exported by library packages |
|
||||
| `exports` | Functions exported for cross-package calls via `lib.require()` |
|
||||
| `depends` | Array of package IDs this package depends on |
|
||||
| `slots` | Named UI injection points (host surfaces declare these) |
|
||||
| `contributes` | Slot contributions this package injects into other surfaces |
|
||||
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||
| `schema_version` | Integer for additive schema migrations |
|
||||
|
||||
## Composability: Slots and Contributions
|
||||
|
||||
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
|
||||
|
||||
### Declaring Slots (Host Surfaces)
|
||||
|
||||
Surfaces declare slots where other extensions can inject UI:
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string — current note ID",
|
||||
"getContent": "function — returns note body text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slot names are namespaced at runtime as `{package-id}:{slot-name}` (e.g., `notes:toolbar-actions`). The `context` field documents what data the slot provides — it is not enforced at runtime.
|
||||
|
||||
### Contributing to Slots
|
||||
|
||||
Extensions declare which slots they inject into:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text dictation"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at `GET /api/v1/admin/slots`.
|
||||
|
||||
### Cross-Package Function Calls
|
||||
|
||||
Any package that declares `exports` can be called via `lib.require()`, not just library-type packages. The caller must declare the target in its `depends` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"depends": ["image-gen"],
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
## Package Lifecycle
|
||||
|
||||
1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes.
|
||||
|
||||
@@ -50,7 +50,7 @@ is detected by the kernel. Detected capabilities: `pgvector`, `workspace`,
|
||||
|
||||
### lib
|
||||
|
||||
Load exported functions from library packages.
|
||||
Load exported functions from other packages.
|
||||
|
||||
```python
|
||||
helpers = lib.require("my-utils")
|
||||
@@ -58,11 +58,17 @@ result = helpers.format_date("2026-01-15")
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- The library must be declared in your package manifest's `dependencies`.
|
||||
- The library must be type `library`, status `active`, tier `starlark`.
|
||||
- The target package must be declared in your manifest's `depends` array.
|
||||
- The target package must declare `exports` in its manifest.
|
||||
- The target package must be status `active`, tier `starlark`.
|
||||
- Any package type (`library`, `extension`, `full`) can be called as long
|
||||
as it declares exports. This enables full packages to expose callable
|
||||
functions alongside their UI and API routes.
|
||||
- Circular dependencies are detected and rejected.
|
||||
- Results are cached per execution (calling `require` twice returns the
|
||||
same object).
|
||||
- The called function runs with the *target* package's permissions, not
|
||||
the caller's.
|
||||
|
||||
### permissions
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
export function createSlots(emitFn) {
|
||||
/** @type {Map<string, Array<{id:string, component:Function, priority:number}>>} */
|
||||
const _registry = new Map();
|
||||
/** @type {Map<string, string>|undefined} */
|
||||
let _declarations;
|
||||
|
||||
function _sort(arr) {
|
||||
arr.sort((a, b) => a.priority - b.priority);
|
||||
@@ -73,5 +75,43 @@ export function createSlots(emitFn) {
|
||||
names() {
|
||||
return [..._registry.keys()];
|
||||
},
|
||||
|
||||
/**
|
||||
* Render all components registered in a slot.
|
||||
* Each component is called with the context object. Errors are
|
||||
* caught so one broken contributor doesn't break the host surface.
|
||||
* @param {string} name — slot name
|
||||
* @param {object} context — props passed to each component
|
||||
* @returns {Array} — array of rendered vnodes (nulls filtered)
|
||||
*/
|
||||
renderAll(name, context = {}) {
|
||||
return this.get(name).map(e => {
|
||||
try {
|
||||
return e.component(context);
|
||||
} catch (err) {
|
||||
console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare a slot for discoverability. Runtime-only metadata —
|
||||
* does not affect registration or rendering.
|
||||
* @param {string} name — slot name
|
||||
* @param {string} description — human-readable purpose
|
||||
*/
|
||||
declare(name, description) {
|
||||
if (!_declarations) _declarations = new Map();
|
||||
_declarations.set(name, description);
|
||||
},
|
||||
|
||||
/**
|
||||
* List declared slot metadata (from declare()).
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
declarations() {
|
||||
return _declarations ? new Map(_declarations) : new Map();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user