Feat v0.9.0 multi surface packages (#73)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 44s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 44s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #73.
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,12 +958,16 @@ func main() {
|
||||
Session: middleware.OptionalAuth(cfg, stores.Users, userCache),
|
||||
})
|
||||
|
||||
// Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect).
|
||||
// Extension surface + API routes: /s/:slug/* dispatches between
|
||||
// surface rendering (GET) and ext API calls (all methods, /api/* prefix).
|
||||
{
|
||||
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||
extAPI := base.Group("/s/:slug/api")
|
||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
||||
extAPI.Any("/*path", extAPIH.Handle)
|
||||
pageEngine.RegisterExtensionRoutes(
|
||||
base,
|
||||
middleware.OptionalAuth(cfg, stores.Users, userCache),
|
||||
middleware.Auth(cfg, stores.Users, userCache, stores.APITokens),
|
||||
extAPIH.Handle,
|
||||
)
|
||||
}
|
||||
|
||||
bp := cfg.BasePath
|
||||
|
||||
@@ -99,7 +99,9 @@ type PageData struct {
|
||||
Theme string // "dark", "light", or "" (= use default)
|
||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||
|
||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||
SurfacePath string `json:"-"` // matched surface path pattern, e.g. "/monitor"
|
||||
SurfaceParams map[string]string `json:"-"` // extracted params, e.g. {"id": "abc"}
|
||||
|
||||
EnabledSurfaces []string `json:"-"`
|
||||
|
||||
@@ -158,29 +160,60 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
||||
|
||||
var items []ExtensionNavItem
|
||||
for _, sr := range surfaces {
|
||||
if sr.Source == "core" || !sr.Enabled {
|
||||
if sr.Source == "core" || !sr.Enabled || sr.Status != "active" {
|
||||
continue
|
||||
}
|
||||
// Only surface and full types have routes — extensions are headless
|
||||
if sr.Type != "surface" && sr.Type != "full" {
|
||||
continue
|
||||
}
|
||||
route := ""
|
||||
|
||||
basePath := "/s/" + sr.ID
|
||||
title := sr.Title
|
||||
|
||||
// Multi-surface: find the nav entry from surfaces array
|
||||
if sr.Manifest != nil {
|
||||
route, _ = sr.Manifest["route"].(string)
|
||||
}
|
||||
if route == "" {
|
||||
route = "/s/" + sr.ID
|
||||
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok {
|
||||
navEntry := findNavSurface(rawSurfaces)
|
||||
if navEntry != nil {
|
||||
if p, ok := navEntry["path"].(string); ok && p != "/" {
|
||||
basePath = "/s/" + sr.ID + p
|
||||
}
|
||||
if t, ok := navEntry["title"].(string); ok && t != "" {
|
||||
title = t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, ExtensionNavItem{
|
||||
ID: sr.ID,
|
||||
Title: sr.Title,
|
||||
Route: route,
|
||||
Title: title,
|
||||
Route: basePath,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// findNavSurface returns the surface entry to use for navigation.
|
||||
// Returns the first entry with nav:true, falling back to the entry with path "/".
|
||||
func findNavSurface(surfaces []any) map[string]any {
|
||||
var root map[string]any
|
||||
for _, raw := range surfaces {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if nav, ok := s["nav"].(bool); ok && nav {
|
||||
return s
|
||||
}
|
||||
if p, ok := s["path"].(string); ok && p == "/" {
|
||||
root = s
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
|
||||
// These extensions have JS scripts that should be injected into every page
|
||||
// so they can register renderers with the SDK.
|
||||
@@ -401,8 +434,10 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
|
||||
// No restart required after installing a surface via admin API.
|
||||
// RenderExtensionSurface handles extension surface rendering with multi-surface
|
||||
// support. Each surface entry in the manifest can have its own path, access
|
||||
// level, title, and layout. Called by RegisterExtensionRoutes for both root
|
||||
// requests (/s/:slug) and sub-path requests (/s/:slug/monitor).
|
||||
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
@@ -410,6 +445,10 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
c.String(http.StatusNotFound, "Surface not found")
|
||||
return
|
||||
}
|
||||
reqPath := c.Param("path")
|
||||
if reqPath == "" {
|
||||
reqPath = "/"
|
||||
}
|
||||
|
||||
if e.stores.Packages == nil {
|
||||
c.String(http.StatusNotFound, "Surface registry not available")
|
||||
@@ -427,28 +466,70 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if sr.Source == "core" {
|
||||
// Core surfaces have their own routes — don't serve them here
|
||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Multi-surface resolution ─────────────────────────────
|
||||
var surfacePath string
|
||||
var surfaceParams map[string]string
|
||||
surfaceTitle := sr.Title
|
||||
surfaceAccess := "authenticated"
|
||||
surfaceLayout := "single"
|
||||
|
||||
// Read package-level defaults
|
||||
if pkgAuth, ok := sr.Manifest["auth"].(string); ok && pkgAuth != "" {
|
||||
surfaceAccess = pkgAuth
|
||||
}
|
||||
if pkgLayout, ok := sr.Manifest["layout"].(string); ok && pkgLayout != "" {
|
||||
surfaceLayout = pkgLayout
|
||||
}
|
||||
|
||||
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
|
||||
matched, params := matchSurface(rawSurfaces, reqPath)
|
||||
if matched == nil {
|
||||
c.String(http.StatusNotFound, "Page not found")
|
||||
return
|
||||
}
|
||||
surfacePath, _ = matched["path"].(string)
|
||||
surfaceParams = params
|
||||
if t, ok := matched["title"].(string); ok && t != "" {
|
||||
surfaceTitle = t
|
||||
}
|
||||
if a, ok := matched["access"].(string); ok && a != "" {
|
||||
surfaceAccess = a
|
||||
}
|
||||
if l, ok := matched["layout"].(string); ok && l != "" {
|
||||
surfaceLayout = l
|
||||
}
|
||||
} else {
|
||||
// Legacy package without surfaces — only serve root path
|
||||
if reqPath != "/" {
|
||||
c.String(http.StatusNotFound, "Page not found")
|
||||
return
|
||||
}
|
||||
surfacePath = "/"
|
||||
}
|
||||
|
||||
// ── Access check ─────────────────────────────────────────
|
||||
if !evaluateAccess(c, surfaceAccess) {
|
||||
// Redirect unauthenticated users to login; deny others with 403
|
||||
if c.GetString("user_id") == "" {
|
||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
|
||||
} else {
|
||||
c.String(http.StatusForbidden, "Access denied")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
user := e.getUserContext(c)
|
||||
|
||||
// Build manifest from DB record
|
||||
route, _ := sr.Manifest["route"].(string)
|
||||
if route == "" {
|
||||
route = "/s/" + sr.ID
|
||||
}
|
||||
layout, _ := sr.Manifest["layout"].(string)
|
||||
if layout == "" {
|
||||
layout = "single"
|
||||
}
|
||||
manifest := &SurfaceManifest{
|
||||
ID: sr.ID,
|
||||
Route: route,
|
||||
Title: sr.Title,
|
||||
Route: "/s/" + sr.ID,
|
||||
Title: surfaceTitle,
|
||||
Template: "surface-extension",
|
||||
Layout: layout,
|
||||
Layout: surfaceLayout,
|
||||
Source: "extension",
|
||||
}
|
||||
|
||||
@@ -456,6 +537,8 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
Surface: sr.ID,
|
||||
User: user,
|
||||
Manifest: manifest,
|
||||
SurfacePath: surfacePath,
|
||||
SurfaceParams: surfaceParams,
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
ExtensionSurfaces: e.extensionNavItems(),
|
||||
BrowserExtensions: e.browserExtensionIDs(),
|
||||
@@ -463,6 +546,98 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// matchSurface finds the best-matching surface entry for a request path.
|
||||
// Supports Gin-style param segments (/:id matches /abc). Static segments
|
||||
// are preferred over param segments. Returns nil if no surface matches.
|
||||
func matchSurface(surfaces []any, reqPath string) (map[string]any, map[string]string) {
|
||||
reqParts := splitPath(reqPath)
|
||||
|
||||
type candidate struct {
|
||||
surface map[string]any
|
||||
params map[string]string
|
||||
score int // higher = more static segments = better match
|
||||
}
|
||||
|
||||
var best *candidate
|
||||
for _, raw := range surfaces {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pattern, _ := s["path"].(string)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
patParts := splitPath(pattern)
|
||||
if len(patParts) != len(reqParts) {
|
||||
continue
|
||||
}
|
||||
|
||||
params := map[string]string{}
|
||||
score := 0
|
||||
matched := true
|
||||
for i, pp := range patParts {
|
||||
if strings.HasPrefix(pp, ":") {
|
||||
params[pp[1:]] = reqParts[i]
|
||||
} else if pp == reqParts[i] {
|
||||
score++
|
||||
} else {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if best == nil || score > best.score {
|
||||
best = &candidate{surface: s, params: params, score: score}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return best.surface, best.params
|
||||
}
|
||||
|
||||
// splitPath splits a URL path into non-empty segments.
|
||||
// "/" → [], "/monitor" → ["monitor"], "/monitor/:id" → ["monitor", ":id"]
|
||||
func splitPath(p string) []string {
|
||||
var parts []string
|
||||
for _, s := range strings.Split(p, "/") {
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// evaluateAccess checks whether the current request meets an access requirement.
|
||||
func evaluateAccess(c *gin.Context, access string) bool {
|
||||
switch {
|
||||
case access == "public":
|
||||
return true
|
||||
case access == "authenticated":
|
||||
return c.GetString("user_id") != ""
|
||||
case access == "admin":
|
||||
return c.GetBool("is_admin")
|
||||
case strings.HasPrefix(access, "group:"):
|
||||
// Group membership check — look for groups set by auth middleware
|
||||
group := strings.TrimPrefix(access, "group:")
|
||||
if groups, exists := c.Get("user_groups"); exists {
|
||||
if gs, ok := groups.([]string); ok {
|
||||
for _, g := range gs {
|
||||
if g == group {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||
// Passed to RegisterPageRoutes by main.go.
|
||||
type PageRouteMiddleware struct {
|
||||
@@ -502,8 +677,9 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
||||
registerRoutes(group, s, handler)
|
||||
}
|
||||
|
||||
// No restart needed after installing new surfaces via admin API.
|
||||
group.GET("/s/:slug", e.RenderExtensionSurface())
|
||||
// Extension surfaces are registered separately via
|
||||
// RegisterExtensionRoutes (called from main.go) to unify
|
||||
// the /s/:slug route tree with the ext API dispatcher.
|
||||
}
|
||||
|
||||
// Admin surfaces — auth + admin role middleware
|
||||
@@ -553,6 +729,63 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
||||
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
||||
}
|
||||
|
||||
// RegisterExtensionRoutes registers the combined extension surface and ext API
|
||||
// routes. A single /s/:slug/*path catch-all dispatches between:
|
||||
// - /s/:slug/api/* → apiAuth middleware + extAPIHandler (JSON API)
|
||||
// - /s/:slug/* → surface handler with per-surface access checks (HTML)
|
||||
//
|
||||
// This avoids Gin route conflicts between the catch-all and the API sub-path.
|
||||
// Must be called AFTER RegisterPageRoutes (which handles core surfaces).
|
||||
func (e *Engine) RegisterExtensionRoutes(
|
||||
base *gin.RouterGroup,
|
||||
optAuth gin.HandlerFunc,
|
||||
apiAuth gin.HandlerFunc,
|
||||
extAPIHandler gin.HandlerFunc,
|
||||
) {
|
||||
surfaceHandler := e.RenderExtensionSurface()
|
||||
|
||||
sGroup := base.Group("/s/:slug")
|
||||
sGroup.Use(optAuth) // populate user context if token present; don't require it
|
||||
|
||||
// Root path: /s/:slug (no sub-path) — surface only
|
||||
sGroup.GET("", surfaceHandler)
|
||||
|
||||
// Sub-paths: /s/:slug/* — dispatch between API and surface
|
||||
sGroup.Any("/*path", func(c *gin.Context) {
|
||||
subPath := c.Param("path")
|
||||
|
||||
// API requests: /s/:slug/api/*
|
||||
if strings.HasPrefix(subPath, "/api/") || subPath == "/api" {
|
||||
apiAuth(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
// Rewrite the path param so the ext API handler sees the
|
||||
// API-relative path, e.g. "/api/items" → "/items"
|
||||
apiPath := strings.TrimPrefix(subPath, "/api")
|
||||
if apiPath == "" {
|
||||
apiPath = "/"
|
||||
}
|
||||
for i := range c.Params {
|
||||
if c.Params[i].Key == "path" {
|
||||
c.Params[i].Value = apiPath
|
||||
break
|
||||
}
|
||||
}
|
||||
extAPIHandler(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Surface requests: GET only
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.String(http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
surfaceHandler(c)
|
||||
})
|
||||
}
|
||||
|
||||
// surfaceHandler returns the appropriate Gin handler for a surface.
|
||||
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||
if s.ID == "workflow" {
|
||||
|
||||
175
server/pages/pages_surface_match_test.go
Normal file
175
server/pages/pages_surface_match_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── matchSurface tests ──────────────────────────────────────
|
||||
|
||||
func TestMatchSurface_StaticRoot(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
map[string]any{"path": "/submit", "title": "Submit"},
|
||||
}
|
||||
s, params := matchSurface(surfaces, "/")
|
||||
if s == nil {
|
||||
t.Fatal("expected match for /")
|
||||
}
|
||||
if s["title"] != "Dashboard" {
|
||||
t.Errorf("expected Dashboard, got %v", s["title"])
|
||||
}
|
||||
if len(params) != 0 {
|
||||
t.Errorf("expected no params, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_StaticPath(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
map[string]any{"path": "/submit", "title": "Submit"},
|
||||
map[string]any{"path": "/monitor", "title": "Monitor"},
|
||||
}
|
||||
s, _ := matchSurface(surfaces, "/submit")
|
||||
if s == nil {
|
||||
t.Fatal("expected match for /submit")
|
||||
}
|
||||
if s["title"] != "Submit" {
|
||||
t.Errorf("expected Submit, got %v", s["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_ParamPath(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
map[string]any{"path": "/:id", "title": "Detail"},
|
||||
}
|
||||
s, params := matchSurface(surfaces, "/abc123")
|
||||
if s == nil {
|
||||
t.Fatal("expected match for /:id")
|
||||
}
|
||||
if s["title"] != "Detail" {
|
||||
t.Errorf("expected Detail, got %v", s["title"])
|
||||
}
|
||||
if params["id"] != "abc123" {
|
||||
t.Errorf("expected id=abc123, got %v", params["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_MultiSegmentParam(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/monitor", "title": "Monitor"},
|
||||
map[string]any{"path": "/monitor/:id", "title": "Instance Detail"},
|
||||
}
|
||||
s, params := matchSurface(surfaces, "/monitor/inst-42")
|
||||
if s == nil {
|
||||
t.Fatal("expected match for /monitor/:id")
|
||||
}
|
||||
if s["title"] != "Instance Detail" {
|
||||
t.Errorf("expected Instance Detail, got %v", s["title"])
|
||||
}
|
||||
if params["id"] != "inst-42" {
|
||||
t.Errorf("expected id=inst-42, got %v", params["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_NoMatch(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
map[string]any{"path": "/submit", "title": "Submit"},
|
||||
}
|
||||
s, _ := matchSurface(surfaces, "/nonexistent/deep")
|
||||
if s != nil {
|
||||
t.Errorf("expected no match, got %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_StaticPreferredOverParam(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/:id", "title": "Detail"},
|
||||
map[string]any{"path": "/new", "title": "New"},
|
||||
}
|
||||
s, params := matchSurface(surfaces, "/new")
|
||||
if s == nil {
|
||||
t.Fatal("expected match for /new")
|
||||
}
|
||||
if s["title"] != "New" {
|
||||
t.Errorf("expected static match 'New', got %v", s["title"])
|
||||
}
|
||||
if len(params) != 0 {
|
||||
t.Errorf("expected no params for static match, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSurface_EmptySurfaces(t *testing.T) {
|
||||
s, _ := matchSurface([]any{}, "/")
|
||||
if s != nil {
|
||||
t.Errorf("expected no match for empty surfaces, got %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── splitPath tests ─────────────────────────────────────────
|
||||
|
||||
func TestSplitPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"/", 0},
|
||||
{"/submit", 1},
|
||||
{"/monitor/:id", 2},
|
||||
{"/:id/edit", 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
parts := splitPath(tt.input)
|
||||
if len(parts) != tt.want {
|
||||
t.Errorf("splitPath(%q) = %d parts, want %d", tt.input, len(parts), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── findNavSurface tests ────────────────────────────────────
|
||||
|
||||
func TestFindNavSurface_ExplicitNav(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
map[string]any{"path": "/monitor", "title": "Monitor", "nav": true},
|
||||
}
|
||||
s := findNavSurface(surfaces)
|
||||
if s == nil {
|
||||
t.Fatal("expected nav surface")
|
||||
}
|
||||
if s["title"] != "Monitor" {
|
||||
t.Errorf("expected Monitor, got %v", s["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNavSurface_FallbackRoot(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/submit", "title": "Submit"},
|
||||
map[string]any{"path": "/", "title": "Dashboard"},
|
||||
}
|
||||
s := findNavSurface(surfaces)
|
||||
if s == nil {
|
||||
t.Fatal("expected nav surface")
|
||||
}
|
||||
if s["title"] != "Dashboard" {
|
||||
t.Errorf("expected Dashboard, got %v", s["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNavSurface_NoMatch(t *testing.T) {
|
||||
surfaces := []any{
|
||||
map[string]any{"path": "/submit", "title": "Submit"},
|
||||
map[string]any{"path": "/admin", "title": "Admin"},
|
||||
}
|
||||
s := findNavSurface(surfaces)
|
||||
if s != nil {
|
||||
t.Errorf("expected nil, got %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── evaluateAccess tests ────────────────────────────────────
|
||||
// Note: evaluateAccess depends on gin.Context which is hard to unit test
|
||||
// without a full Gin setup. These are covered via handler integration tests.
|
||||
// The matchSurface + splitPath + findNavSurface functions above are the
|
||||
// pure-logic components that benefit most from unit testing.
|
||||
@@ -122,6 +122,8 @@
|
||||
{{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot
|
||||
populates sw.auth.user and sw.auth.policies from API. */}}
|
||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||
{{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}}
|
||||
{{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}}
|
||||
</script>
|
||||
|
||||
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
|
||||
|
||||
Reference in New Issue
Block a user