This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/package_validate.go
Jeffrey Smith c2473efee2
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
Feat v0.9.0 multi-surface packages (#73)
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>
2026-04-03 12:34:38 +00:00

242 lines
8.0 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
// Duplicated from packages.go for use in standalone validation.
var validManifestID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// ManifestInfo holds the parsed and validated fields from a manifest.json.
type ManifestInfo struct {
ID string
Title string
Type string // surface, extension, full, workflow, library, test-runner
Version string
Description string
Author string
Tier string
SchemaVersion int
HasRoute bool
HasSurfaces bool
HasTools bool
HasPipes bool
HasHooks bool
HasSettings bool
HasExports bool
Dependencies map[string]any
Requires []string
Signature string // reserved for future package signing
UserPermissions []string // user-facing permissions declared by the extension
GatePermission string // if set, checks this user permission before calling on_request
}
// ValidateManifest parses a manifest map and validates all required fields,
// type constraints, and structural rules. Returns a ManifestInfo or an error
// describing the first validation failure.
func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
if manifest == nil {
return nil, fmt.Errorf("manifest is nil")
}
info := &ManifestInfo{}
// ── Required fields ──────────────────────────────────────────
info.ID, _ = manifest["id"].(string)
info.Title, _ = manifest["title"].(string)
if info.ID == "" || info.Title == "" {
return nil, fmt.Errorf("manifest must have 'id' and 'title'")
}
// ── Package ID slug validation ───────────────────────────────
if !validManifestID.MatchString(info.ID) {
return nil, fmt.Errorf("package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)")
}
// ── Type validation ──────────────────────────────────────────
info.Type, _ = manifest["type"].(string)
if info.Type == "" {
info.Type = "surface" // backward compat
}
validTypes := map[string]bool{
"surface": true, "extension": true, "full": true,
"workflow": true, "library": true, "test-runner": true,
}
if !validTypes[info.Type] {
return nil, fmt.Errorf("manifest type must be 'surface', 'extension', 'full', 'workflow', 'library', or 'test-runner'")
}
// ── Extract optional fields ──────────────────────────────────
info.Version, _ = manifest["version"].(string)
if info.Version == "" {
info.Version = "0.0.0"
}
info.Description, _ = manifest["description"].(string)
info.Author, _ = manifest["author"].(string)
info.Tier, _ = manifest["tier"].(string)
if info.Tier == "" {
info.Tier = "browser"
}
info.Signature, _ = manifest["signature"].(string)
// ── 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
info.HasSettings = manifest["settings"] != nil
info.HasExports = false
if deps, ok := manifest["dependencies"].(map[string]any); ok {
info.Dependencies = deps
}
if reqs, ok := manifest["requires"].([]any); ok {
for _, r := range reqs {
if s, ok := r.(string); ok && s != "" {
info.Requires = append(info.Requires, s)
}
}
}
// Extension-declared user permissions
if ups, ok := manifest["user_permissions"].([]any); ok {
for _, v := range ups {
if s, ok := v.(string); ok && s != "" {
info.UserPermissions = append(info.UserPermissions, s)
}
}
}
info.GatePermission, _ = manifest["gate_permission"].(string)
info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ────────────────────────────────
hasExtBehavior := info.HasTools || info.HasPipes || info.HasHooks
switch info.Type {
case "surface":
// route is optional for surfaces
case "extension":
if !hasExtBehavior {
return nil, fmt.Errorf("extension packages require at least one of: tools, pipes, hooks")
}
if info.HasRoute {
return nil, fmt.Errorf("extension packages cannot have a route — use type 'full' for both")
}
case "full":
if !info.HasRoute {
return nil, fmt.Errorf("full packages require a route")
}
if !hasExtBehavior && !info.HasSettings {
return nil, fmt.Errorf("full packages require at least one of: tools, pipes, hooks, settings")
}
case "workflow":
if manifest["workflow_definition"] == nil {
return nil, fmt.Errorf("workflow packages require a 'workflow_definition' in the manifest")
}
case "library":
exports, _ := manifest["exports"].([]any)
if len(exports) == 0 {
return nil, fmt.Errorf("library packages require an 'exports' array")
}
info.HasExports = true
if info.HasTools || info.HasPipes {
return nil, fmt.Errorf("library packages cannot have tools or pipes")
}
if info.HasRoute {
return nil, fmt.Errorf("library packages cannot have a route")
}
case "test-runner":
// Test runners are surface-like packages discovered by type.
// They are not shown in navigation (extensionNavItems filters for surface/full).
// They may have a route but it's optional — the registry surface provides access.
}
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
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, nil, fmt.Errorf("invalid manifest.json: %w", err)
}
info, err := ValidateManifest(manifest)
if err != nil {
return nil, nil, err
}
return manifest, info, nil
}