1 Commits

Author SHA1 Message Date
8b9360d198 Feat v0.9.8 conditional routing SDK primitive
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Failing after 2m42s
CI/CD / test-sqlite (pull_request) Successful in 3m5s
CI/CD / build-and-deploy (pull_request) Has been skipped
Promote workflow branch-rule evaluation to a generic Starlark
routing.evaluate(rules, data) module available to all extensions.
10 operators, first-match-wins, no permission gate. 8 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:38:30 +00:00
16 changed files with 83 additions and 972 deletions

View File

@@ -2,75 +2,6 @@
All notable changes to Armature are documented here.
## v0.10.0 — Panel Manifest + Lifecycle
First version in the Panels series. Adds the plumbing layer for
composable companion views — a new rendering tier between surfaces
(full-page) and block renderers (inline). No presentation UI ships
in this version; floating and docked chrome arrive in v0.10.1v0.10.2.
**Manifest: `panels` field (provider + consumer)**
- Provider form: packages declare panels as a map of `{ entry, title,
icon, description, min_width, min_height, default_width, default_height }`
- Consumer form: surfaces declare panel dependencies as an array of
`"package.panel"` strings (soft dependency — install succeeds even
if provider is missing)
- Validation enforces required `entry` and `title` for providers,
`pkg.panel` dot-format for consumers
**Backend: panel resolution at surface load**
- `PanelMeta` struct carries resolved panel metadata
- `resolvePanels()` resolves consumer references against installed and
enabled provider packages at surface render time
- `window.__PANELS__` injected into base template for extension surfaces
- Soft-dependency warning logged at install time for unresolved consumers
**Frontend: `sw.panels` SDK module**
- `sw.panels.open(panelId, opts)` — lazy-loads panel JS via dynamic
`import()`, mounts into container, caches module for reuse
- `sw.panels.close(panelId)` — calls cleanup, removes container
- `sw.panels.toggle(panelId, opts)` — open if closed, close if open
- `sw.panels.isOpen(panelId)` / `sw.panels.isAvailable(panelId)` — boolean queries
- `sw.panels.list()` / `sw.panels.active()` — registered and open panel IDs
- Events: `panels.opened` and `panels.closed` emitted on lifecycle transitions
- Mount context: `{ sw, params, panelId, close, resize }`
- Unstyled container (position:fixed div) — v0.10.1 adds FloatingPanel chrome
**Tests:** 10 new — 6 manifest validation, 4 panel resolution
## v0.9.9 — Surface Access via Roles
Surfaces can now gate access by team role. A manifest declaring
`"access": "role:approver"` restricts the surface to users who hold the
`approver` role in any team — consistent with how `group:NAME` works.
**New surface access level: `role:ROLENAME`**
- Added to both `evaluateAccess()` and `validAccessLevels()` validation
- User must hold the specified role in at least one team (any-team
semantics — no team context needed in the URL)
- System admins bypass role checks, consistent with `RequireRole`
middleware
- Unauthenticated users get redirected to login
- Graceful fallback: if Teams store is nil, access is denied (fail closed)
**New store method: `HasRoleInAnyTeam(ctx, userID, role)`**
- Checks both primary role (`team_members`) and additional roles
(`team_user_roles`) across all teams in a single query
- Implemented for both SQLite and PostgreSQL
**Refactor:** `evaluateAccess` promoted from package-level function to
`Engine` method to enable store access for role lookups.
**Tests:** 10 new tests — 5 handler integration tests (granted, denied,
unauthenticated, admin bypass, nil store), 3 store tests
(primary role, additional role, no match), 2 validation tests
(valid role, empty role name)
## v0.9.8 — Conditional Routing → SDK Primitive
Promotes the workflow branch-rule engine to a generic Starlark SDK

View File

