// 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 | 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 | 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, `Error

%d

%s

`, 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) }