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>
6.0 KiB
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:
{
"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:
{
"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:
const path = window.__SURFACE_PATH__ || '/';
const params = window.__SURFACE_PARAMS__ || {};
Use these to decide which view to render:
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:
// 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:
- Updates
window.__SURFACE_PATH__andwindow.__SURFACE_PARAMS__ - Calls
history.pushState()to update the URL - Emits a
surface.navigateevent
Listening for Navigation Events
Re-render when the user navigates (including browser back/forward):
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:
<a href="/s/workflow-builder/monitor">Monitor</a>
For SPA-style navigation:
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:
// 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.