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:
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.
|
||||
Reference in New Issue
Block a user