3 Commits

Author SHA1 Message Date
33278d0d69 Feat v0.10.0 panel manifest lifecycle (#84)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m50s
CI/CD / test-sqlite (push) Successful in 3m9s
CI/CD / build-and-deploy (push) Successful in 1m1s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 21:22:30 +00:00
414b2290ce Feat v0.9.9 surface access roles (#83)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 2m59s
CI/CD / build-and-deploy (push) Successful in 1m28s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 20:06:25 +00:00
b0e9dd7f80 Feat v0.9.8 routing sdk (#82)
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Failing after 2m39s
CI/CD / test-sqlite (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 19:39:21 +00:00
20 changed files with 1366 additions and 80 deletions

View File

@@ -2,6 +2,96 @@
All notable changes to Armature are documented here. 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
module available to all extensions — no permission required.
**New Starlark module: `routing`**
- `routing.evaluate(rules, data)` — evaluates an ordered list of
condition rules against a data dict; returns the first matching
rule's `target` string, or `None` if no rule matches
- 10 operators: `exists`, `not_exists`, `eq`, `neq`, `gt`, `lt`,
`gte`, `lte`, `in`, `contains`
- First-match-wins semantics
- Domain-agnostic: uses `target` (not `target_stage`) so any
extension can use it for feature flags, content routing, approval
logic, etc.
- Always available — pure computation, no I/O, no permission gate
**Tests:** 8 new unit tests covering all operators, type coercion,
first-match-wins, empty/missing/bad input
## v0.9.7 — Full Read/Write Workflow Starlark Module ## v0.9.7 — Full Read/Write Workflow Starlark Module
Extensions with `workflow.access` permission can now start, advance, Extensions with `workflow.access` permission can now start, advance,

View File

@@ -1,7 +1,5 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages
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
is an extension. is an extension.
@@ -73,92 +71,37 @@ All completed work is documented in `CHANGELOG.md`.
--- ---
## Planned
### v0.9.x — Multi-Surface Packages + Workflow Redesign ### v0.9.x — Multi-Surface Packages + Workflow Redesign
**v0.9.0 — Multi-Surface Packages** *(completed)* | Version | Title |
|---------|-------|
Packages declare a `surfaces` array with per-path access controls, | v0.9.0 | Multi-Surface Packages |
titles, and layouts. Unified route tree dispatches between surface | v0.9.1 | Server-Side Sub-Path Routing |
rendering and ext API calls. `sw.navigate()` for client-side sub-path | v0.9.2 | Converter Consolidation |
routing. Design doc: `docs/DESIGN-multi-surface.md`. | v0.9.3 | Team User Roles |
| v0.9.4 | Package Adoption + Roles |
**v0.9.1 — Server-Side Sub-Path Routing** *(completed)* | v0.9.5 | Typed Forms SDK |
| v0.9.6 | Stage Mode Collapse |
Consolidated root and catch-all route handlers into a unified dispatcher. | v0.9.7 | Workflow Starlark Write Ops |
Added `aggregateAccess()` for early auth short-circuit on all-authenticated | v0.9.8 | Routing SDK Primitive |
packages. SDK seeds initial history state for back-button resilience. | v0.9.9 | Surface Access via Roles |
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**
Expose `routing.evaluate(rules, data)` as a Starlark SDK function.
Branch rules become a reusable decision engine for any extension.
**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.
--- ---
### v0.10.x — Panels + Composable Layout ## Current: v0.10.x — Panels + Composable Layout
Panels are a new kernel rendering tier between surfaces (full-page) and Panels are a new kernel rendering tier between surfaces (full-page) and
block renderers (inline). They solve composable companion views — e.g., block renderers (inline). They solve composable companion views — e.g.,
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`. 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 | | Version | Title |
|---------|-------| |---------|-------|
| v0.10.0 | Panel Manifest + Lifecycle |
| v0.10.1 | FloatingPanel Primitive | | v0.10.1 | FloatingPanel Primitive |
| v0.10.2 | Docked Panels + Mode Transitions | | v0.10.2 | Docked Panels + Mode Transitions |
| v0.10.3 | Panel Communication Patterns | | v0.10.3 | Panel Communication Patterns |

View File

@@ -1 +1 @@
0.9.7 0.10.0

View File

@@ -84,6 +84,24 @@ Returns `True` if the user has the permission, `False` otherwise (including
when the user is not found). Resolves the user's groups and merges granted when the user is not found). Resolves the user's groups and merges granted
permissions — works for both kernel and extension-declared permissions. permissions — works for both kernel and extension-declared permissions.
### routing
Generic rule-based decision engine. Evaluates an ordered list of conditions
against a data dict, returning the first matching rule's target string.
```python
result = routing.evaluate([
{"field": "priority", "op": "eq", "value": "critical", "target": "escalation"},
{"field": "amount", "op": "gt", "value": 10000, "target": "manager_review"},
{"field": "region", "op": "in", "value": ["EU", "UK"], "target": "gdpr_flow"},
], stage_data)
# Returns "escalation", "manager_review", "gdpr_flow", or None
```
Each rule is a dict with `field`, `op`, `value`, and `target`. Operators:
`exists`, `not_exists`, `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`,
`contains`. First-match-wins; returns `None` if no rule matches.
## Permission-gated modules ## Permission-gated modules
These modules are only available if the package has the corresponding These modules are only available if the package has the corresponding

View File

@@ -38,6 +38,8 @@ type ManifestInfo struct {
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) 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 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 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, // ValidateManifest parses a manifest map and validates all required fields,
@@ -199,6 +201,44 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
info.HasFormTemplate = true 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) info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ──────────────────────────────── // ── Type-specific constraints ────────────────────────────────
@@ -259,6 +299,8 @@ func validAccessLevels(access string) bool {
return true return true
case strings.HasPrefix(access, "group:"): case strings.HasPrefix(access, "group:"):
return strings.TrimPrefix(access, "group:") != "" return strings.TrimPrefix(access, "group:") != ""
case strings.HasPrefix(access, "role:"):
return strings.TrimPrefix(access, "role:") != ""
default: default:
return false return false
} }

View File

@@ -349,3 +349,100 @@ func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) {
t.Errorf("expected layout 'editor', got %q", s["layout"]) 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,6 +349,22 @@ 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{ resp := gin.H{
"id": pkgID, "id": pkgID,
"title": mInfo.Title, "title": mInfo.Title,

View File

@@ -262,6 +262,93 @@ 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 ────────────────────────────────── // ── helpers ──────────────────────────────────
func seedRoleUser(t *testing.T, username, email string) string { func seedRoleUser(t *testing.T, username, email string) string {

View File

@@ -61,6 +61,20 @@ type SurfaceManifest struct {
Source string `json:"source"` // "core" or "extension" 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. // BannerConfig holds environment banner settings.
type BannerConfig struct { type BannerConfig struct {
Text string `json:"text"` Text string `json:"text"`
@@ -109,6 +123,8 @@ type PageData struct {
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection) 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 InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name Tagline string // branding: tagline under instance name
@@ -237,6 +253,72 @@ func (e *Engine) browserExtensionIDs() []string {
return ids 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. // UserContext is the authenticated user's info available to templates.
type UserContext struct { type UserContext struct {
ID string `json:"id"` ID string `json:"id"`
@@ -522,7 +604,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
} }
// ── Access check ───────────────────────────────────────── // ── Access check ─────────────────────────────────────────
if !evaluateAccess(c, surfaceAccess) { if !e.evaluateAccess(c, surfaceAccess) {
// Redirect unauthenticated users to login; deny others with 403 // Redirect unauthenticated users to login; deny others with 403
if c.GetString("user_id") == "" { if c.GetString("user_id") == "" {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login") c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
@@ -552,6 +634,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
EnabledSurfaces: e.EnabledSurfaceIDs(), EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(), ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(), BrowserExtensions: e.browserExtensionIDs(),
Panels: e.resolvePanels(c.Request.Context(), sr.Manifest),
}) })
} }
} }
@@ -651,7 +734,7 @@ func aggregateAccess(surfaces []any) string {
} }
// evaluateAccess checks whether the current request meets an access requirement. // evaluateAccess checks whether the current request meets an access requirement.
func evaluateAccess(c *gin.Context, access string) bool { func (e *Engine) evaluateAccess(c *gin.Context, access string) bool {
switch { switch {
case access == "public": case access == "public":
return true return true
@@ -672,6 +755,20 @@ func evaluateAccess(c *gin.Context, access string) bool {
} }
} }
return false 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: default:
return false return false
} }

View File

@@ -11,9 +11,69 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"armature/config" "armature/config"
"armature/models"
"armature/store" "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 ──────────────────────────────────────── // ── mock PackageStore ────────────────────────────────────────
// mockPackageStore implements store.PackageStore with in-memory data. // mockPackageStore implements store.PackageStore with in-memory data.
@@ -293,3 +353,136 @@ func TestHandler_EarlyAuthShortCircuit(t *testing.T) {
t.Errorf("expected redirect to /login, got %s", loc) 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)
}
}

141
server/pages/panels_test.go Normal file
View File

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

View File

@@ -0,0 +1,190 @@
package sandbox
// routing_module.go
//
// Starlark routing module — a generic rule-based decision engine.
// Always available (pure computation, no I/O).
//
// Starlark API:
// result = routing.evaluate(rules, data)
// # result → "target_string" or None
import (
"fmt"
"strings"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)
// BuildRoutingModule creates the "routing" Starlark module.
// No permission required — pure computation.
func BuildRoutingModule() *starlarkstruct.Module {
return MakeModule("routing", starlark.StringDict{
"evaluate": starlark.NewBuiltin("routing.evaluate", routingEvaluateBuiltin()),
})
}
// routingRule is the generic equivalent of workflow.Condition.
// Uses "target" instead of "target_stage" to be domain-agnostic.
type routingRule struct {
Field string
Op string
Value any
Target string
}
func routingEvaluateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var rulesVal, dataVal starlark.Value
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &rulesVal, &dataVal); err != nil {
return nil, err
}
// Convert rules list → []routingRule
rulesList, ok := rulesVal.(*starlark.List)
if !ok {
return nil, fmt.Errorf("routing.evaluate: rules must be a list, got %s", rulesVal.Type())
}
rules := make([]routingRule, rulesList.Len())
for i := 0; i < rulesList.Len(); i++ {
ruleDict, ok := rulesList.Index(i).(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("routing.evaluate: rules[%d] must be a dict, got %s", i, rulesList.Index(i).Type())
}
m := DictToMap(ruleDict)
field, _ := m["field"].(string)
op, _ := m["op"].(string)
target, _ := m["target"].(string)
if field == "" || op == "" || target == "" {
return nil, fmt.Errorf("routing.evaluate: rules[%d] must have field, op, and target", i)
}
rules[i] = routingRule{
Field: field,
Op: op,
Value: m["value"],
Target: target,
}
}
// Convert data dict → Go map
dataDict, ok := dataVal.(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("routing.evaluate: data must be a dict, got %s", dataVal.Type())
}
data := DictToMap(dataDict)
// Evaluate rules — first match wins
for _, rule := range rules {
if evaluateRoutingCondition(rule, data) {
return starlark.String(rule.Target), nil
}
}
return starlark.None, nil
}
}
// ── Evaluation helpers (mirrored from workflow/routing.go) ─────────
//
// These are pure functions copied from the workflow package to avoid
// a sandbox → workflow import cycle. The workflow package continues
// using its own copy for ResolveNextStage.
// evaluateRoutingCondition checks if a single rule matches against data.
func evaluateRoutingCondition(rule routingRule, data map[string]any) bool {
val, exists := data[rule.Field]
switch rule.Op {
case "exists":
return exists
case "not_exists":
return !exists
}
if !exists {
return false
}
switch rule.Op {
case "eq":
return routingCompareEq(val, rule.Value)
case "neq":
return !routingCompareEq(val, rule.Value)
case "gt":
return routingCompareNum(val, rule.Value) > 0
case "lt":
return routingCompareNum(val, rule.Value) < 0
case "gte":
return routingCompareNum(val, rule.Value) >= 0
case "lte":
return routingCompareNum(val, rule.Value) <= 0
case "in":
return routingCompareIn(val, rule.Value)
case "contains":
return routingCompareContains(val, rule.Value)
default:
return false
}
}
func routingCompareEq(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
func routingCompareNum(a, b any) int {
af := routingToFloat(a)
bf := routingToFloat(b)
if af == nil || bf == nil {
return 0
}
switch {
case *af < *bf:
return -1
case *af > *bf:
return 1
default:
return 0
}
}
func routingToFloat(v any) *float64 {
switch n := v.(type) {
case float64:
return &n
case int:
f := float64(n)
return &f
case int64:
f := float64(n)
return &f
case string:
var f float64
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
return &f
}
}
return nil
}
func routingCompareIn(val, list any) bool {
arr, ok := list.([]any)
if !ok {
return false
}
vs := fmt.Sprintf("%v", val)
for _, item := range arr {
if fmt.Sprintf("%v", item) == vs {
return true
}
}
return false
}
func routingCompareContains(val, target any) bool {
s := fmt.Sprintf("%v", val)
t := fmt.Sprintf("%v", target)
return strings.Contains(s, t)
}

View File

@@ -0,0 +1,162 @@
package sandbox
import (
"testing"
"go.starlark.net/starlark"
)
func runRoutingScript(t *testing.T, script string) starlark.StringDict {
t.Helper()
mod := BuildRoutingModule()
predeclared := starlark.StringDict{"routing": mod}
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
if err != nil {
t.Fatal(err)
}
return globals
}
func runRoutingScriptErr(t *testing.T, script string) error {
t.Helper()
mod := BuildRoutingModule()
predeclared := starlark.StringDict{"routing": mod}
_, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
return err
}
func TestRoutingEvaluate_SingleMatch(t *testing.T) {
globals := runRoutingScript(t, `
result = routing.evaluate([
{"field": "priority", "op": "eq", "value": "high", "target": "escalation"},
], {"priority": "high"})
`)
result := globals["result"]
if s, ok := result.(starlark.String); !ok || string(s) != "escalation" {
t.Fatalf("expected 'escalation', got %v", result)
}
}
func TestRoutingEvaluate_FirstMatchWins(t *testing.T) {
globals := runRoutingScript(t, `
result = routing.evaluate([
{"field": "amount", "op": "gt", "value": 100, "target": "big"},
{"field": "amount", "op": "gt", "value": 50, "target": "medium"},
], {"amount": 200})
`)
result := globals["result"]
if s, ok := result.(starlark.String); !ok || string(s) != "big" {
t.Fatalf("expected 'big', got %v", result)
}
}
func TestRoutingEvaluate_NoMatch(t *testing.T) {
globals := runRoutingScript(t, `
result = routing.evaluate([
{"field": "status", "op": "eq", "value": "done", "target": "finish"},
], {"status": "pending"})
`)
if globals["result"] != starlark.None {
t.Fatalf("expected None, got %v", globals["result"])
}
}
func TestRoutingEvaluate_EmptyRules(t *testing.T) {
globals := runRoutingScript(t, `
result = routing.evaluate([], {"x": 1})
`)
if globals["result"] != starlark.None {
t.Fatalf("expected None, got %v", globals["result"])
}
}
func TestRoutingEvaluate_AllOperators(t *testing.T) {
globals := runRoutingScript(t, `
# exists
r1 = routing.evaluate([{"field": "x", "op": "exists", "value": "", "target": "yes"}], {"x": 1})
# not_exists
r2 = routing.evaluate([{"field": "x", "op": "not_exists", "value": "", "target": "yes"}], {"y": 1})
# eq
r3 = routing.evaluate([{"field": "s", "op": "eq", "value": "abc", "target": "yes"}], {"s": "abc"})
# neq
r4 = routing.evaluate([{"field": "s", "op": "neq", "value": "abc", "target": "yes"}], {"s": "xyz"})
# gt
r5 = routing.evaluate([{"field": "n", "op": "gt", "value": 10, "target": "yes"}], {"n": 20})
# lt
r6 = routing.evaluate([{"field": "n", "op": "lt", "value": 10, "target": "yes"}], {"n": 5})
# gte
r7 = routing.evaluate([{"field": "n", "op": "gte", "value": 10, "target": "yes"}], {"n": 10})
# lte
r8 = routing.evaluate([{"field": "n", "op": "lte", "value": 10, "target": "yes"}], {"n": 10})
# in
r9 = routing.evaluate([{"field": "s", "op": "in", "value": ["a", "b", "c"], "target": "yes"}], {"s": "b"})
# contains
r10 = routing.evaluate([{"field": "s", "op": "contains", "value": "ell", "target": "yes"}], {"s": "hello"})
`)
for _, name := range []string{"r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10"} {
v := globals[name]
if s, ok := v.(starlark.String); !ok || string(s) != "yes" {
t.Errorf("%s: expected 'yes', got %v", name, v)
}
}
}
func TestRoutingEvaluate_TypeCoercion(t *testing.T) {
globals := runRoutingScript(t, `
# Numeric string vs int
r1 = routing.evaluate([
{"field": "n", "op": "gt", "value": 10, "target": "yes"},
], {"n": "20"})
# Int equality via string normalization
r2 = routing.evaluate([
{"field": "n", "op": "eq", "value": 42, "target": "yes"},
], {"n": 42})
`)
for _, name := range []string{"r1", "r2"} {
v := globals[name]
if s, ok := v.(starlark.String); !ok || string(s) != "yes" {
t.Errorf("%s: expected 'yes', got %v", name, v)
}
}
}
func TestRoutingEvaluate_BadInput(t *testing.T) {
// rules must be a list
err1 := runRoutingScriptErr(t, `routing.evaluate("bad", {})`)
if err1 == nil {
t.Fatal("expected error for non-list rules")
}
// data must be a dict
err2 := runRoutingScriptErr(t, `routing.evaluate([], "bad")`)
if err2 == nil {
t.Fatal("expected error for non-dict data")
}
// rule missing required fields
err3 := runRoutingScriptErr(t, `routing.evaluate([{"field": "x"}], {})`)
if err3 == nil {
t.Fatal("expected error for incomplete rule")
}
}
func TestRoutingEvaluate_MissingFields(t *testing.T) {
globals := runRoutingScript(t, `
# Field not in data — eq should not match
r1 = routing.evaluate([
{"field": "absent", "op": "eq", "value": "x", "target": "bad"},
], {"other": "y"})
# not_exists matches when field is absent
r2 = routing.evaluate([
{"field": "absent", "op": "not_exists", "value": "", "target": "good"},
], {"other": "y"})
`)
if globals["r1"] != starlark.None {
t.Fatalf("r1: expected None for missing field, got %v", globals["r1"])
}
if s, ok := globals["r2"].(starlark.String); !ok || string(s) != "good" {
t.Fatalf("r2: expected 'good', got %v", globals["r2"])
}
}

View File

@@ -467,6 +467,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
// Always available — read-only team role queries // Always available — read-only team role queries
modules["teams"] = BuildTeamsModule(ctx, r.stores) modules["teams"] = BuildTeamsModule(ctx, r.stores)
// Always available — pure-computation routing decision engine
modules["routing"] = BuildRoutingModule()
// Allows any starlark package to load declared library dependencies. // Allows any starlark package to load declared library dependencies.
if lc != nil { if lc != nil {
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc) modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)

View File

@@ -206,6 +206,9 @@ type TeamStore interface {
// HasRole checks whether a user holds a specific role (primary or additional). // HasRole checks whether a user holds a specific role (primary or additional).
HasRole(ctx context.Context, teamID, userID, role string) (bool, error) 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 deletes all additional roles for a member (cleanup on removal).
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error RemoveAllUserRoles(ctx context.Context, teamID, userID string) error

View File

@@ -388,6 +388,17 @@ func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (b
return exists, err 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 { func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx, _, err := DB.ExecContext(ctx,
`DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2`, `DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2`,

View File

@@ -395,6 +395,17 @@ func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (b
return count > 0, err 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 { func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx, _, err := DB.ExecContext(ctx,
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`, `DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`,

View File

@@ -27,6 +27,7 @@ import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js'; import { createUsers } from './users.js';
import { createTesting } from './testing.js'; import { createTesting } from './testing.js';
import { createForms } from './forms.js'; import { createForms } from './forms.js';
import { createPanels } from './panels.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -129,6 +130,9 @@ export async function boot() {
// Forms — typed form rendering + validation (v0.9.5) // Forms — typed form rendering + validation (v0.9.5)
sw.forms = createForms(restClient); 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 // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
@@ -239,11 +243,18 @@ export async function boot() {
} }
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.9.5'; sw._sdk = '0.10.0';
// 8. Expose globally // 8. Expose globally
window.sw = sw; 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 // 9. Boot sequence
theme.init(); theme.init();

170
src/js/sw/sdk/panels.js Normal file
View File

@@ -0,0 +1,170 @@
// ==========================================
// 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()];
},
};
}