@@ -1,5 +1,7 @@
# Armature — Roadmap
## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else
is an extension.
@@ -71,37 +73,95 @@ All completed work is documented in `CHANGELOG.md`.
---
## Planned
### v0.9.x — Multi-Surface Packages + Workflow Redesign
| Version | Title |
|---------|-------|
| v0.9.0 | Multi-Surface Packages |
| v0.9.1 | Server-Side Sub-Path Routing |
| v0.9.2 | Converter Consolidation |
| v0.9.3 | Team User Roles |
| v0.9.4 | Package Adoption + Roles |
| v0.9.5 | Typed Forms SDK |
| v0.9.6 | Stage Mode Collapse |
| v0.9.7 | Workflow Starlark Write Ops |
| v0.9.8 | Routing SDK Primitive |
| v0.9.9 | Surface Access via Roles |
**v0.9.0 — Multi-Surface Packages** *(completed)*
Packages declare a `surfaces` array with per-path access controls,
titles, and layouts. Unified route tree dispatches between surface
rendering and ext API calls. `sw.navigate()` for client-side sub-path
routing. Design doc: `docs/DESIGN-multi-surface.md`.
**v0.9.1 — Server-Side Sub-Path Routing** *(completed)*
Consolidated root and catch-all route handlers into a unified dispatcher.
Added `aggregateAccess()` for early auth short-circuit on all-authenticated
packages. SDK seeds initial history state for back-button resilience.
8 handler integration tests + 5 aggregateAccess unit tests.
**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** *(completed)*
Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go`
(4 exported functions) and snapshot parsers into `models/snapshot.go`.
Standardized on wrapped snapshot format. ~350 lines of duplication removed.
Design doc: `docs/DESIGN-workflow-redesign.md`.
**v0.9.3 — Team User Roles** *(completed)*
Many-to-many `team_user_roles` table. `RequireRole()` middleware.
Manifest `requires_roles` field (advisory). Starlark `teams` module
with `get_member_roles()` and `has_role()`. Team-admin UI with role
badge chips and assignment dropdown. 10 new tests.
**v0.9.4 — Package Adoption + Roles** *(completed)*
`adoptable` manifest field + `team_role_catalog` table. When a team
adopts an adoptable package, the package's `requires_roles` auto-populate
into the team's role catalog. Adopted packages reference the original via
`adopted_from` column (shared assets, no disk duplication).
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
4 new endpoints, migration 017, 11 new tests.
**v0.9.5 — Typed Forms → SDK Primitive** *(completed)*
Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
`models/workflow.go` into a standalone `forms` package. REST endpoint
`POST /api/v1/forms/validate`. Starlark `forms.validate()` module.
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
Manifest `form_template` accepted at package level. 16 new tests.
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)*
`stage_type` deprecated (no longer validated, defaults to "simple").
`stage_mode` collapsed from 4→3 values: form / delegated / automated.
"review" mapped to "form" on input; review surface removed (~110 lines).
Migration 018. 4 package manifests updated.
**v0.9.7 — Full Read/Write Workflow Starlark Module** *(completed)*
`WorkflowEngine` interface extracted in sandbox package to break
circular import. Four write builtins added: `workflow.start()`,
`workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()`.
`instanceToDict` and `signoffToDict` helpers shared by read+write paths.
6 new tests.
**v0.9.8 — Conditional Routing → SDK Primitive** *(completed)*
`routing.evaluate(rules, data)` Starlark builtin — a generic decision
engine reusable by any extension. 10 operators (exists, not_exists, eq,
neq, gt, lt, gte, lte, in, contains), first-match-wins, returns target
string or None. Always available (pure computation, no permission).
8 new tests.
**v0.9.9 — Surface Access via Roles**
Wire team roles (v0.9.3) into surface access declarations:
`access: role:approver`. Kernel middleware checks role membership.
Completes the workflow→package access story.
---
## Current: v0.10.x — Panels + Composable Layout
### v0.10.x — Panels + Composable Layout
Panels are a new kernel rendering tier between surfaces (full-page) and
block renderers (inline). They solve composable companion views — e.g.,
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`.
**v0.10.0 — Panel Manifest + Lifecycle** *(completed)*
Manifest `panels` field (provider map + consumer array, soft dependency).
`resolvePanels()` at surface load. `sw.panels` SDK module with full
lifecycle API. Lazy JS loading with module caching. 10 new tests.
| Version | Title |
|---------|-------|
| v0.10.0 | Panel Manifest + Lifecycle |
| v0.10.1 | FloatingPanel Primitive |
| v0.10.2 | Docked Panels + Mode Transitions |
| v0.10.3 | Panel Communication Patterns |

View File

@@ -1 +1 @@
0.10.0
0.9.8

View File

@@ -38,8 +38,6 @@ type ManifestInfo struct {
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
HasFormTemplate bool // if true, package declares a form_template at the top level
HasPanels bool // if true, package provides panels (provider form)
PanelConsumers []string // panel IDs this package consumes (consumer form, e.g. "notes.reference")
}
// ValidateManifest parses a manifest map and validates all required fields,
@@ -201,44 +199,6 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
info.HasFormTemplate = true
}
// v0.10.0: panels — provider (map) or consumer (array) declarations
if rawPanels := manifest["panels"]; rawPanels != nil {
switch p := rawPanels.(type) {
case map[string]any:
// Provider form: { "reference": { "entry": "...", "title": "..." }, ... }
for key, raw := range p {
panel, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("panels.%s must be an object", key)
}
entry, _ := panel["entry"].(string)
if entry == "" {
return nil, fmt.Errorf("panels.%s requires an 'entry' field", key)
}
title, _ := panel["title"].(string)
if title == "" {
return nil, fmt.Errorf("panels.%s requires a 'title' field", key)
}
}
info.HasPanels = true
case []any:
// Consumer form: ["notes.reference", "notes.graph"]
for i, raw := range p {
s, ok := raw.(string)
if !ok || s == "" {
return nil, fmt.Errorf("panels[%d] must be a non-empty string", i)
}
parts := strings.SplitN(s, ".", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("panels[%d] must be in 'package.panel' format, got %q", i, s)
}
info.PanelConsumers = append(info.PanelConsumers, s)
}
default:
return nil, fmt.Errorf("panels must be an object (provider) or array (consumer)")
}
}
info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ────────────────────────────────
@@ -299,8 +259,6 @@ func validAccessLevels(access string) bool {
return true
case strings.HasPrefix(access, "group:"):
return strings.TrimPrefix(access, "group:") != ""
case strings.HasPrefix(access, "role:"):
return strings.TrimPrefix(access, "role:") != ""
default:
return false
}

