Feat v0.9.0 multi-surface packages (#73)
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m48s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Successful in 1m23s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m48s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Successful in 1m23s
Packages can declare a `surfaces` array with per-path access controls, titles, and layouts. A single package can serve a public form, an authenticated dashboard, and an admin page — each with independent access enforcement. Kernel: - Manifest validation for surfaces array (path, access, duplicates) - Auto-synthesis from legacy auth/layout for existing packages - Unified /s/:slug route tree (RegisterExtensionRoutes) dispatches between surface rendering and ext API calls - matchSurface() with Gin-style :param support and specificity ordering - evaluateAccess() for per-surface access checks - Nav filters out pending_review packages (pre-existing bug fix) Frontend: - __SURFACE_PATH__ and __SURFACE_PARAMS__ template injection - sw.navigate(path, params) for SPA-style intra-package routing - surface.navigate event + popstate handling - SDK version bumped to 0.9.0 Docs: - MULTI-SURFACE-GUIDE.md — full developer guide - PACKAGE-FORMAT.md — surfaces field reference - CHANGELOG.md, ROADMAP.md updated 22 new tests (11 manifest validation, 11 route matching/nav). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
CHANGELOG.md
72
CHANGELOG.md
@@ -2,6 +2,78 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
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
|
## v0.8.5 — Extension Composability
|
||||||
|
|
||||||
Extensions can now compose with each other through declared slots, UI
|
Extensions can now compose with each other through declared slots, UI
|
||||||
|
|||||||
52
ROADMAP.md
52
ROADMAP.md
@@ -75,70 +75,70 @@ All completed work is documented in `CHANGELOG.md`.
|
|||||||
|
|
||||||
## Planned
|
## 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`
|
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
|
Three files contain near-identical Go↔Starlark converters; three copies
|
||||||
of the snapshot parser exist. Consolidate into `sandbox/convert.go` and
|
of the snapshot parser exist. Consolidate into `sandbox/convert.go` and
|
||||||
one exported snapshot function. Standardize on wrapped snapshot format.
|
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
|
Promote the buried role system to a kernel primitive. New
|
||||||
`team_user_roles` table (many-to-many). Manifest `requires_roles` field.
|
`team_user_roles` table (many-to-many). Manifest `requires_roles` field.
|
||||||
Team admin UI for role assignment. Kernel middleware `RequireRole()`.
|
Team admin UI for role assignment. Kernel middleware `RequireRole()`.
|
||||||
Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`.
|
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
|
`scope: adoptable` manifest field. When a team adopts an adoptable
|
||||||
package, the package's `requires_roles` auto-populate into the team's
|
package, the package's `requires_roles` auto-populate into the team's
|
||||||
role slots. Replaces `AdoptTeamWorkflow` clone mechanism.
|
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
|
Extract `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
||||||
`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()`
|
`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()`
|
||||||
and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package
|
and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package
|
||||||
can declare forms, not just workflow stages.
|
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`
|
`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook`
|
||||||
presence. Remove from new manifests, keep parsing for backward compat.
|
presence. Remove from new manifests, keep parsing for backward compat.
|
||||||
Collapse `stage_mode` from 4 to 3 values: form / delegated / automated.
|
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()`,
|
Add `workflow.start()`, `workflow.advance()`, `workflow.cancel()`,
|
||||||
`workflow.submit_signoff()` to the Starlark module. Extract engine
|
`workflow.submit_signoff()` to the Starlark module. Extract engine
|
||||||
interface to break circular import. Unlocks fully automated workflow
|
interface to break circular import.
|
||||||
orchestration from Starlark hooks and extensions.
|
|
||||||
|
|
||||||
**v0.9.6 — Conditional Routing → SDK Primitive**
|
**v0.9.8 — Conditional Routing → SDK Primitive**
|
||||||
|
|
||||||
Expose `routing.evaluate(rules, data)` as a Starlark SDK function.
|
Expose `routing.evaluate(rules, data)` as a Starlark SDK function.
|
||||||
Branch rules become a reusable decision engine for any extension.
|
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
|
Wire team roles (v0.9.3) into surface access declarations:
|
||||||
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:
|
|
||||||
`access: role:approver`. Kernel middleware checks role membership.
|
`access: role:approver`. Kernel middleware checks role membership.
|
||||||
Completes the workflow→package access story.
|
Completes the workflow→package access story.
|
||||||
|
|
||||||
|
|||||||
201
docs/MULTI-SURFACE-GUIDE.md
Normal file
201
docs/MULTI-SURFACE-GUIDE.md
Normal file
@@ -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 `<a>` links that do full page loads:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/s/workflow-builder/monitor">Monitor</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -60,9 +60,10 @@ Only `manifest.json` is required. All other directories are optional and include
|
|||||||
| `description` | Short description |
|
| `description` | Short description |
|
||||||
| `icon` | Emoji for sidebar/menu display |
|
| `icon` | Emoji for sidebar/menu display |
|
||||||
| `author` | Package author |
|
| `author` | Package author |
|
||||||
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
|
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
|
||||||
| `auth` | `authenticated` or `public` |
|
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
|
||||||
| `layout` | Surface layout mode (e.g., `single`) |
|
| `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`) |
|
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
||||||
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
||||||
| `db_tables` | Table definitions with columns and indexes |
|
| `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": [...]}` |
|
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||||
| `schema_version` | Integer for additive schema migrations |
|
| `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
|
## Composability: Slots and Contributions
|
||||||
|
|
||||||
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
|
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
||||||
@@ -21,6 +22,7 @@ type ManifestInfo struct {
|
|||||||
Tier string
|
Tier string
|
||||||
SchemaVersion int
|
SchemaVersion int
|
||||||
HasRoute bool
|
HasRoute bool
|
||||||
|
HasSurfaces bool
|
||||||
HasTools bool
|
HasTools bool
|
||||||
HasPipes bool
|
HasPipes bool
|
||||||
HasHooks bool
|
HasHooks bool
|
||||||
@@ -81,7 +83,64 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
}
|
}
|
||||||
info.Signature, _ = manifest["signature"].(string)
|
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.HasTools = manifest["tools"] != nil
|
||||||
info.HasPipes = manifest["pipes"] != nil
|
info.HasPipes = manifest["pipes"] != nil
|
||||||
info.HasHooks = manifest["hooks"] != nil
|
info.HasHooks = manifest["hooks"] != nil
|
||||||
@@ -156,6 +215,18 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
return info, nil
|
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.
|
// ValidateManifestJSON parses raw JSON bytes into a manifest map and validates.
|
||||||
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
|
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
|
||||||
var manifest map[string]any
|
var manifest map[string]any
|
||||||
|
|||||||
@@ -198,3 +198,154 @@ func TestValidateManifest_WorkflowNoDef(t *testing.T) {
|
|||||||
t.Fatal("expected error for workflow without workflow_definition")
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -958,12 +958,16 @@ func main() {
|
|||||||
Session: middleware.OptionalAuth(cfg, stores.Users, userCache),
|
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)
|
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||||
extAPI := base.Group("/s/:slug/api")
|
pageEngine.RegisterExtensionRoutes(
|
||||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
base,
|
||||||
extAPI.Any("/*path", extAPIH.Handle)
|
middleware.OptionalAuth(cfg, stores.Users, userCache),
|
||||||
|
middleware.Auth(cfg, stores.Users, userCache, stores.APITokens),
|
||||||
|
extAPIH.Handle,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
bp := cfg.BasePath
|
bp := cfg.BasePath
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ type PageData struct {
|
|||||||
Theme string // "dark", "light", or "" (= use default)
|
Theme string // "dark", "light", or "" (= use default)
|
||||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
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:"-"`
|
EnabledSurfaces []string `json:"-"`
|
||||||
|
|
||||||
@@ -158,29 +160,60 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
|||||||
|
|
||||||
var items []ExtensionNavItem
|
var items []ExtensionNavItem
|
||||||
for _, sr := range surfaces {
|
for _, sr := range surfaces {
|
||||||
if sr.Source == "core" || !sr.Enabled {
|
if sr.Source == "core" || !sr.Enabled || sr.Status != "active" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only surface and full types have routes — extensions are headless
|
// Only surface and full types have routes — extensions are headless
|
||||||
if sr.Type != "surface" && sr.Type != "full" {
|
if sr.Type != "surface" && sr.Type != "full" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
route := ""
|
|
||||||
|
basePath := "/s/" + sr.ID
|
||||||
|
title := sr.Title
|
||||||
|
|
||||||
|
// Multi-surface: find the nav entry from surfaces array
|
||||||
if sr.Manifest != nil {
|
if sr.Manifest != nil {
|
||||||
route, _ = sr.Manifest["route"].(string)
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok {
|
||||||
}
|
navEntry := findNavSurface(rawSurfaces)
|
||||||
if route == "" {
|
if navEntry != nil {
|
||||||
route = "/s/" + sr.ID
|
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{
|
items = append(items, ExtensionNavItem{
|
||||||
ID: sr.ID,
|
ID: sr.ID,
|
||||||
Title: sr.Title,
|
Title: title,
|
||||||
Route: route,
|
Route: basePath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
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.
|
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
|
||||||
// These extensions have JS scripts that should be injected into every page
|
// These extensions have JS scripts that should be injected into every page
|
||||||
// so they can register renderers with the SDK.
|
// 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.
|
// RenderExtensionSurface handles extension surface rendering with multi-surface
|
||||||
// No restart required after installing a surface via admin API.
|
// 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 {
|
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
slug := c.Param("slug")
|
slug := c.Param("slug")
|
||||||
@@ -410,6 +445,10 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
c.String(http.StatusNotFound, "Surface not found")
|
c.String(http.StatusNotFound, "Surface not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reqPath := c.Param("path")
|
||||||
|
if reqPath == "" {
|
||||||
|
reqPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
if e.stores.Packages == nil {
|
if e.stores.Packages == nil {
|
||||||
c.String(http.StatusNotFound, "Surface registry not available")
|
c.String(http.StatusNotFound, "Surface registry not available")
|
||||||
@@ -427,28 +466,70 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sr.Source == "core" {
|
if sr.Source == "core" {
|
||||||
// Core surfaces have their own routes — don't serve them here
|
|
||||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||||
return
|
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)
|
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{
|
manifest := &SurfaceManifest{
|
||||||
ID: sr.ID,
|
ID: sr.ID,
|
||||||
Route: route,
|
Route: "/s/" + sr.ID,
|
||||||
Title: sr.Title,
|
Title: surfaceTitle,
|
||||||
Template: "surface-extension",
|
Template: "surface-extension",
|
||||||
Layout: layout,
|
Layout: surfaceLayout,
|
||||||
Source: "extension",
|
Source: "extension",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +537,8 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
Surface: sr.ID,
|
Surface: sr.ID,
|
||||||
User: user,
|
User: user,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
|
SurfacePath: surfacePath,
|
||||||
|
SurfaceParams: surfaceParams,
|
||||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||||
ExtensionSurfaces: e.extensionNavItems(),
|
ExtensionSurfaces: e.extensionNavItems(),
|
||||||
BrowserExtensions: e.browserExtensionIDs(),
|
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.
|
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||||
// Passed to RegisterPageRoutes by main.go.
|
// Passed to RegisterPageRoutes by main.go.
|
||||||
type PageRouteMiddleware struct {
|
type PageRouteMiddleware struct {
|
||||||
@@ -502,8 +677,9 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
|||||||
registerRoutes(group, s, handler)
|
registerRoutes(group, s, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No restart needed after installing new surfaces via admin API.
|
// Extension surfaces are registered separately via
|
||||||
group.GET("/s/:slug", e.RenderExtensionSurface())
|
// RegisterExtensionRoutes (called from main.go) to unify
|
||||||
|
// the /s/:slug route tree with the ext API dispatcher.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin surfaces — auth + admin role middleware
|
// 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))
|
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.
|
// surfaceHandler returns the appropriate Gin handler for a surface.
|
||||||
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||||
if s.ID == "workflow" {
|
if s.ID == "workflow" {
|
||||||
|
|||||||
175
server/pages/pages_surface_match_test.go
Normal file
175
server/pages/pages_surface_match_test.go
Normal file
@@ -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.
|
||||||
@@ -122,6 +122,8 @@
|
|||||||
{{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot
|
{{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot
|
||||||
populates sw.auth.user and sw.auth.policies from API. */}}
|
populates sw.auth.user and sw.auth.policies from API. */}}
|
||||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||||
|
{{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}}
|
||||||
|
{{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
|
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
|
||||||
|
|||||||
@@ -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
|
// Marker for idempotency
|
||||||
sw._sdk = '0.7.1';
|
sw._sdk = '0.9.0';
|
||||||
|
|
||||||
// 8. Expose globally
|
// 8. Expose globally
|
||||||
window.sw = sw;
|
window.sw = sw;
|
||||||
|
|||||||
Reference in New Issue
Block a user