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:
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user