View File

@@ -349,100 +349,3 @@ func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) {
t.Errorf("expected layout 'editor', got %q", s["layout"])
}
}
// ── Panels validation (v0.10.0) ─────────────────────────────
func TestValidateManifest_PanelsProviderValid(t *testing.T) {
m := map[string]any{
"id": "my-notes",
"title": "Notes",
"panels": map[string]any{
"reference": map[string]any{
"entry": "js/panels/reference.js",
"title": "Notes Reference",
"icon": "📝",
},
},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.HasPanels {
t.Error("expected HasPanels to be true")
}
}
func TestValidateManifest_PanelsProviderMissingEntry(t *testing.T) {
m := map[string]any{
"id": "my-notes",
"title": "Notes",
"panels": map[string]any{
"reference": map[string]any{
"title": "Notes Reference",
},
},
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for panel missing entry")
}
}
func TestValidateManifest_PanelsProviderMissingTitle(t *testing.T) {
m := map[string]any{
"id": "my-notes",
"title": "Notes",
"panels": map[string]any{
"reference": map[string]any{
"entry": "js/panels/reference.js",
},
},
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for panel missing title")
}
}
func TestValidateManifest_PanelsConsumerValid(t *testing.T) {
m := map[string]any{
"id": "my-chat",
"title": "Chat",
"panels": []any{"notes.reference", "notes.graph"},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(info.PanelConsumers) != 2 {
t.Fatalf("expected 2 panel consumers, got %d", len(info.PanelConsumers))
}
if info.PanelConsumers[0] != "notes.reference" {
t.Errorf("expected 'notes.reference', got %q", info.PanelConsumers[0])
}
}
func TestValidateManifest_PanelsConsumerInvalidFormat(t *testing.T) {
m := map[string]any{
"id": "my-chat",
"title": "Chat",
"panels": []any{"notes-no-dot"},
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for consumer panel without dot separator")
}
}
func TestValidateManifest_PanelsInvalidType(t *testing.T) {
m := map[string]any{
"id": "my-pkg",
"title": "My Package",
"panels": "invalid-string",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for panels as string (neither map nor array)")
}
}

View File

@@ -349,22 +349,6 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// Phase 11: Soft-dep warnings for panel consumers
if len(mInfo.PanelConsumers) > 0 {
for _, ref := range mInfo.PanelConsumers {
parts := strings.SplitN(ref, ".", 2)
if len(parts) != 2 {
continue
}
provPkg, _ := h.stores.Packages.Get(c.Request.Context(), parts[0])
if provPkg == nil {
log.Printf("[packages] %s: panel consumer %q — provider package %q not installed", pkgID, ref, parts[0])
} else if !provPkg.Enabled {
log.Printf("[packages] %s: panel consumer %q — provider package %q is disabled", pkgID, ref, parts[0])
}
}
}
resp := gin.H{
"id": pkgID,
"title": mInfo.Title,

View File

@@ -262,93 +262,6 @@ func TestRequireRole_Denied(t *testing.T) {
}
}
// ── Store: HasRoleInAnyTeam ──────────────────
func TestHasRoleInAnyTeam_PrimaryRole(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
_, userID, _ := seedTeamAndMember(t, stores)
// "member" is the primary role assigned during seedTeamAndMember
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "member")
if err != nil {
t.Fatalf("HasRoleInAnyTeam: %v", err)
}
if !has {
t.Error("expected true for primary role 'member'")
}
}
func TestHasRoleInAnyTeam_AdditionalRole(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
teamID, userID, _ := seedTeamAndMember(t, stores)
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "reviewer")
if err != nil {
t.Fatalf("HasRoleInAnyTeam: %v", err)
}
if !has {
t.Error("expected true for additional role 'reviewer'")
}
}
func TestHasRoleInAnyTeam_NoMatch(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
_, userID, _ := seedTeamAndMember(t, stores)
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "nonexistent")
if err != nil {
t.Fatalf("HasRoleInAnyTeam: %v", err)
}
if has {
t.Error("expected false for non-existent role")
}
}
// ── Manifest: role access validation ────────
func TestValidateManifest_SurfaceRoleAccess(t *testing.T) {
m := map[string]any{
"id": "role-pkg",
"title": "Role Gated",
"type": "surface",
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver"},
},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error for role:approver access: %v", err)
}
if !info.HasSurfaces {
t.Error("expected HasSurfaces to be true")
}
}
func TestValidateManifest_SurfaceRoleAccessEmpty(t *testing.T) {
m := map[string]any{
"id": "role-pkg",
"title": "Role Gated",
"type": "surface",
"surfaces": []any{
map[string]any{"path": "/", "access": "role:"},
},
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for empty role name 'role:'")
}
}
// ── helpers ──────────────────────────────────
func seedRoleUser(t *testing.T, username, email string) string {

View File

@@ -61,20 +61,6 @@ type SurfaceManifest struct {
Source string `json:"source"` // "core" or "extension"
}
// PanelMeta describes a resolved panel available to a surface.
type PanelMeta struct {
PanelID string `json:"panel_id"` // e.g. "notes.reference"
PackageID string `json:"package_id"` // e.g. "notes"
Entry string `json:"entry"` // e.g. "js/panels/reference.js"
Title string `json:"title"` // human-readable
Icon string `json:"icon,omitempty"` // emoji or icon key
Description string `json:"description,omitempty"` // short description
MinWidth int `json:"min_width,omitempty"` // minimum width in px
MinHeight int `json:"min_height,omitempty"` // minimum height in px
DefaultWidth int `json:"default_width,omitempty"` // default width in px
DefaultHeight int `json:"default_height,omitempty"` // default height in px
}
// BannerConfig holds environment banner settings.
type BannerConfig struct {
Text string `json:"text"`
@@ -123,8 +109,6 @@ type PageData struct {
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection)
Panels []PanelMeta `json:"-"` // resolved panels available to this surface
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name
@@ -253,72 +237,6 @@ func (e *Engine) browserExtensionIDs() []string {
return ids
}
// resolvePanels reads the consumer's panels array from the raw manifest and
// resolves each reference against installed+enabled provider packages.
// Returns nil if no panels are declared or no providers are available.
func (e *Engine) resolvePanels(ctx context.Context, manifest map[string]any) []PanelMeta {
if e.stores.Packages == nil {
return nil
}
rawPanels, ok := manifest["panels"].([]any)
if !ok || len(rawPanels) == 0 {
return nil
}
var result []PanelMeta
for _, raw := range rawPanels {
ref, ok := raw.(string)
if !ok {
continue
}
parts := strings.SplitN(ref, ".", 2)
if len(parts) != 2 {
continue
}
pkgID, panelKey := parts[0], parts[1]
provPkg, err := e.stores.Packages.Get(ctx, pkgID)
if err != nil || provPkg == nil || !provPkg.Enabled {
continue
}
provPanels, ok := provPkg.Manifest["panels"].(map[string]any)
if !ok {
continue
}
panelDef, ok := provPanels[panelKey].(map[string]any)
if !ok {
continue
}
meta := PanelMeta{
PanelID: ref,
PackageID: pkgID,
}
meta.Entry, _ = panelDef["entry"].(string)
meta.Title, _ = panelDef["title"].(string)
meta.Icon, _ = panelDef["icon"].(string)
meta.Description, _ = panelDef["description"].(string)
if v, ok := panelDef["min_width"].(float64); ok {
meta.MinWidth = int(v)
}
if v, ok := panelDef["min_height"].(float64); ok {
meta.MinHeight = int(v)
}
if v, ok := panelDef["default_width"].(float64); ok {
meta.DefaultWidth = int(v)
}
if v, ok := panelDef["default_height"].(float64); ok {
meta.DefaultHeight = int(v)
}
if meta.Entry != "" && meta.Title != "" {
result = append(result, meta)
}
}
return result
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
@@ -604,7 +522,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
}
// ── Access check ─────────────────────────────────────────
if !e.evaluateAccess(c, surfaceAccess) {
if !evaluateAccess(c, surfaceAccess) {
// Redirect unauthenticated users to login; deny others with 403
if c.GetString("user_id") == "" {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
@@ -634,7 +552,6 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
Panels: e.resolvePanels(c.Request.Context(), sr.Manifest),
})
}
}
@@ -734,7 +651,7 @@ func aggregateAccess(surfaces []any) string {
}
// evaluateAccess checks whether the current request meets an access requirement.
func (e *Engine) evaluateAccess(c *gin.Context, access string) bool {
func evaluateAccess(c *gin.Context, access string) bool {
switch {
case access == "public":
return true
@@ -755,20 +672,6 @@ func (e *Engine) evaluateAccess(c *gin.Context, access string) bool {
}
}
return false
case strings.HasPrefix(access, "role:"):
role := strings.TrimPrefix(access, "role:")
userID := c.GetString("user_id")
if userID == "" {
return false
}
if c.GetBool("is_admin") {
return true
}
if e.stores.Teams == nil {
return false
}
has, err := e.stores.Teams.HasRoleInAnyTeam(c.Request.Context(), userID, role)
return err == nil && has
default:
return false
}

View File

@@ -11,69 +11,9 @@ import (
"github.com/gin-gonic/gin"
"armature/config"
"armature/models"
"armature/store"
)
// ── mock TeamStore ───────────────────────────────────────────
// mockTeamStore implements store.TeamStore with a simple role lookup map.
// Only HasRoleInAnyTeam is functional; everything else is a no-op stub.
type mockTeamStore struct {
// userRoles maps userID → set of roles they hold in any team
userRoles map[string]map[string]bool
}
func newMockTeamStore() *mockTeamStore {
return &mockTeamStore{userRoles: make(map[string]map[string]bool)}
}
func (m *mockTeamStore) addRole(userID, role string) {
if m.userRoles[userID] == nil {
m.userRoles[userID] = make(map[string]bool)
}
m.userRoles[userID][role] = true
}
func (m *mockTeamStore) HasRoleInAnyTeam(_ context.Context, userID, role string) (bool, error) {
if roles, ok := m.userRoles[userID]; ok {
return roles[role], nil
}
return false, nil
}
// Stubs — not exercised by surface access tests.
func (m *mockTeamStore) Create(context.Context, *models.Team) error { return nil }
func (m *mockTeamStore) GetByID(context.Context, string) (*models.Team, error) { return nil, nil }
func (m *mockTeamStore) Update(context.Context, string, map[string]interface{}) error { return nil }
func (m *mockTeamStore) Delete(context.Context, string) error { return nil }
func (m *mockTeamStore) List(context.Context) ([]models.Team, error) { return nil, nil }
func (m *mockTeamStore) ListForUser(context.Context, string) ([]models.Team, error) { return nil, nil }
func (m *mockTeamStore) AddMember(context.Context, string, string, string) error { return nil }
func (m *mockTeamStore) RemoveMember(context.Context, string, string) error { return nil }
func (m *mockTeamStore) UpdateMemberRole(context.Context, string, string, string) error { return nil }
func (m *mockTeamStore) ListMembers(context.Context, string) ([]models.TeamMember, error) { return nil, nil }
func (m *mockTeamStore) GetMember(context.Context, string, string) (*models.TeamMember, error) { return nil, nil }
func (m *mockTeamStore) GetUserTeamIDs(context.Context, string) ([]string, error) { return nil, nil }
func (m *mockTeamStore) IsTeamAdmin(context.Context, string, string) (bool, error) { return false, nil }
func (m *mockTeamStore) IsMember(context.Context, string, string) (bool, error) { return false, nil }
func (m *mockTeamStore) Exists(context.Context, string) (bool, error) { return false, nil }
func (m *mockTeamStore) UpdateMemberRoleByID(context.Context, string, string, string) (int64, error) { return 0, nil }
func (m *mockTeamStore) DeleteMemberByID(context.Context, string, string) (int64, error) { return 0, nil }
func (m *mockTeamStore) ListTeamAuditActions(context.Context, string) ([]string, error) { return nil, nil }
func (m *mockTeamStore) GetFirstTeamIDForUser(context.Context, string) (string, error) { return "", nil }
func (m *mockTeamStore) AddMemberReturningID(context.Context, string, string, string) (string, error) { return "", nil }
func (m *mockTeamStore) MergeSettings(context.Context, string, string) error { return nil }
func (m *mockTeamStore) AddUserRole(context.Context, string, string, string, string) error { return nil }
func (m *mockTeamStore) RemoveUserRole(context.Context, string, string, string) error { return nil }
func (m *mockTeamStore) ListUserRoles(context.Context, string, string) ([]string, error) { return nil, nil }
func (m *mockTeamStore) GetMemberRoles(context.Context, string, string) ([]string, error) { return nil, nil }
func (m *mockTeamStore) HasRole(context.Context, string, string, string) (bool, error) { return false, nil }
func (m *mockTeamStore) RemoveAllUserRoles(context.Context, string, string) error { return nil }
func (m *mockTeamStore) AddRoleToCatalog(context.Context, string, string, string) error { return nil }
func (m *mockTeamStore) ListRoleCatalog(context.Context, string) ([]store.TeamRoleCatalogEntry, error) { return nil, nil }
func (m *mockTeamStore) RemoveRoleCatalogBySource(context.Context, string, string) error { return nil }
// ── mock PackageStore ────────────────────────────────────────
// mockPackageStore implements store.PackageStore with in-memory data.
@@ -353,136 +293,3 @@ func TestHandler_EarlyAuthShortCircuit(t *testing.T) {
t.Errorf("expected redirect to /login, got %s", loc)
}
}
// ── v0.9.9 — role-based surface access ──────────────────────
// testRouterWithTeams builds a router whose Engine has a mock TeamStore.
// The optAuth middleware also checks X-Admin header to set is_admin.
func testRouterWithTeams(t *testing.T, teams *mockTeamStore, pkgs ...*store.PackageRegistration) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
engine := &Engine{
cfg: &config.Config{BasePath: ""},
stores: store.Stores{Packages: newMockPackageStore(pkgs...), Teams: teams},
loaders: make(map[string]DataLoaderFunc),
}
engine.parseTemplates()
engine.registerCoreSurfaces()
r := gin.New()
optAuth := func(c *gin.Context) {
if uid := c.GetHeader("X-User-ID"); uid != "" {
c.Set("user_id", uid)
c.Set("role", "user")
}
if c.GetHeader("X-Admin") == "true" {
c.Set("is_admin", true)
}
c.Next()
}
apiAuth := func(c *gin.Context) { c.Next() }
apiHandler := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
engine.RegisterExtensionRoutes(r.Group(""), optAuth, apiAuth, apiHandler)
return r
}
func TestHandler_RoleAccess_Granted(t *testing.T) {
teams := newMockTeamStore()
teams.addRole("user-1", "approver")
pkg := &store.PackageRegistration{
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
Enabled: true, Status: "active",
Manifest: map[string]any{
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
},
},
}
r := testRouterWithTeams(t, teams, pkg)
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-1")
if w.Code != http.StatusOK {
t.Errorf("expected 200 for user with role, got %d", w.Code)
}
}
func TestHandler_RoleAccess_Denied(t *testing.T) {
teams := newMockTeamStore()
// user-2 has no roles
pkg := &store.PackageRegistration{
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
Enabled: true, Status: "active",
Manifest: map[string]any{
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
},
},
}
r := testRouterWithTeams(t, teams, pkg)
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-2")
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for user without role, got %d", w.Code)
}
}
func TestHandler_RoleAccess_Unauthenticated(t *testing.T) {
teams := newMockTeamStore()
pkg := &store.PackageRegistration{
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
Enabled: true, Status: "active",
Manifest: map[string]any{
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
},
},
}
r := testRouterWithTeams(t, teams, pkg)
w := doGet(r, "/s/role-pkg") // no auth
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("expected 307 redirect, got %d", w.Code)
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/login") {
t.Errorf("expected redirect to /login, got %s", loc)
}
}
func TestHandler_RoleAccess_AdminBypass(t *testing.T) {
teams := newMockTeamStore()
// admin-user has no "approver" role but is_admin=true
pkg := &store.PackageRegistration{
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
Enabled: true, Status: "active",
Manifest: map[string]any{
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
},
},
}
r := testRouterWithTeams(t, teams, pkg)
w := doGet(r, "/s/role-pkg", "X-User-ID", "admin-user", "X-Admin", "true")
if w.Code != http.StatusOK {
t.Errorf("expected 200 for admin bypass, got %d", w.Code)
}
}
func TestHandler_RoleAccess_NilTeamStore(t *testing.T) {
// Engine with no team store — role access should deny gracefully
pkg := &store.PackageRegistration{
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
Enabled: true, Status: "active",
Manifest: map[string]any{
"surfaces": []any{
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
},
},
}
r := testRouter(t, pkg) // testRouter uses nil Teams store
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-1")
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 when Teams store is nil, got %d", w.Code)
}
}

