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;