From 5ad6d77c5647678d438040e9c58dd63b6999dd6b Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 12:40:47 +0000 Subject: [PATCH] Feat v0.9.0 multi surface packages (#73) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 72 ++++++ ROADMAP.md | 52 ++--- VERSION | 2 +- docs/MULTI-SURFACE-GUIDE.md | 201 ++++++++++++++++ docs/PACKAGE-FORMAT.md | 72 +++++- server/handlers/package_validate.go | 73 +++++- server/handlers/package_validate_test.go | 151 ++++++++++++ server/main.go | 12 +- server/pages/pages.go | 285 ++++++++++++++++++++--- server/pages/pages_surface_match_test.go | 175 ++++++++++++++ server/pages/templates/base.html | 2 + src/js/sw/sdk/index.js | 40 +++- 12 files changed, 1075 insertions(+), 62 deletions(-) create mode 100644 docs/MULTI-SURFACE-GUIDE.md create mode 100644 server/pages/pages_surface_match_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bdec59e..6c846c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,78 @@ All notable changes to Armature are documented here. +## v0.9.0 — Multi-Surface Packages + +Packages can now declare multiple surfaces — each with its own path, access +level, title, and layout. A single package can serve a public submission +form, an authenticated dashboard, and an admin settings page. The kernel +resolves incoming requests to the correct surface and enforces access +server-side. + +**Manifest: `surfaces` array** + +- Packages declare a `surfaces` array with entries like + `{ "path": "/submit", "access": "public", "title": "Report a Bug" }`. +- Each surface has independent `path`, `access`, `title`, `layout`, and + `nav` fields. Package-level `auth` and `layout` serve as defaults. +- Supported access levels: `public`, `authenticated`, `admin`, `group:{name}`. +- Packages without `surfaces` get auto-synthesis from legacy `auth`/`layout` + fields — no existing packages break. + +**Kernel: unified route tree** + +- Surface handler and ext API handler share a single `/s/:slug` route tree + via `RegisterExtensionRoutes`. The dispatcher checks the path prefix: + `/api/*` → ext API handler with JWT auth; everything else → surface + handler with per-surface access checks. +- `matchSurface()` resolves request paths against surface patterns with + Gin-style `:param` support. Static segments preferred over params. +- `evaluateAccess()` checks access requirements per-surface, with login + redirect for unauthenticated users and 403 for insufficient permissions. + +**Frontend: `__SURFACE_PATH__` + `sw.navigate()`** + +- `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__` injected into + every extension surface page. Packages use these to decide which view to + render. +- `sw.navigate(path, params)` for SPA-style intra-package routing via + `pushState`. Emits `surface.navigate` events. Handles back/forward via + `popstate`. +- SDK version bumped to `0.9.0`. + +**Navigation** + +- `extensionNavItems` reads the `surfaces` array to find the nav entry + (first `nav: true`, or root `/`). +- Fix: packages with `status: pending_review` no longer appear in the + sidebar navigation. + +**Validation** + +- `ValidateManifest` validates `surfaces` entries: path required, must + start with `/`, no duplicates, access level must be recognized. +- Empty `surfaces` array rejected. Non-array `surfaces` rejected. + +**Tests: 22 new** + +- 11 manifest validation tests (surfaces valid/invalid, auto-synthesis, + group access, duplicate paths). +- 11 route matching tests (static paths, param extraction, specificity + ordering, nav resolution). + +**Modified files:** + +- `server/handlers/package_validate.go` — surfaces validation + auto-synthesis +- `server/handlers/package_validate_test.go` — 11 new tests +- `server/main.go` — unified extension route registration +- `server/pages/pages.go` — matchSurface, evaluateAccess, findNavSurface, + RegisterExtensionRoutes, nav status filter +- `server/pages/pages_surface_match_test.go` — 11 new tests +- `server/pages/templates/base.html` — surface path/params injection +- `src/js/sw/sdk/index.js` — sw.navigate, popstate, version bump +- `docs/PACKAGE-FORMAT.md` — surfaces field documentation +- `docs/MULTI-SURFACE-GUIDE.md` — developer guide + ## v0.8.5 — Extension Composability Extensions can now compose with each other through declared slots, UI diff --git a/ROADMAP.md b/ROADMAP.md index 1d19ea0..0af80d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,70 +75,70 @@ All completed work is documented in `CHANGELOG.md`. ## Planned -### v0.9.x — Workflow Redesign +### v0.9.x — Multi-Surface Packages + Workflow Redesign + +**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** + +Full-page refresh on sub-paths (e.g. `/s/my-pkg/monitor`) currently +requires client-side routing. This version restructures the Gin route +tree so the kernel resolves sub-paths server-side, including proper +`OptionalAuth` for packages with mixed public/authenticated surfaces. + +**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** Design doc: `docs/DESIGN-workflow-redesign.md` -The workflow system (~7,600 lines, 25+ files) was built incrementally and -has accumulated redundant concepts, buried primitives, and a read-only -Starlark module. This series cleans up the debt and promotes reusable -primitives before building reference extensions on top. - -**v0.9.0 — Starlark Converter Consolidation + Snapshot Cleanup** - Three files contain near-identical Go↔Starlark converters; three copies of the snapshot parser exist. Consolidate into `sandbox/convert.go` and one exported snapshot function. Standardize on wrapped snapshot format. -**v0.9.1 — Team User Roles** +**v0.9.3 — Team User Roles** Promote the buried role system to a kernel primitive. New `team_user_roles` table (many-to-many). Manifest `requires_roles` field. Team admin UI for role assignment. Kernel middleware `RequireRole()`. Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`. -**v0.9.2 — Package Adoption + Roles** +**v0.9.4 — Package Adoption + Roles** `scope: adoptable` manifest field. When a team adopts an adoptable package, the package's `requires_roles` auto-populate into the team's role slots. Replaces `AdoptTeamWorkflow` clone mechanism. -**v0.9.3 — Typed Forms → SDK Primitive** +**v0.9.5 — Typed Forms → SDK Primitive** Extract `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from `models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()` and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package can declare forms, not just workflow stages. -**v0.9.4 — Deprecate `stage_type`, Collapse `stage_mode`** +**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** `stage_type` (simple/dynamic/automated) is redundant with `starlark_hook` presence. Remove from new manifests, keep parsing for backward compat. Collapse `stage_mode` from 4 to 3 values: form / delegated / automated. -`review` was just `delegated` with signoff — signoff is independent of -rendering mode. -**v0.9.5 — Full Read/Write Workflow Starlark Module** +**v0.9.7 — Full Read/Write Workflow Starlark Module** Add `workflow.start()`, `workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()` to the Starlark module. Extract engine -interface to break circular import. Unlocks fully automated workflow -orchestration from Starlark hooks and extensions. +interface to break circular import. -**v0.9.6 — Conditional Routing → SDK Primitive** +**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.7 — Multi-Surface Manifest + Route Resolution** +**v0.9.9 — Surface Access via Roles** -Packages can declare multiple surface routes with independent access -controls. Kernel resolves routes per-page. Enables mixed public/auth -pages in a single package. - -**v0.9.8 — Surface Access via Roles** - -Wire team roles (v0.9.1) into surface access declarations: +Wire team roles (v0.9.3) into surface access declarations: `access: role:approver`. Kernel middleware checks role membership. Completes the workflow→package access story. diff --git a/VERSION b/VERSION index 7ada0d3..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.9.0 diff --git a/docs/MULTI-SURFACE-GUIDE.md b/docs/MULTI-SURFACE-GUIDE.md new file mode 100644 index 0000000..0b728a0 --- /dev/null +++ b/docs/MULTI-SURFACE-GUIDE.md @@ -0,0 +1,201 @@ +# Multi-Surface Packages + +A multi-surface package serves multiple pages from a single package, each +with its own URL path, access level, and layout. Before v0.9.0, a package +got one route (`/s/{id}`), one auth posture, and one layout. Now a single +package can serve a public submission form alongside an authenticated +dashboard and an admin settings page. + +## When to Use Multi-Surface + +Use multi-surface when your package has logically related pages that share +the same backend (Starlark hooks, ext API, database tables) but need: + +- **Different access levels** — public intake form + authenticated dashboard +- **Multiple views** — list view, detail view, edit view, monitor view +- **Sub-pages** — settings, admin, or debug pages within the package + +If your pages don't share backend state, use separate packages instead. + +## Manifest Setup + +Add a `surfaces` array to your manifest. Each entry declares a page: + +```json +{ + "id": "workflow-builder", + "title": "Workflow Builder", + "type": "full", + "version": "0.1.0", + "icon": "🔧", + "auth": "authenticated", + "layout": "single", + + "surfaces": [ + { "path": "/", "title": "Workflows" }, + { "path": "/new", "title": "New Workflow", "nav": false }, + { "path": "/:id/edit", "title": "Edit Workflow", "nav": false }, + { "path": "/monitor", "title": "Monitor", "nav": true }, + { "path": "/monitor/:id", "title": "Instance Detail", "nav": false } + ], + + "hooks": ["surface"], + "permissions": ["workflow.access"] +} +``` + +This generates five server routes, all under `/s/workflow-builder/`: + +| Route | Access | Nav | +|-------|--------|-----| +| `/s/workflow-builder/` | authenticated | yes | +| `/s/workflow-builder/new` | authenticated | no | +| `/s/workflow-builder/:id/edit` | authenticated | no | +| `/s/workflow-builder/monitor` | authenticated | yes | +| `/s/workflow-builder/monitor/:id` | authenticated | no | + +### Surface Entry Fields + +| Field | Default | Description | +|----------|----------------|-------------| +| `path` | **required** | URL path relative to `/s/{id}`. Supports `:param` segments. | +| `access` | package `auth` | `public`, `authenticated`, `admin`, or `group:{name}`. | +| `title` | package `title`| Label shown in nav and page title. | +| `layout` | package `layout`| `single` or `editor`. | +| `nav` | `true` for `/`, `false` otherwise | Whether this surface appears in the sidebar. | + +### Mixed Access Levels + +A package can mix public and authenticated surfaces: + +```json +{ + "id": "bug-tracker", + "auth": "authenticated", + "surfaces": [ + { "path": "/submit", "access": "public", "title": "Report a Bug" }, + { "path": "/", "title": "Dashboard" }, + { "path": "/:id", "title": "Bug Detail", "nav": false }, + { "path": "/admin", "access": "admin", "title": "Settings", "nav": false } + ] +} +``` + +The kernel enforces access per-surface. Unauthenticated visitors can reach +`/submit` but are redirected to login if they try `/` or `/:id`. + +## Frontend: Routing Within Your Package + +### Reading the Current Surface + +When your package JS loads, two globals tell you which surface was matched: + +```js +const path = window.__SURFACE_PATH__ || '/'; +const params = window.__SURFACE_PARAMS__ || {}; +``` + +Use these to decide which view to render: + +```js +function render() { + const path = window.__SURFACE_PATH__ || '/'; + const params = window.__SURFACE_PARAMS__ || {}; + + const root = document.getElementById('surface-root'); + + switch (path) { + case '/': return renderList(root); + case '/new': return renderEditor(root, null); + case '/:id/edit': return renderEditor(root, params.id); + case '/monitor': return renderMonitor(root); + case '/monitor/:id':return renderDetail(root, params.id); + default: return render404(root); + } +} + +render(); +``` + +### SPA Navigation with `sw.navigate()` + +Navigate between surfaces without a full page reload: + +```js +// Navigate to a static path +sw.navigate('/new'); + +// Navigate with params +sw.navigate('/:id/edit', { id: 'wf-42' }); + +// Navigate to monitor sub-page +sw.navigate('/monitor/:id', { id: 'inst-7' }); +``` + +`sw.navigate()` does three things: +1. Updates `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__` +2. Calls `history.pushState()` to update the URL +3. Emits a `surface.navigate` event + +### Listening for Navigation Events + +Re-render when the user navigates (including browser back/forward): + +```js +sw.on('surface.navigate', ({ path, params }) => { + window.__SURFACE_PATH__ = path; + window.__SURFACE_PARAMS__ = params; + render(); +}); +``` + +### Links Between Surfaces + +For simple `` links that do full page loads: + +```html +Monitor +``` + +For SPA-style navigation: + +```js +button.onclick = () => sw.navigate('/monitor'); +``` + +## API Routes + +All surfaces in a package share the same ext API. API calls go through +`/s/{id}/api/*` regardless of which surface is active: + +```js +// These work from any surface in the package +const items = await sw.api.get('/items'); +const item = await sw.api.get(`/items/${id}`); +await sw.api.post('/items', { title: 'New item' }); +``` + +The ext API handler checks `package.status == "active"` — if the package +is in `pending_review`, all API calls return 403. + +## Backward Compatibility + +Packages without a `surfaces` array continue to work. The kernel +synthesizes a single-entry array from the legacy `auth` and `layout` +fields: + +``` +auth: "authenticated" + layout: "single" + → surfaces: [{ "path": "/", "access": "authenticated", "layout": "single" }] +``` + +No migration is required for existing packages. + +## Constraints + +- **No cross-package routing.** Surface paths are relative to the package + mount point. A package cannot claim arbitrary top-level routes. +- **No per-surface Starlark hooks.** All surfaces share the same backend + hooks. Use the surface path in your hook logic to differentiate. +- **No SSR.** Surface rendering is client-side. The kernel serves the + shell template; your JS renders the content. diff --git a/docs/PACKAGE-FORMAT.md b/docs/PACKAGE-FORMAT.md index 1e57a40..d6c6fd9 100644 --- a/docs/PACKAGE-FORMAT.md +++ b/docs/PACKAGE-FORMAT.md @@ -60,9 +60,10 @@ Only `manifest.json` is required. All other directories are optional and include | `description` | Short description | | `icon` | Emoji for sidebar/menu display | | `author` | Package author | -| `route` | URL path for surfaces (e.g., `/s/my-package`) | -| `auth` | `authenticated` or `public` | -| `layout` | Surface layout mode (e.g., `single`) | +| `surfaces` | Array of surface entries with per-path access, title, layout (see below) | +| `route` | *(deprecated — use `surfaces`)* URL path for surfaces | +| `auth` | Default access level for all surfaces: `authenticated`, `public`, `admin` | +| `layout` | Default layout for all surfaces: `single`, `editor` | | `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) | | `api_routes` | Array of `{"method": "GET", "path": "/items"}` | | `db_tables` | Table definitions with columns and indexes | @@ -75,6 +76,71 @@ Only `manifest.json` is required. All other directories are optional and include | `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` | | `schema_version` | Integer for additive schema migrations | +## Multi-Surface Packages + +A package can serve multiple pages, each with its own path, access level, +title, and layout. Declare a `surfaces` array in the manifest: + +```json +{ + "id": "bug-tracker", + "title": "Bug Tracker", + "type": "full", + "auth": "authenticated", + "layout": "single", + "surfaces": [ + { "path": "/", "title": "Dashboard" }, + { "path": "/submit", "access": "public", "title": "Report a Bug" }, + { "path": "/:id", "title": "Bug Detail", "nav": false }, + { "path": "/admin", "access": "admin", "title": "Settings", "nav": false } + ] +} +``` + +### Surface Entry Fields + +| Field | Type | Default | Description | +|----------|--------|----------------|-------------| +| `path` | string | **required** | Relative to `/s/{pkg-id}`. Supports `:param` segments. | +| `access` | string | package `auth` | `public`, `authenticated`, `admin`, `group:{name}`. | +| `title` | string | package `title`| Human label. Used in nav if `nav: true`. | +| `layout` | string | package `layout`| `single`, `editor`, or future layouts. | +| `nav` | bool | see rules | Show in sidebar navigation. | + +### Nav Visibility Rules + +- `path: "/"` defaults to `nav: true` (primary entry point) +- All others default to `nav: false` (sub-pages) +- Explicit `"nav": true` overrides — a package can put multiple entries in nav +- The sidebar links to the first surface with `nav: true` + +### Backward Compatibility + +If `surfaces` is absent, the kernel synthesizes one entry from the legacy +`auth` and `layout` fields. No existing packages break. + +### Client-Side Navigation + +Within a multi-surface package, use `sw.navigate()` for SPA-style routing: + +```js +const path = window.__SURFACE_PATH__ || '/'; +const params = window.__SURFACE_PARAMS__ || {}; + +if (path === '/') renderDashboard(); +if (path === '/submit') renderSubmitForm(); +if (path === '/:id') renderDetail(params.id); + +// Navigate to another surface within the package +sw.navigate('/submit'); +sw.navigate('/:id', { id: 'bug-42' }); + +// Listen for navigation events (including back/forward) +sw.on('surface.navigate', ({ path, params }) => { + renderView(path, params); +}); +``` + ## Composability: Slots and Contributions Packages compose with each other through named UI injection points (**slots**) and **contributions**. diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index 2a29ca9..ff962c9 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "regexp" + "strings" ) // validManifestID matches lowercase alphanumeric slugs with optional hyphens. @@ -21,6 +22,7 @@ type ManifestInfo struct { Tier string SchemaVersion int HasRoute bool + HasSurfaces bool HasTools bool HasPipes bool HasHooks bool @@ -81,7 +83,64 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { } info.Signature, _ = manifest["signature"].(string) - info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil + // ── Surfaces validation ───────────────────────────────────── + if rawSurfaces, ok := manifest["surfaces"].([]any); ok { + if len(rawSurfaces) == 0 { + return nil, fmt.Errorf("surfaces array cannot be empty") + } + seen := map[string]bool{} + for i, raw := range rawSurfaces { + s, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("surfaces[%d] must be an object", i) + } + path, _ := s["path"].(string) + if path == "" { + return nil, fmt.Errorf("surfaces[%d] requires a path", i) + } + if !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf("surfaces[%d] path must start with /", i) + } + if seen[path] { + return nil, fmt.Errorf("duplicate surface path: %s", path) + } + seen[path] = true + if access, ok := s["access"].(string); ok { + if !validAccessLevels(access) { + return nil, fmt.Errorf("surfaces[%d] invalid access: %s", i, access) + } + } + } + info.HasSurfaces = true + info.HasRoute = true + } else if manifest["surfaces"] != nil { + // surfaces key present but not an array + return nil, fmt.Errorf("surfaces must be an array") + } else { + // No surfaces declared — synthesize from legacy fields. + // This ensures every routable package always has a surfaces array. + if info.Type == "surface" || info.Type == "full" { + auth, _ := manifest["auth"].(string) + if auth == "" { + auth = "authenticated" + } + layout, _ := manifest["layout"].(string) + if layout == "" { + layout = "single" + } + manifest["surfaces"] = []any{ + map[string]any{ + "path": "/", + "access": auth, + "layout": layout, + "title": info.Title, + }, + } + info.HasSurfaces = true + } + info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil || info.HasSurfaces + } + info.HasTools = manifest["tools"] != nil info.HasPipes = manifest["pipes"] != nil info.HasHooks = manifest["hooks"] != nil @@ -156,6 +215,18 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { return info, nil } +// validAccessLevels checks whether an access string is a recognized value. +func validAccessLevels(access string) bool { + switch { + case access == "public", access == "authenticated", access == "admin": + return true + case strings.HasPrefix(access, "group:"): + return strings.TrimPrefix(access, "group:") != "" + default: + return false + } +} + // ValidateManifestJSON parses raw JSON bytes into a manifest map and validates. func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) { var manifest map[string]any diff --git a/server/handlers/package_validate_test.go b/server/handlers/package_validate_test.go index 085fcea..96944c7 100644 --- a/server/handlers/package_validate_test.go +++ b/server/handlers/package_validate_test.go @@ -198,3 +198,154 @@ func TestValidateManifest_WorkflowNoDef(t *testing.T) { t.Fatal("expected error for workflow without workflow_definition") } } + +// ── Surfaces validation ───────────────────────────────────── + +func TestValidateManifest_ValidSurfaces(t *testing.T) { + m := map[string]any{ + "id": "bug-tracker", + "title": "Bug Tracker", + "type": "full", + "hooks": []any{"surface"}, + "surfaces": []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/submit", "access": "public", "title": "Report a Bug"}, + map[string]any{"path": "/:id", "title": "Bug Detail", "nav": false}, + map[string]any{"path": "/admin", "access": "admin", "title": "Settings"}, + }, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.HasSurfaces { + t.Error("expected HasSurfaces to be true") + } + if !info.HasRoute { + t.Error("expected HasRoute to be true") + } +} + +func TestValidateManifest_EmptySurfaces(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{}, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for empty surfaces array") + } +} + +func TestValidateManifest_SurfaceMissingPath(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{ + map[string]any{"title": "No Path"}, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for surface without path") + } +} + +func TestValidateManifest_SurfacePathNoSlash(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{ + map[string]any{"path": "submit"}, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for path not starting with /") + } +} + +func TestValidateManifest_SurfaceDuplicatePath(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{ + map[string]any{"path": "/"}, + map[string]any{"path": "/"}, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for duplicate surface path") + } +} + +func TestValidateManifest_SurfaceInvalidAccess(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{ + map[string]any{"path": "/", "access": "superuser"}, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for invalid access level") + } +} + +func TestValidateManifest_SurfaceGroupAccess(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "type": "surface", + "surfaces": []any{ + map[string]any{"path": "/", "access": "group:engineering"}, + }, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.HasSurfaces { + t.Error("expected HasSurfaces to be true") + } +} + +func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) { + m := map[string]any{ + "id": "legacy-pkg", + "title": "Legacy Package", + "type": "surface", + "auth": "public", + "layout": "editor", + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.HasSurfaces { + t.Error("expected HasSurfaces after auto-synthesis") + } + // Verify the synthesized surfaces array was injected into the manifest + surfaces, ok := m["surfaces"].([]any) + if !ok || len(surfaces) != 1 { + t.Fatalf("expected 1 synthesized surface, got %v", m["surfaces"]) + } + s := surfaces[0].(map[string]any) + if s["path"] != "/" { + t.Errorf("expected path '/', got %q", s["path"]) + } + if s["access"] != "public" { + t.Errorf("expected access 'public', got %q", s["access"]) + } + if s["layout"] != "editor" { + t.Errorf("expected layout 'editor', got %q", s["layout"]) + } +} diff --git a/server/main.go b/server/main.go index b742acc..90259ea 100644 --- a/server/main.go +++ b/server/main.go @@ -958,12 +958,16 @@ func main() { Session: middleware.OptionalAuth(cfg, stores.Users, userCache), }) - // Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect). + // Extension surface + API routes: /s/:slug/* dispatches between + // surface rendering (GET) and ext API calls (all methods, /api/* prefix). { extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner) - extAPI := base.Group("/s/:slug/api") - extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens)) - extAPI.Any("/*path", extAPIH.Handle) + pageEngine.RegisterExtensionRoutes( + base, + middleware.OptionalAuth(cfg, stores.Users, userCache), + middleware.Auth(cfg, stores.Users, userCache, stores.APITokens), + extAPIH.Handle, + ) } bp := cfg.BasePath diff --git a/server/pages/pages.go b/server/pages/pages.go index 825da75..bdc1a1e 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -99,7 +99,9 @@ type PageData struct { Theme string // "dark", "light", or "" (= use default) SurfaceSettings any // JSON-serialized to window.__SETTINGS__ - Manifest *SurfaceManifest `json:"manifest,omitempty"` + Manifest *SurfaceManifest `json:"manifest,omitempty"` + SurfacePath string `json:"-"` // matched surface path pattern, e.g. "/monitor" + SurfaceParams map[string]string `json:"-"` // extracted params, e.g. {"id": "abc"} EnabledSurfaces []string `json:"-"` @@ -158,29 +160,60 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem { var items []ExtensionNavItem for _, sr := range surfaces { - if sr.Source == "core" || !sr.Enabled { + if sr.Source == "core" || !sr.Enabled || sr.Status != "active" { continue } // Only surface and full types have routes — extensions are headless if sr.Type != "surface" && sr.Type != "full" { continue } - route := "" + + basePath := "/s/" + sr.ID + title := sr.Title + + // Multi-surface: find the nav entry from surfaces array if sr.Manifest != nil { - route, _ = sr.Manifest["route"].(string) - } - if route == "" { - route = "/s/" + sr.ID + if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok { + navEntry := findNavSurface(rawSurfaces) + if navEntry != nil { + if p, ok := navEntry["path"].(string); ok && p != "/" { + basePath = "/s/" + sr.ID + p + } + if t, ok := navEntry["title"].(string); ok && t != "" { + title = t + } + } + } } + items = append(items, ExtensionNavItem{ ID: sr.ID, - Title: sr.Title, - Route: route, + Title: title, + Route: basePath, }) } return items } +// findNavSurface returns the surface entry to use for navigation. +// Returns the first entry with nav:true, falling back to the entry with path "/". +func findNavSurface(surfaces []any) map[string]any { + var root map[string]any + for _, raw := range surfaces { + s, ok := raw.(map[string]any) + if !ok { + continue + } + if nav, ok := s["nav"].(bool); ok && nav { + return s + } + if p, ok := s["path"].(string); ok && p == "/" { + root = s + } + } + return root +} + // browserExtensionIDs returns IDs of enabled browser-tier extensions. // These extensions have JS scripts that should be injected into every page // so they can register renderers with the SDK. @@ -401,8 +434,10 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc { } } -// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces. -// No restart required after installing a surface via admin API. +// RenderExtensionSurface handles extension surface rendering with multi-surface +// support. Each surface entry in the manifest can have its own path, access +// level, title, and layout. Called by RegisterExtensionRoutes for both root +// requests (/s/:slug) and sub-path requests (/s/:slug/monitor). func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { return func(c *gin.Context) { slug := c.Param("slug") @@ -410,6 +445,10 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { c.String(http.StatusNotFound, "Surface not found") return } + reqPath := c.Param("path") + if reqPath == "" { + reqPath = "/" + } if e.stores.Packages == nil { c.String(http.StatusNotFound, "Surface registry not available") @@ -427,28 +466,70 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { return } if sr.Source == "core" { - // Core surfaces have their own routes — don't serve them here c.String(http.StatusNotFound, "Surface not found: "+slug) return } + // ── Multi-surface resolution ───────────────────────────── + var surfacePath string + var surfaceParams map[string]string + surfaceTitle := sr.Title + surfaceAccess := "authenticated" + surfaceLayout := "single" + + // Read package-level defaults + if pkgAuth, ok := sr.Manifest["auth"].(string); ok && pkgAuth != "" { + surfaceAccess = pkgAuth + } + if pkgLayout, ok := sr.Manifest["layout"].(string); ok && pkgLayout != "" { + surfaceLayout = pkgLayout + } + + if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 { + matched, params := matchSurface(rawSurfaces, reqPath) + if matched == nil { + c.String(http.StatusNotFound, "Page not found") + return + } + surfacePath, _ = matched["path"].(string) + surfaceParams = params + if t, ok := matched["title"].(string); ok && t != "" { + surfaceTitle = t + } + if a, ok := matched["access"].(string); ok && a != "" { + surfaceAccess = a + } + if l, ok := matched["layout"].(string); ok && l != "" { + surfaceLayout = l + } + } else { + // Legacy package without surfaces — only serve root path + if reqPath != "/" { + c.String(http.StatusNotFound, "Page not found") + return + } + surfacePath = "/" + } + + // ── Access check ───────────────────────────────────────── + 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") + } else { + c.String(http.StatusForbidden, "Access denied") + } + return + } + user := e.getUserContext(c) - // Build manifest from DB record - route, _ := sr.Manifest["route"].(string) - if route == "" { - route = "/s/" + sr.ID - } - layout, _ := sr.Manifest["layout"].(string) - if layout == "" { - layout = "single" - } manifest := &SurfaceManifest{ ID: sr.ID, - Route: route, - Title: sr.Title, + Route: "/s/" + sr.ID, + Title: surfaceTitle, Template: "surface-extension", - Layout: layout, + Layout: surfaceLayout, Source: "extension", } @@ -456,6 +537,8 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { Surface: sr.ID, User: user, Manifest: manifest, + SurfacePath: surfacePath, + SurfaceParams: surfaceParams, EnabledSurfaces: e.EnabledSurfaceIDs(), ExtensionSurfaces: e.extensionNavItems(), BrowserExtensions: e.browserExtensionIDs(), @@ -463,6 +546,98 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { } } +// matchSurface finds the best-matching surface entry for a request path. +// Supports Gin-style param segments (/:id matches /abc). Static segments +// are preferred over param segments. Returns nil if no surface matches. +func matchSurface(surfaces []any, reqPath string) (map[string]any, map[string]string) { + reqParts := splitPath(reqPath) + + type candidate struct { + surface map[string]any + params map[string]string + score int // higher = more static segments = better match + } + + var best *candidate + for _, raw := range surfaces { + s, ok := raw.(map[string]any) + if !ok { + continue + } + pattern, _ := s["path"].(string) + if pattern == "" { + continue + } + patParts := splitPath(pattern) + if len(patParts) != len(reqParts) { + continue + } + + params := map[string]string{} + score := 0 + matched := true + for i, pp := range patParts { + if strings.HasPrefix(pp, ":") { + params[pp[1:]] = reqParts[i] + } else if pp == reqParts[i] { + score++ + } else { + matched = false + break + } + } + if !matched { + continue + } + if best == nil || score > best.score { + best = &candidate{surface: s, params: params, score: score} + } + } + if best == nil { + return nil, nil + } + return best.surface, best.params +} + +// splitPath splits a URL path into non-empty segments. +// "/" → [], "/monitor" → ["monitor"], "/monitor/:id" → ["monitor", ":id"] +func splitPath(p string) []string { + var parts []string + for _, s := range strings.Split(p, "/") { + if s != "" { + parts = append(parts, s) + } + } + return parts +} + +// evaluateAccess checks whether the current request meets an access requirement. +func evaluateAccess(c *gin.Context, access string) bool { + switch { + case access == "public": + return true + case access == "authenticated": + return c.GetString("user_id") != "" + case access == "admin": + return c.GetBool("is_admin") + case strings.HasPrefix(access, "group:"): + // Group membership check — look for groups set by auth middleware + group := strings.TrimPrefix(access, "group:") + if groups, exists := c.Get("user_groups"); exists { + if gs, ok := groups.([]string); ok { + for _, g := range gs { + if g == group { + return true + } + } + } + } + return false + default: + return false + } +} + // PageRouteMiddleware holds middleware handlers for each auth level. // Passed to RegisterPageRoutes by main.go. type PageRouteMiddleware struct { @@ -502,8 +677,9 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar registerRoutes(group, s, handler) } - // No restart needed after installing new surfaces via admin API. - group.GET("/s/:slug", e.RenderExtensionSurface()) + // Extension surfaces are registered separately via + // RegisterExtensionRoutes (called from main.go) to unify + // the /s/:slug route tree with the ext API dispatcher. } // Admin surfaces — auth + admin role middleware @@ -553,6 +729,63 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces)) } +// RegisterExtensionRoutes registers the combined extension surface and ext API +// routes. A single /s/:slug/*path catch-all dispatches between: +// - /s/:slug/api/* → apiAuth middleware + extAPIHandler (JSON API) +// - /s/:slug/* → surface handler with per-surface access checks (HTML) +// +// This avoids Gin route conflicts between the catch-all and the API sub-path. +// Must be called AFTER RegisterPageRoutes (which handles core surfaces). +func (e *Engine) RegisterExtensionRoutes( + base *gin.RouterGroup, + optAuth gin.HandlerFunc, + apiAuth gin.HandlerFunc, + extAPIHandler gin.HandlerFunc, +) { + surfaceHandler := e.RenderExtensionSurface() + + sGroup := base.Group("/s/:slug") + sGroup.Use(optAuth) // populate user context if token present; don't require it + + // Root path: /s/:slug (no sub-path) — surface only + sGroup.GET("", surfaceHandler) + + // Sub-paths: /s/:slug/* — dispatch between API and surface + sGroup.Any("/*path", func(c *gin.Context) { + subPath := c.Param("path") + + // API requests: /s/:slug/api/* + if strings.HasPrefix(subPath, "/api/") || subPath == "/api" { + apiAuth(c) + if c.IsAborted() { + return + } + // Rewrite the path param so the ext API handler sees the + // API-relative path, e.g. "/api/items" → "/items" + apiPath := strings.TrimPrefix(subPath, "/api") + if apiPath == "" { + apiPath = "/" + } + for i := range c.Params { + if c.Params[i].Key == "path" { + c.Params[i].Value = apiPath + break + } + } + extAPIHandler(c) + return + } + + // Surface requests: GET only + if c.Request.Method != http.MethodGet { + c.String(http.StatusMethodNotAllowed, "Method not allowed") + return + } + + surfaceHandler(c) + }) +} + // surfaceHandler returns the appropriate Gin handler for a surface. func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc { if s.ID == "workflow" { diff --git a/server/pages/pages_surface_match_test.go b/server/pages/pages_surface_match_test.go new file mode 100644 index 0000000..08e0132 --- /dev/null +++ b/server/pages/pages_surface_match_test.go @@ -0,0 +1,175 @@ +package pages + +import ( + "testing" +) + +// ── matchSurface tests ────────────────────────────────────── + +func TestMatchSurface_StaticRoot(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/submit", "title": "Submit"}, + } + s, params := matchSurface(surfaces, "/") + if s == nil { + t.Fatal("expected match for /") + } + if s["title"] != "Dashboard" { + t.Errorf("expected Dashboard, got %v", s["title"]) + } + if len(params) != 0 { + t.Errorf("expected no params, got %v", params) + } +} + +func TestMatchSurface_StaticPath(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/submit", "title": "Submit"}, + map[string]any{"path": "/monitor", "title": "Monitor"}, + } + s, _ := matchSurface(surfaces, "/submit") + if s == nil { + t.Fatal("expected match for /submit") + } + if s["title"] != "Submit" { + t.Errorf("expected Submit, got %v", s["title"]) + } +} + +func TestMatchSurface_ParamPath(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/:id", "title": "Detail"}, + } + s, params := matchSurface(surfaces, "/abc123") + if s == nil { + t.Fatal("expected match for /:id") + } + if s["title"] != "Detail" { + t.Errorf("expected Detail, got %v", s["title"]) + } + if params["id"] != "abc123" { + t.Errorf("expected id=abc123, got %v", params["id"]) + } +} + +func TestMatchSurface_MultiSegmentParam(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/monitor", "title": "Monitor"}, + map[string]any{"path": "/monitor/:id", "title": "Instance Detail"}, + } + s, params := matchSurface(surfaces, "/monitor/inst-42") + if s == nil { + t.Fatal("expected match for /monitor/:id") + } + if s["title"] != "Instance Detail" { + t.Errorf("expected Instance Detail, got %v", s["title"]) + } + if params["id"] != "inst-42" { + t.Errorf("expected id=inst-42, got %v", params["id"]) + } +} + +func TestMatchSurface_NoMatch(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/submit", "title": "Submit"}, + } + s, _ := matchSurface(surfaces, "/nonexistent/deep") + if s != nil { + t.Errorf("expected no match, got %v", s) + } +} + +func TestMatchSurface_StaticPreferredOverParam(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/:id", "title": "Detail"}, + map[string]any{"path": "/new", "title": "New"}, + } + s, params := matchSurface(surfaces, "/new") + if s == nil { + t.Fatal("expected match for /new") + } + if s["title"] != "New" { + t.Errorf("expected static match 'New', got %v", s["title"]) + } + if len(params) != 0 { + t.Errorf("expected no params for static match, got %v", params) + } +} + +func TestMatchSurface_EmptySurfaces(t *testing.T) { + s, _ := matchSurface([]any{}, "/") + if s != nil { + t.Errorf("expected no match for empty surfaces, got %v", s) + } +} + +// ── splitPath tests ───────────────────────────────────────── + +func TestSplitPath(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"/", 0}, + {"/submit", 1}, + {"/monitor/:id", 2}, + {"/:id/edit", 2}, + } + for _, tt := range tests { + parts := splitPath(tt.input) + if len(parts) != tt.want { + t.Errorf("splitPath(%q) = %d parts, want %d", tt.input, len(parts), tt.want) + } + } +} + +// ── findNavSurface tests ──────────────────────────────────── + +func TestFindNavSurface_ExplicitNav(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/", "title": "Dashboard"}, + map[string]any{"path": "/monitor", "title": "Monitor", "nav": true}, + } + s := findNavSurface(surfaces) + if s == nil { + t.Fatal("expected nav surface") + } + if s["title"] != "Monitor" { + t.Errorf("expected Monitor, got %v", s["title"]) + } +} + +func TestFindNavSurface_FallbackRoot(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/submit", "title": "Submit"}, + map[string]any{"path": "/", "title": "Dashboard"}, + } + s := findNavSurface(surfaces) + if s == nil { + t.Fatal("expected nav surface") + } + if s["title"] != "Dashboard" { + t.Errorf("expected Dashboard, got %v", s["title"]) + } +} + +func TestFindNavSurface_NoMatch(t *testing.T) { + surfaces := []any{ + map[string]any{"path": "/submit", "title": "Submit"}, + map[string]any{"path": "/admin", "title": "Admin"}, + } + s := findNavSurface(surfaces) + if s != nil { + t.Errorf("expected nil, got %v", s) + } +} + +// ── evaluateAccess tests ──────────────────────────────────── +// Note: evaluateAccess depends on gin.Context which is hard to unit test +// without a full Gin setup. These are covered via handler integration tests. +// The matchSurface + splitPath + findNavSurface functions above are the +// pure-logic components that benefit most from unit testing. diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 8e5cf16..8aac465 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -122,6 +122,8 @@ {{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot populates sw.auth.user and sw.auth.policies from API. */}} {{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}} + {{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}} + {{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}} {{/* All surfaces use Preact SDK boot(). Legacy script includes removed. diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index e79724e..b44f498 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -186,8 +186,46 @@ export async function boot() { }); }; + // Navigate — multi-surface intra-package routing + sw.navigate = function (path, params) { + const manifest = window.__MANIFEST__; + if (!manifest?.id) { + console.warn('[sw] navigate: no manifest — not inside an extension surface'); + return; + } + // Interpolate :param placeholders + let resolved = path; + if (params) { + for (const [k, v] of Object.entries(params)) { + resolved = resolved.replace(':' + k, encodeURIComponent(v)); + } + } + const base = window.__BASE__ || ''; + const url = base + '/s/' + manifest.id + resolved; + + // Update globals so the package can re-render + window.__SURFACE_PATH__ = path; + window.__SURFACE_PARAMS__ = params || {}; + + history.pushState({ surfacePath: path, surfaceParams: params }, '', url); + events.emit('surface.navigate', { path, params: params || {}, url }, { localOnly: true }); + }; + + // Listen for popstate (back/forward) to emit navigation events + window.addEventListener('popstate', (e) => { + if (e.state?.surfacePath != null) { + window.__SURFACE_PATH__ = e.state.surfacePath; + window.__SURFACE_PARAMS__ = e.state.surfaceParams || {}; + events.emit('surface.navigate', { + path: e.state.surfacePath, + params: e.state.surfaceParams || {}, + url: location.pathname, + }, { localOnly: true }); + } + }); + // Marker for idempotency - sw._sdk = '0.7.1'; + sw._sdk = '0.9.0'; // 8. Expose globally window.sw = sw;