View File

@@ -1,141 +0,0 @@
package pages
import (
"context"
"testing"
"armature/store"
)
func TestResolvePanels_HappyPath(t *testing.T) {
// Provider package with a panels map
provider := &store.PackageRegistration{
ID: "notes",
Title: "Notes",
Enabled: true,
Source: "extension",
Manifest: map[string]any{
"panels": map[string]any{
"reference": map[string]any{
"entry": "js/panels/reference.js",
"title": "Notes Reference",
"icon": "📝",
"description": "Searchable note list",
"min_width": float64(280),
"default_width": float64(400),
},
},
},
}
// Consumer package that wants notes.reference
consumer := &store.PackageRegistration{
ID: "chat",
Title: "Chat",
Enabled: true,
Source: "extension",
Manifest: map[string]any{
"panels": []any{"notes.reference"},
},
}
e := testEngine(t, provider, consumer)
panels := e.resolvePanels(context.Background(), consumer.Manifest)
if len(panels) != 1 {
t.Fatalf("expected 1 panel, got %d", len(panels))
}
p := panels[0]
if p.PanelID != "notes.reference" {
t.Errorf("expected panel_id 'notes.reference', got %q", p.PanelID)
}
if p.PackageID != "notes" {
t.Errorf("expected package_id 'notes', got %q", p.PackageID)
}
if p.Entry != "js/panels/reference.js" {
t.Errorf("expected entry 'js/panels/reference.js', got %q", p.Entry)
}
if p.Title != "Notes Reference" {
t.Errorf("expected title 'Notes Reference', got %q", p.Title)
}
if p.Icon != "📝" {
t.Errorf("expected icon '📝', got %q", p.Icon)
}
if p.MinWidth != 280 {
t.Errorf("expected min_width 280, got %d", p.MinWidth)
}
if p.DefaultWidth != 400 {
t.Errorf("expected default_width 400, got %d", p.DefaultWidth)
}
}
func TestResolvePanels_ProviderMissing(t *testing.T) {
// Consumer references a package that doesn't exist
consumer := &store.PackageRegistration{
ID: "chat",
Title: "Chat",
Enabled: true,
Source: "extension",
Manifest: map[string]any{
"panels": []any{"nonexistent.panel"},
},
}
e := testEngine(t, consumer)
panels := e.resolvePanels(context.Background(), consumer.Manifest)
if len(panels) != 0 {
t.Errorf("expected 0 panels for missing provider, got %d", len(panels))
}
}
func TestResolvePanels_ProviderDisabled(t *testing.T) {
provider := &store.PackageRegistration{
ID: "notes",
Title: "Notes",
Enabled: false, // disabled
Source: "extension",
Manifest: map[string]any{
"panels": map[string]any{
"reference": map[string]any{
"entry": "js/panels/reference.js",
"title": "Notes Reference",
},
},
},
}
consumer := &store.PackageRegistration{
ID: "chat",
Title: "Chat",
Enabled: true,
Source: "extension",
Manifest: map[string]any{
"panels": []any{"notes.reference"},
},
}
e := testEngine(t, provider, consumer)
panels := e.resolvePanels(context.Background(), consumer.Manifest)
if len(panels) != 0 {
t.Errorf("expected 0 panels for disabled provider, got %d", len(panels))
}
}
func TestResolvePanels_NoPanelsField(t *testing.T) {
consumer := &store.PackageRegistration{
ID: "chat",
Title: "Chat",
Enabled: true,
Source: "extension",
Manifest: map[string]any{},
}
e := testEngine(t, consumer)
panels := e.resolvePanels(context.Background(), consumer.Manifest)
if panels != nil {
t.Errorf("expected nil panels for manifest without panels field, got %v", panels)
}
}

