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.
|
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
|
## v0.8.4 — Documentation Refresh + Surface Sizing Fix
|
||||||
|
|
||||||
Eight versions of module additions (v0.7.5–v0.8.3) shipped without a
|
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
|
# 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
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
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
|
topbar and surface container share vertical space correctly — eliminates
|
||||||
44px scroll cutoff on all surfaces.
|
44px scroll cutoff on all surfaces.
|
||||||
|
|
||||||
**v0.8.5 — Extension Composability**
|
**v0.8.5 — Extension Composability** ✅
|
||||||
|
|
||||||
Design doc: `docs/DESIGN-extension-composability.md`
|
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
|
slots). `lib.require()` relaxed from library-only to any package with
|
||||||
`exports` — enables full packages (with surfaces, settings, UI) to also
|
`exports` — enables full packages (with surfaces, settings, UI) to also
|
||||||
expose callable backend functions. `sw.slots.renderAll()` SDK helper.
|
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
|
The composability primitive that enables: LLM tool registration, UI
|
||||||
contribution (toolbar buttons, image actions), and cross-extension
|
contribution (toolbar buttons, image actions), and cross-extension
|
||||||
function calls. No new tables, migrations, or permissions.
|
function calls. No new tables, migrations, or permissions.
|
||||||
|
|
||||||
Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`,
|
Modified: `sandbox/lib_module.go`, `handlers/extensions.go`,
|
||||||
`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.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
|
# DESIGN — Extension Composability
|
||||||
|
|
||||||
**Version:** v0.8.4
|
**Version:** v0.8.5
|
||||||
**Status:** Draft
|
**Status:** Implemented
|
||||||
**Author:** Jeff / Claude session 2026-04-02
|
**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) |
|
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
||||||
| `db_tables` | no | Table definitions (see below) |
|
| `db_tables` | no | Table definitions (see below) |
|
||||||
| `settings` | no | User-configurable settings schema |
|
| `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 |
|
| `hooks` | no | Event bus subscriptions |
|
||||||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||||||
| `capabilities` | no | Environment requirements (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
|
In Starlark, check permissions inline via `req["permissions"]` or call
|
||||||
`permissions.check(user_id, "image-gen.use")`.
|
`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 Sandbox API
|
||||||
|
|
||||||
Starlark scripts run server-side with a 1M operation budget and no
|
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 |
|
| `db_tables` | Table definitions with columns and indexes |
|
||||||
| `settings` | User-configurable settings with type, label, description, default |
|
| `settings` | User-configurable settings with type, label, description, default |
|
||||||
| `hooks` | Event bus subscription patterns |
|
| `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": [...]}` |
|
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||||
| `schema_version` | Integer for additive schema migrations |
|
| `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
|
## 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.
|
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
|
### lib
|
||||||
|
|
||||||
Load exported functions from library packages.
|
Load exported functions from other packages.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
helpers = lib.require("my-utils")
|
helpers = lib.require("my-utils")
|
||||||
@@ -58,11 +58,17 @@ result = helpers.format_date("2026-01-15")
|
|||||||
```
|
```
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- The library must be declared in your package manifest's `dependencies`.
|
- The target package must be declared in your manifest's `depends` array.
|
||||||
- The library must be type `library`, status `active`, tier `starlark`.
|
- 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.
|
- Circular dependencies are detected and rejected.
|
||||||
- Results are cached per execution (calling `require` twice returns the
|
- Results are cached per execution (calling `require` twice returns the
|
||||||
same object).
|
same object).
|
||||||
|
- The called function runs with the *target* package's permissions, not
|
||||||
|
the caller's.
|
||||||
|
|
||||||
### permissions
|
### 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,
|
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 ──────────────────────────────
|
// ── User endpoints ──────────────────────────────
|
||||||
|
|
||||||
// ListUserExtensions returns all enabled extensions for the current user,
|
// ListUserExtensions returns all enabled extensions for the current user,
|
||||||
@@ -208,6 +246,12 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
|||||||
manifestMap = map[string]any{}
|
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{
|
pkg := &store.PackageRegistration{
|
||||||
ID: body.ExtID,
|
ID: body.ExtID,
|
||||||
Title: body.Name,
|
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)
|
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.
|
// InstallPackage uploads and installs a .pkg/.surface archive.
|
||||||
// POST /api/v1/admin/packages/install
|
// POST /api/v1/admin/packages/install
|
||||||
//
|
//
|
||||||
@@ -466,6 +504,13 @@ func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string)
|
|||||||
return nil, nil, nil, err
|
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)
|
// Signing hook (reserved)
|
||||||
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
||||||
if mInfo.Signature != "" {
|
if mInfo.Signature != "" {
|
||||||
|
|||||||
@@ -859,6 +859,7 @@ func main() {
|
|||||||
admin.POST("/packages/:id/update", pkgAdm.UpdatePackage)
|
admin.POST("/packages/:id/update", pkgAdm.UpdatePackage)
|
||||||
admin.GET("/packages/:id/export", pkgAdm.ExportPackage)
|
admin.GET("/packages/:id/export", pkgAdm.ExportPackage)
|
||||||
admin.GET("/dependencies", pkgAdm.ListAllDependencies)
|
admin.GET("/dependencies", pkgAdm.ListAllDependencies)
|
||||||
|
admin.GET("/slots", pkgAdm.ListSlots)
|
||||||
|
|
||||||
// Workflow package export
|
// Workflow package export
|
||||||
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
|
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)
|
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)
|
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
|
||||||
if err != nil {
|
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" {
|
// Any package with exports is callable — not just type "library"
|
||||||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
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 {
|
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 {
|
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)
|
// 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)
|
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Extract exports from manifest
|
// 8. Build exported members (exports already extracted above)
|
||||||
exports := extractExports(libPkg.Manifest)
|
|
||||||
if len(exports) == 0 {
|
|
||||||
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
members := make(starlark.StringDict, len(exports))
|
members := make(starlark.StringDict, len(exports))
|
||||||
for _, name := range exports {
|
for _, name := range exports {
|
||||||
val, ok := result.Globals[name]
|
val, ok := result.Globals[name]
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
export function createSlots(emitFn) {
|
export function createSlots(emitFn) {
|
||||||
/** @type {Map<string, Array<{id:string, component:Function, priority:number}>>} */
|
/** @type {Map<string, Array<{id:string, component:Function, priority:number}>>} */
|
||||||
const _registry = new Map();
|
const _registry = new Map();
|
||||||
|
/** @type {Map<string, string>|undefined} */
|
||||||
|
let _declarations;
|
||||||
|
|
||||||
function _sort(arr) {
|
function _sort(arr) {
|
||||||
arr.sort((a, b) => a.priority - b.priority);
|
arr.sort((a, b) => a.priority - b.priority);
|
||||||
@@ -73,5 +75,43 @@ export function createSlots(emitFn) {
|
|||||||
names() {
|
names() {
|
||||||
return [..._registry.keys()];
|
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