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/pages/pages.go
Jeffrey Smith 3ae959c8a9
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 2m50s
CI/CD / test-sqlite (pull_request) Successful in 3m1s
CI/CD / build-and-deploy (pull_request) Successful in 1m27s
Feat v0.9.1 server-side sub-path routing
Hardens multi-surface routing: unified root/catch-all dispatcher,
aggregateAccess() early auth short-circuit for all-authenticated
packages, SDK history.replaceState() for back-button resilience.

13 new tests (5 aggregateAccess unit + 8 handler integration).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:11:20 +00:00

1335 lines
38 KiB
Go

// Package pages provides server-side HTML rendering via Go templates.
//
// Templates are embedded in the binary via //go:embed and organized as:
//
// templates/base.html — outer shell (banner + surface block + scripts)
// templates/login.html — standalone login page
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
// templates/surfaces/*.html — one per surface (admin, settings, team-admin, extension)
package pages
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
"armature/config"
"armature/models"
"armature/store"
)
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html
var templateFS embed.FS
// Engine holds parsed templates and rendering state.
type Engine struct {
mu sync.RWMutex
templates *template.Template
cfg *config.Config
stores store.Stores
loaders map[string]DataLoaderFunc
surfaces []SurfaceManifest
devMode bool
}
// SurfaceManifest describes a surface's registration. Core surfaces are
// registered in Go at startup. Extension surfaces will be registered from
// manifest files (future).
type SurfaceManifest struct {
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/admin"
AltRoutes []string `json:"alt_routes"` // additional URL patterns
Title string `json:"title"` // human-readable: "Admin", "Settings"
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
Components []string `json:"components"` // component IDs used: ["file-tree"]
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
Styles []string `json:"styles"` // CSS files (surface-specific)
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
Source string `json:"source"` // "core" or "extension"
}
// BannerConfig holds environment banner settings.
type BannerConfig struct {
Text string `json:"text"`
Color string `json:"color"`
Background string `json:"background"`
Visible bool `json:"visible"`
}
// MessageConfig holds dismissible message bar settings.
type MessageConfig struct {
Text string `json:"text"`
Variant string `json:"variant"` // info, warn, error, success
Visible bool `json:"visible"`
}
// FooterConfig holds optional footer bar settings.
type FooterConfig struct {
Text string `json:"text"`
Visible bool `json:"visible"`
}
// PageData is passed to every template render.
type PageData struct {
Banner BannerConfig
Message MessageConfig
Footer FooterConfig
Surface string // active surface ID
Section string // sub-section (for admin pages)
CSPNonce string
BasePath string
Version string
Environment string
User *UserContext
Data any // surface-specific data from loader
Theme string // "dark", "light", or "" (= use default)
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
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:"-"`
ExtensionSurfaces []ExtensionNavItem `json:"-"`
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection)
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name
RegistrationOpen bool // whether self-registration is enabled
AuthMode string // "builtin", "mtls", "oidc"
}
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
// Used by templates: {{if .SurfaceEnabled "editor"}}
func (pd PageData) SurfaceEnabled(id string) bool {
for _, s := range pd.EnabledSurfaces {
if s == id {
return true
}
}
return false
}
// ExtensionNavItem holds the minimal info needed to render an extension
// surface link in the sidebar nav.
type ExtensionNavItem struct {
ID string
Title string
Route string
}
// ConfigSectionEntry describes a package-declared config section for
// lazy-loading in Settings, Admin, or Team Admin surfaces.
type ConfigSectionEntry struct {
PackageID string `json:"package_id"` // section key & asset path prefix
Label string `json:"label"` // nav link text
Icon string `json:"icon"` // optional SVG path data (admin CatIcon format)
Component string `json:"component"` // relative asset path, e.g. "js/config.js"
Category string `json:"category"` // admin only: category tab (default "system")
}
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
// Queries the DB at render time so newly installed surfaces appear immediately.
func (e *Engine) extensionNavItems() []ExtensionNavItem {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
}
var items []ExtensionNavItem
for _, sr := range surfaces {
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
}
basePath := "/s/" + sr.ID
title := sr.Title
// Multi-surface: find the nav entry from surfaces array
if sr.Manifest != nil {
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: 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.
func (e *Engine) browserExtensionIDs() []string {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
pkgs, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load browser extensions: %v", err)
return nil
}
var ids []string
for _, pkg := range pkgs {
if pkg.Enabled && pkg.Type == "extension" && pkg.Tier == "browser" {
ids = append(ids, pkg.ID)
}
}
return ids
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Email string `json:"email"`
}
// DataLoaderFunc loads surface-specific data for template rendering.
type DataLoaderFunc func(c *gin.Context, stores store.Stores) (any, error)
// New creates a template engine. Call after config and stores are ready.
func New(cfg *config.Config, stores store.Stores) *Engine {
e := &Engine{
cfg: cfg,
stores: stores,
loaders: make(map[string]DataLoaderFunc),
devMode: gin.Mode() == gin.DebugMode,
}
e.parseTemplates()
e.registerLoaders()
e.registerCoreSurfaces()
e.SeedSurfaces()
return e
}
// registerCoreSurfaces populates the surface manifest list with the kernel
// surfaces. Extension surfaces are appended from DB at startup.
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
DataRequires: []string{"admin"},
Layout: "single", Source: "core",
},
{
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
DataRequires: []string{"settings"},
Layout: "single", Source: "core",
},
{
ID: "team-admin", Route: "/team-admin/:section", AltRoutes: []string{"/team-admin"},
Title: "Team Admin", Template: "surface-team-admin", Auth: "authenticated",
DataRequires: []string{"team-admin"},
Layout: "single", Source: "core",
},
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
{
ID: "workflow-landing", Route: "/w/:id/:slug",
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
{
ID: "welcome", Route: "/welcome",
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core",
},
{
ID: "docs", Route: "/docs/:section", AltRoutes: []string{"/docs"},
Title: "Docs", Template: "surface-docs", Auth: "authenticated",
Layout: "single", Source: "core",
},
}
}
// GetSurface returns the manifest for a surface by ID, or nil if not found.
func (e *Engine) GetSurface(id string) *SurfaceManifest {
for i := range e.surfaces {
if e.surfaces[i].ID == id {
return &e.surfaces[i]
}
}
return nil
}
// Surfaces returns all registered surface manifests.
func (e *Engine) Surfaces() []SurfaceManifest {
return e.surfaces
}
// parseTemplates compiles all embedded templates with the custom FuncMap.
func (e *Engine) parseTemplates() {
funcMap := template.FuncMap{
"toJSON": toJSON,
"eq": func(a, b string) bool { return a == b },
"contains": stringContains,
"esc": template.HTMLEscapeString,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"join": strings.Join,
"dict": dict,
"or": tmplOr,
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/*.html",
"templates/components/*.html",
"templates/surfaces/*.html",
)
if err != nil {
log.Fatalf("❌ Failed to parse templates: %v", err)
}
e.mu.Lock()
e.templates = tmpl
e.mu.Unlock()
var names []string
for _, t := range tmpl.Templates() {
if t.Name() != "" {
names = append(names, t.Name())
}
}
log.Printf("[pages] Parsed %d templates", len(names))
}
// RegisterLoader adds a named data loader for a surface.
func (e *Engine) RegisterLoader(name string, fn DataLoaderFunc) {
e.loaders[name] = fn
}
// Render renders a named template into the response.
func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.BasePath = e.cfg.BasePath
data.Version = Version
data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
data.Message = e.loadMessage()
data.Footer = e.loadFooter()
if data.Theme == "" {
data.Theme = "dark"
}
e.mu.RLock()
tmpl := e.templates
e.mu.RUnlock()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
log.Printf("[pages] Template render error (%s): %v", name, err)
}
}
// RenderSurface is a Gin handler factory for surface routes.
func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
return func(c *gin.Context) {
user := e.getUserContext(c)
var data any
if loader, ok := e.loaders[surfaceID]; ok {
var err error
data, err = loader(c, e.stores)
if err != nil {
log.Printf("[pages] Data loader error (%s): %v", surfaceID, err)
c.String(http.StatusInternalServerError, "Failed to load page data")
return
}
}
section := c.Param("section")
if section == "" && surfaceID == "admin" {
section = "users"
}
if section == "" && surfaceID == "settings" {
section = "general"
}
if section == "" && surfaceID == "team-admin" {
section = "members"
}
if section == "" && surfaceID == "docs" {
section = "GETTING-STARTED"
}
e.Render(c, "base.html", PageData{
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}
// 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")
if slug == "" {
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")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
if !sr.Enabled {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
return
}
if sr.Source == "core" {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
// ── Early auth short-circuit ─────────────────────────────
// For all-authenticated packages, redirect before surface matching.
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
profile := aggregateAccess(rawSurfaces)
if profile == "authenticated" && c.GetString("user_id") == "" {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
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)
manifest := &SurfaceManifest{
ID: sr.ID,
Route: "/s/" + sr.ID,
Title: surfaceTitle,
Template: "surface-extension",
Layout: surfaceLayout,
Source: "extension",
}
e.Render(c, "base.html", PageData{
Surface: sr.ID,
User: user,
Manifest: manifest,
SurfacePath: surfacePath,
SurfaceParams: surfaceParams,
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}
// 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
}
// aggregateAccess scans surfaces to determine the package's access profile.
// Returns "public" (all public), "authenticated" (all require auth), or "mixed".
func aggregateAccess(surfaces []any) string {
hasPublic := false
hasAuth := false
for _, raw := range surfaces {
s, ok := raw.(map[string]any)
if !ok {
continue
}
access, _ := s["access"].(string)
if access == "" {
access = "authenticated" // default
}
if access == "public" {
hasPublic = true
} else {
hasAuth = true
}
if hasPublic && hasAuth {
return "mixed"
}
}
if hasPublic {
return "public"
}
return "authenticated"
}
// 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 {
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
Session gin.HandlerFunc // AuthOrSession — for workflow
}
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
// Replaces manual per-surface route blocks in main.go.
//
// Groups surfaces by auth level to avoid redundant middleware chains.
// Workflow surfaces use RenderWorkflow() (separate template + data model).
// All other surfaces use RenderSurface().
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
// Group surfaces by auth type
byAuth := map[string][]SurfaceManifest{}
for _, s := range e.surfaces {
byAuth[s.Auth] = append(byAuth[s.Auth], s)
}
// Authenticated surfaces — single middleware group
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
group := base.Group("")
group.Use(mw.Authenticated)
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
// Disabled surface → redirect to chat
registerRoutes(group, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
// Welcome auto-redirects to / when extension surfaces exist
if s.ID == "welcome" {
handler = e.welcomeRedirectIfSurfaces(handler)
}
registerRoutes(group, s, handler)
}
// 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
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
for _, s := range surfaces {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
for _, m := range mw.Admin {
group.Use(m)
}
// Admin surfaces are always enabled (needed to manage other surfaces)
handler := e.RenderSurface(s.ID)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Session surfaces (workflow) — session middleware
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
continue
}
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
handler := e.surfaceHandler(s)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Public surfaces — no middleware
if surfaces, ok := byAuth["public"]; ok {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
registerRoutes(base, s, e.disabledRedirect())
continue
}
handler := e.surfaceHandler(s)
registerRoutes(base, s, handler)
}
}
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
// Dispatcher handles both root and sub-path requests.
// API requests (/s/:slug/api/*) go to extAPIHandler with JWT auth.
// Everything else goes to the surface handler with per-surface access checks.
dispatch := func(c *gin.Context) {
subPath := c.Param("path")
if subPath == "" {
subPath = "/"
}
// 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)
}
// Root path: /s/:slug (Gin requires a separate registration since
// /*path doesn't match the bare group path)
sGroup.GET("", func(c *gin.Context) {
c.Params = append(c.Params, gin.Param{Key: "path", Value: "/"})
dispatch(c)
})
// Sub-paths: /s/:slug/*path — API and surface dispatch
sGroup.Any("/*path", dispatch)
}
// surfaceHandler returns the appropriate Gin handler for a surface.
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
if s.ID == "workflow-landing" {
return e.RenderWorkflowLanding()
}
return e.RenderSurface(s.ID)
}
// registerRoutes adds the primary route and alt routes for a surface.
// Used for surfaces in the root group (authenticated).
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
group.GET(s.Route, handler)
for _, alt := range s.AltRoutes {
if alt != s.Route {
group.GET(alt, handler)
}
}
}
// registerRoutesRelative adds routes relative to a group prefix.
// Used for admin/session surfaces with their own prefix group.
// "/admin/:section" with prefix "/admin" → "/:section"
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
rel := stripPrefix(s.Route, prefix)
group.GET(rel, handler)
for _, alt := range s.AltRoutes {
r := stripPrefix(alt, prefix)
if r != rel {
group.GET(r, handler)
}
}
}
// routePrefix extracts the first path segment as a group prefix.
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
func routePrefix(route string) string {
trimmed := strings.TrimPrefix(route, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
return ""
}
return "/" + parts[0]
}
// stripPrefix removes a prefix from a route path. Returns "" if the
// entire path IS the prefix (which Gin interprets as the group root).
func stripPrefix(route, prefix string) string {
if prefix == "" {
return route
}
rel := strings.TrimPrefix(route, prefix)
if rel == "" {
rel = ""
}
return rel
}
// RenderLogin serves the standalone login page.
func (e *Engine) RenderLogin() gin.HandlerFunc {
return func(c *gin.Context) {
instanceName, logoURL, tagline := e.loadBranding()
regOpen := e.isRegistrationOpen()
e.Render(c, "login.html", PageData{
Surface: "login",
InstanceName: instanceName,
LogoURL: logoURL,
Tagline: tagline,
RegistrationOpen: regOpen,
AuthMode: e.cfg.AuthMode,
})
}
}
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct {
EntryToken string
WorkflowTitle string
WorkflowDescription string
SessionID string
SessionName string
StageMode string // form | review | delegated | automated
StageName string
FormTemplateJSON string // typed form template JSON (empty if delegated)
TotalStages int
CurrentStage int
SurfacePkgID string
BrandingJSON string
InstanceID string // workflow instance ID (used for API calls)
Status string // pending | active | completed | cancelled | stale
AudienceMismatch bool // true when current stage audience is team/system but visitor is unauthenticated
}
// WorkflowLandingPageData is passed to workflow-landing.html.
type WorkflowLandingPageData struct {
WorkflowID string
Scope string
Slug string
Name string
Description string
Branding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
}
PersonaName string
PersonaIcon string
StageCount int
FirstStageMode string // form | review | delegated | automated
ResumeURL string // non-empty if visitor has an active session
}
// RenderWorkflow renders the workflow execution page for a running instance.
// Route: /w/:id — :id is either an instance ID or a public entry token.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
routeID := c.Param("id")
sessionID := c.GetString("session_id")
sessionName := "Visitor"
instanceName, _, _ := e.loadBranding()
// Try to load instance — the route param may be an instance ID
// or an entry token. Also check the ?token= query param.
var inst *models.WorkflowInstance
if e.stores.Workflows != nil {
// First try: query param token (from landing page redirect)
if qToken := c.Query("token"); qToken != "" {
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, qToken)
}
// Second try: route param as entry token
if inst == nil {
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, routeID)
}
// Third try: route param as instance ID
if inst == nil {
inst, _ = e.stores.Workflows.GetInstance(ctx, routeID)
}
}
if inst == nil {
c.String(http.StatusNotFound, "Workflow instance not found")
return
}
// Load workflow definition for title, description, branding
title, description := "Workflow", ""
var brandingJSON string
wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
if wf != nil {
title = wf.Name
description = wf.Description
if len(wf.Branding) > 0 {
brandingJSON = string(wf.Branding)
}
}
// Load stages to resolve current stage info
var stageMode, stageName, formTplJSON, surfacePkgID string
var totalStages, currentStage int
stageMode = "form" // default
stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
totalStages = len(stages)
for i, s := range stages {
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
currentStage = i
stageName = s.Name
stageMode = s.StageMode
if stageMode == "" {
stageMode = "form"
}
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
formTplJSON = string(s.FormTemplate)
}
if s.SurfacePkgID != nil {
surfacePkgID = *s.SurfacePkgID
}
break
}
}
// Resolve the actual entry token from the instance (used by JS API calls)
entryToken := ""
if inst.EntryToken != nil {
entryToken = *inst.EntryToken
}
// Detect audience mismatch: stage requires team/system but visitor is
// unauthenticated. Show a "submitted" screen instead of the stage form.
audienceMismatch := false
userID := c.GetString("user_id")
if userID == "" {
// Visitor is unauthenticated — check the current stage audience
for _, s := range stages {
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
if s.Audience == models.AudienceTeam || s.Audience == models.AudienceSystem {
audienceMismatch = true
}
break
}
}
}
e.Render(c, "workflow.html", PageData{
Surface: "workflow",
InstanceName: instanceName,
Data: WorkflowPageData{
EntryToken: entryToken,
WorkflowTitle: title,
WorkflowDescription: description,
SessionID: sessionID,
SessionName: sessionName,
StageMode: stageMode,
StageName: stageName,
FormTemplateJSON: formTplJSON,
TotalStages: totalStages,
CurrentStage: currentStage,
SurfacePkgID: surfacePkgID,
BrandingJSON: brandingJSON,
InstanceID: inst.ID,
Status: inst.Status,
AudienceMismatch: audienceMismatch,
},
})
}
}
// RenderWorkflowLanding renders the public landing page for a workflow.
// Visitors see the workflow name, description, persona info, and a Start button.
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
// :slug is the workflow slug. Same :id param name as /w/:id
// (chat surface) to avoid Gin wildcard conflicts.
scope := c.Param("id")
slug := c.Param("slug")
// Resolve scope to team_id
var teamID *string
if scope != "global" {
teamID = &scope
}
if e.stores.Workflows == nil {
c.String(http.StatusNotFound, "Workflows not available")
return
}
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
if err != nil || wf == nil {
c.String(http.StatusNotFound, "Workflow not found")
return
}
if !wf.IsActive {
c.String(http.StatusNotFound, "Workflow not available")
return
}
data := WorkflowLandingPageData{
WorkflowID: wf.ID,
Scope: scope,
Slug: slug,
Name: wf.Name,
Description: wf.Description,
}
// Parse branding
if len(wf.Branding) > 0 {
_ = json.Unmarshal(wf.Branding, &data.Branding)
}
// Load stages for count + first persona info + stage mode
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
data.StageCount = len(stages)
if len(stages) > 0 {
data.FirstStageMode = stages[0].StageMode
if data.FirstStageMode == "" {
data.FirstStageMode = "custom"
}
// Persona lookup deferred — personas are extensions now
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow-landing.html", PageData{
Surface: "workflow-landing",
InstanceName: instanceName,
Data: data,
})
}
}
// getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
userID, exists := c.Get("user_id")
if !exists {
return nil
}
uid := userID.(string)
uc := &UserContext{
ID: uid,
Email: c.GetString("email"),
Role: c.GetString("role"),
}
// Enrich from DB — username, display_name, and email may not be in JWT claims.
if e.stores.Users != nil {
if u, err := e.stores.Users.GetByID(c.Request.Context(), uid); err == nil && u != nil {
uc.Username = u.Username
uc.DisplayName = u.DisplayName
if uc.Email == "" {
uc.Email = u.Email
}
}
}
return uc
}
// loadBanner reads banner config from global settings.
func (e *Engine) loadBanner() BannerConfig {
if e.stores.GlobalConfig == nil {
return BannerConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "banner")
if err != nil || raw == nil {
return BannerConfig{}
}
b := BannerConfig{}
if v, ok := raw["enabled"].(bool); ok {
b.Visible = v
}
if v, ok := raw["text"].(string); ok {
b.Text = v
}
if v, ok := raw["fg"].(string); ok {
b.Color = v
}
if v, ok := raw["bg"].(string); ok {
b.Background = v
}
return b
}
// loadMessage reads dismissible message bar config from global settings.
func (e *Engine) loadMessage() MessageConfig {
if e.stores.GlobalConfig == nil {
return MessageConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
if err != nil || raw == nil {
return MessageConfig{}
}
m := MessageConfig{}
if v, ok := raw["enabled"].(bool); ok {
m.Visible = v
}
if v, ok := raw["text"].(string); ok {
m.Text = v
}
if v, ok := raw["variant"].(string); ok {
m.Variant = v
}
if m.Variant == "" {
m.Variant = "info"
}
return m
}
// loadFooter reads optional footer bar config from global settings.
func (e *Engine) loadFooter() FooterConfig {
if e.stores.GlobalConfig == nil {
return FooterConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
if err != nil || raw == nil {
return FooterConfig{}
}
f := FooterConfig{}
if v, ok := raw["enabled"].(bool); ok {
f.Visible = v
}
if v, ok := raw["text"].(string); ok {
f.Text = v
}
return f
}
// loadBranding reads instance branding from global settings.
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
name = "Armature" // default
if e.stores.GlobalConfig == nil {
return
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding")
if err != nil || raw == nil {
return
}
if v, ok := raw["instance_name"].(string); ok && v != "" {
name = v
}
if v, ok := raw["logo_url"].(string); ok {
logoURL = v
}
if v, ok := raw["tagline"].(string); ok {
tagline = v
}
return
}
// isRegistrationOpen checks if self-registration is enabled.
func (e *Engine) isRegistrationOpen() bool {
if e.stores.GlobalConfig == nil {
return false
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration")
if err != nil || raw == nil {
return false
}
if v, ok := raw["enabled"].(bool); ok {
return v
}
return false
}
// Version is injected at build time via ldflags.
var Version = "dev"
// SetVersion allows main.go to inject the build version.
func SetVersion(v string) {
Version = v
}
// WriteError writes an error page directly to the response.
func (e *Engine) WriteError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Error</title></head><body><h1>%d</h1><p>%s</p></body></html>`, status, template.HTMLEscapeString(msg))
}
// ── Template FuncMap helpers ─────────────────
func toJSON(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
}
func stringContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// dict creates a map from alternating key/value pairs for template use.
func dict(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs)-1; i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
}
// tmplOr returns the first non-empty string argument.
func tmplOr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "fallback-nonce"
}
return hex.EncodeToString(b)
}