View File

@@ -124,7 +124,6 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
{{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}}
{{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}}
{{if .Panels}}window.__PANELS__ = {{.Panels | toJSON}};{{end}}
</script>
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.

View File

@@ -206,9 +206,6 @@ type TeamStore interface {
// HasRole checks whether a user holds a specific role (primary or additional).
HasRole(ctx context.Context, teamID, userID, role string) (bool, error)
// HasRoleInAnyTeam checks whether a user holds a specific role in any team.
HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error)
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error

View File

@@ -388,17 +388,6 @@ func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (b
return exists, err
}
func (s *TeamStore) HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM team_members WHERE user_id = $1 AND role = $2
UNION ALL
SELECT 1 FROM team_user_roles WHERE user_id = $1 AND role = $2
)`, userID, role).Scan(&exists)
return exists, err
}
func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2`,

View File

@@ -395,17 +395,6 @@ func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (b
return count > 0, err
}
func (s *TeamStore) HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM team_members WHERE user_id = ? AND role = ?
UNION ALL
SELECT 1 FROM team_user_roles WHERE user_id = ? AND role = ?
) LIMIT 1`, userID, role, userID, role).Scan(&count)
return count > 0, err
}
func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`,

View File

@@ -27,7 +27,6 @@ import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js';
import { createTesting } from './testing.js';
import { createForms } from './forms.js';
import { createPanels } from './panels.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -130,9 +129,6 @@ export async function boot() {
// Forms — typed form rendering + validation (v0.9.5)
sw.forms = createForms(restClient);
// Panels — composable companion views (v0.10.0)
sw.panels = createPanels(events.emit.bind(events));
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
@@ -243,18 +239,11 @@ export async function boot() {
}
// Marker for idempotency
sw._sdk = '0.10.0';
sw._sdk = '0.9.5';
// 8. Expose globally
window.sw = sw;
// 8b. Register panels from server-resolved metadata
if (window.__PANELS__) {
for (const meta of window.__PANELS__) {
sw.panels._register(meta.panel_id, meta);
}
}
// 9. Boot sequence
theme.init();

