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>
202 lines
6.0 KiB
Markdown
202 lines
6.0 KiB
Markdown
# 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.
|