View File

@@ -1,170 +0,0 @@
// ==========================================
// Armature — SDK: Panel System (v0.10.0)
// ==========================================
// Composable companion views that surfaces can
// pull in from other packages.
//
// Factory: createPanels(emitFn)
// ==========================================
/**
* Create the panels registry and lifecycle manager.
*
* @param {Function} emitFn — events.emit for change notifications
* @returns {object} panels
*/
export function createPanels(emitFn) {
/** @type {Map<string, object>} panelId → manifest meta */
const _registry = new Map();
/** @type {Map<string, {el: HTMLElement, cleanup: Function}>} panelId → active state */
const _active = new Map();
/** @type {Map<string, object>} panelId → cached JS module */
const _modules = new Map();
return {
/**
* Register a panel from resolved manifest data. Called by the
* kernel during surface boot — not by package code directly.
*
* @param {string} panelId — e.g. 'notes.reference'
* @param {object} meta — PanelMeta from window.__PANELS__
*/
_register(panelId, meta) {
_registry.set(panelId, meta);
},
/**
* Open a panel. Lazy-loads the JS entry if not yet loaded,
* creates an unstyled container, and calls mount().
*
* @param {string} panelId — e.g. 'notes.reference'
* @param {object} [opts]
* @param {string} [opts.mode] — presentation hint (ignored in v0.10.0)
* @param {object} [opts.params] — passed to mount(el, ctx) as ctx.params
* @returns {Promise<boolean>} — false if panel not available
*/
async open(panelId, opts = {}) {
if (!_registry.has(panelId)) return false;
if (_active.has(panelId)) return true; // already open
const meta = _registry.get(panelId);
// Lazy-load the panel JS module
if (!_modules.has(panelId)) {
try {
const base = window.__BASE__ || '';
const url = `${base}/surfaces/${meta.package_id}/${meta.entry}`;
const mod = await import(url);
_modules.set(panelId, mod);
} catch (err) {
console.error(`[sw.panels] Failed to load ${panelId}:`, err);
return false;
}
}
const mod = _modules.get(panelId);
if (typeof mod.mount !== 'function') {
console.error(`[sw.panels] ${panelId} does not export mount()`);
return false;
}
// Create unstyled container (v0.10.0 — no chrome)
const el = document.createElement('div');
el.className = 'sw-panel-container';
el.dataset.panelId = panelId;
el.style.cssText = 'position:fixed;top:80px;right:16px;width:400px;height:350px;background:var(--bg-surface,#fff);border:1px solid var(--border,#ccc);border-radius:8px;overflow:auto;z-index:100;';
document.body.appendChild(el);
// Mount
const ctx = {
sw: window.sw,
params: opts.params || {},
panelId,
close: () => this.close(panelId),
resize: () => {}, // no-op in v0.10.0
};
let cleanup;
try {
cleanup = mod.mount(el, ctx);
} catch (err) {
console.error(`[sw.panels] mount() failed for ${panelId}:`, err);
el.remove();
return false;
}
_active.set(panelId, { el, cleanup: typeof cleanup === 'function' ? cleanup : () => {} });
emitFn('panels.opened', { panelId, mode: opts.mode || 'plain' }, { localOnly: true });
return true;
},
/**
* Close a panel. Calls cleanup, removes container from DOM.
*
* @param {string} panelId
*/
close(panelId) {
const entry = _active.get(panelId);
if (!entry) return;
try {
entry.cleanup();
} catch (err) {
console.warn(`[sw.panels] cleanup error for ${panelId}:`, err);
}
entry.el.remove();
_active.delete(panelId);
emitFn('panels.closed', { panelId }, { localOnly: true });
},
/**
* Toggle a panel open/closed.
*
* @param {string} panelId
* @param {object} [opts] — passed to open() if opening
* @returns {Promise<boolean>}
*/
async toggle(panelId, opts = {}) {
if (_active.has(panelId)) {
this.close(panelId);
return true;
}
return this.open(panelId, opts);
},
/**
* Check if a panel is currently open.
* @param {string} panelId
* @returns {boolean}
*/
isOpen(panelId) {
return _active.has(panelId);
},
/**
* Check if a panel is available (provider installed + enabled).
* @param {string} panelId
* @returns {boolean}
*/
isAvailable(panelId) {
return _registry.has(panelId);
},
/**
* List available panel IDs for the current surface.
* @returns {string[]}
*/
list() {
return [..._registry.keys()];
},
/**
* List currently open panel IDs.
* @returns {string[]}
*/
active() {
return [..._active.keys()];
},
};
}