package web import ( "bytes" "context" _ "embed" "html/template" "net/http" "strings" "sync" ) //go:embed layout.html var layoutHTML string // layout is the ONE base layout (SPEC-009 §4). A parse failure of a constant is // impossible; a non-nil error here would be a programming fault. var layout = template.Must(template.New("layout").Parse(layoutHTML)) // NavLister is the shell's navigation seam (SPEC-008 §7 / SPEC-009 §4): the // names of the ENABLED modules, in registration order. A disabled module's // entry is absent (D-038). *module.Registry satisfies it. Declaring the seam // here — rather than importing the registry — keeps this package a leaf. type NavLister interface { Nav() []string } var ( navMu sync.RWMutex navSrc NavLister ) // SetNav installs the process-wide navigation source. The entrypoint calls it // once at startup with the runtime module registry — the same single, explicit // wiring point as build-time discovery (D-028): the directory set is the source // of truth, and the shell reads the resulting nav. Until it is set (and in // tests) pages render without a nav. func SetNav(l NavLister) { navMu.Lock() navSrc = l navMu.Unlock() } // NavSource returns the installed nav source (nil when unset). func NavSource() NavLister { navMu.RLock() defer navMu.RUnlock() return navSrc } // Markup is page body HTML a caller has ALREADY escaped. The shell escapes // everything it interpolates except this type, so the conversion at the call // site is deliberate and greppable: escape first, then convert. type Markup = template.HTML // Attrs is an escaped attribute set for an element — the WebAuthn client's // data-* contract lives here. Every value interpolated into it MUST be escaped // by the caller. type Attrs = template.HTMLAttr // NavItem is one rendered navigation entry. type NavItem struct { // Label is the human label. Label string // Href is the target — /{GUID}/{module} inside a GUID context. Href string // Active marks the current page (aria-current). Active bool } // Page is one page rendered inside the shell. type Page struct { // Title is the document title (defaults to AppName). Title string // GUID is the identity riding the path. The nav renders only in a GUID // context; identity stays in the URL (D-001). GUID string // Guest marks an anonymous screen (enroll · login · setup · access): no nav // — there is no identity yet. Guest bool // Active is the PARAM2 module to mark as the current nav entry ("" none). Active string // Admin marks a signed-in administrator, so the shell shows the // role-gated nav entries (Users · Modules — SPEC-010 §3). A non-admin sees // Home and Account only. It is normally filled by PageFor from the shell // state the door attached; set it directly only in a unit test. Admin bool // PasskeyBanner shows the prominent post-setup "add a passkey" prompt // (SPEC-010 §6) while the signed-in user has no passkey and has not opted // out (and the origin is not loopback). The door decides; the shell draws. // It is normally filled by PageFor. PasskeyBanner bool // Flash is a notice or error banner ("" for none). Flash string // FlashKind is "error" for an error banner; anything else is a notice. FlashKind string // CSRF is the token every form — and the WebAuthn client — submits. It is // rendered once as . CSRF string // Content is the page body: HTML the caller has ALREADY escaped. Content template.HTML // MainAttrs are extra attributes for the
element — the WebAuthn // ceremony's data-* contract. The caller MUST escape them. MainAttrs template.HTMLAttr } // layoutData is the layout's input (the unexported shape of a Page). type layoutData struct { AppName string Title string CSRF string Flash string FlashKind string Nav []NavItem HomeHref string StaticPath string GUID string PasskeyBanner bool Content template.HTML MainAttrs template.HTMLAttr } // Render writes a page inside the shell. The caller is responsible for // Cache-Control (the enroll/login/ceremony screens set `no-store` — F-2); the // shell sets the content type and the status. // // The page is rendered to a buffer first: a template fault never leaves a // half-written body behind a 200. func Render(w http.ResponseWriter, status int, p Page) error { data := layoutData{ AppName: AppName, Title: strings.TrimSpace(p.Title), CSRF: p.CSRF, Flash: p.Flash, FlashKind: flashKind(p.FlashKind), StaticPath: StaticPath, Content: p.Content, MainAttrs: p.MainAttrs, } if data.Title == "" { data.Title = AppName } if p.Guest || strings.TrimSpace(p.GUID) == "" { data.HomeHref = "/" } else { data.HomeHref = "/" + p.GUID + "/home" data.GUID = p.GUID data.PasskeyBanner = p.PasskeyBanner data.Nav = navItems(p.GUID, p.Active, p.Admin) } var buf bytes.Buffer if err := layout.Execute(&buf, data); err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, err := w.Write(buf.Bytes()) return err } // flashKind normalises a banner kind for the class attribute. func flashKind(kind string) string { if strings.EqualFold(strings.TrimSpace(kind), "error") { return "error" } return "notice" } // navItems builds the nav from the CORE entries plus the installed NavLister // (SPEC-009 §4: a disabled module's entry is absent — D-038; SPEC-010 §3: the // core entries Home · Account · Users · Modules, role-gated). A duplicate // target — the registry's `home`, say — is collapsed onto the core entry, so // the nav never lists the same page twice. func navItems(guid, active string, admin bool) []NavItem { l := NavSource() if l == nil || guid == "" { return nil } items := coreNav(guid, active, admin) seen := make(map[string]bool, len(items)) for _, it := range items { seen[it.Href] = true } for _, name := range l.Nav() { name = strings.TrimSpace(name) if name == "" { continue } href := "/" + guid + "/" + name if seen[href] { continue // a core entry already covers this module (the dashboard) } seen[href] = true items = append(items, NavItem{ Label: ModuleLabel(name), Href: href, Active: name == active, }) } return items } // coreNav is the built-in navigation every app page carries (SPEC-010 §3): Home // and Account for everyone; Users and Modules for an admin. The admin entries // mirror the role gate (`server.Tiers`) — the door is the authority; this only // decides what the shell advertises. func coreNav(guid, active string, admin bool) []NavItem { items := []NavItem{ {Label: "Home", Href: "/" + guid + "/home", Active: active == "home"}, {Label: "Account", Href: "/" + guid + "/account", Active: active == "account"}, } if admin { items = append(items, NavItem{Label: "Users", Href: "/" + guid + "/users", Active: active == "users"}, NavItem{Label: "Modules", Href: "/" + guid + "/core/modules", Active: active == "core"}, ) } return items } // ── the per-request shell state (SPEC-010 §3/§6) ───────────────────────────── // // The door is the ONE place that knows the signed-in user's role and whether // the passkey banner applies (it already loaded the user for the role gate). It // attaches that decoration to the request context; a handler renders an // authenticated page with PageFor, which copies it onto the Page — so the nav // and the banner are consistent on every screen without each handler // re-deriving them. // shellState is the shell decoration for one authenticated request. type shellState struct { admin bool banner bool } // shellCtxKey is the unexported context key for the request's shell state. type shellCtxKey struct{} // WithShell attaches the signed-in user's shell state to the request context. // The door calls it once per authenticated request. func WithShell(ctx context.Context, admin, banner bool) context.Context { return context.WithValue(ctx, shellCtxKey{}, shellState{admin: admin, banner: banner}) } // ShellFromContext returns the shell state the door attached; the zero value // (no nav decoration, no banner) for an anonymous screen or an untouched test. func ShellFromContext(ctx context.Context) (admin, banner bool) { s, _ := ctx.Value(shellCtxKey{}).(shellState) return s.admin, s.banner } // PageFor fills the shell decoration — the role-gated nav and the passkey // banner — from the request context the door attached, and returns the Page. A // handler rendering an authenticated page writes: // // web.Render(w, http.StatusOK, web.PageFor(r, web.Page{...})) // // A guest page (Guest, or no GUID) is returned unchanged: there is no shell // state to apply. func PageFor(r *http.Request, p Page) Page { if r == nil || p.Guest || strings.TrimSpace(p.GUID) == "" { return p } p.Admin, p.PasskeyBanner = ShellFromContext(r.Context()) return p } // ModuleLabel renders a module name as a nav label: the first letter upcased // and separators read as spaces ("home" -> "Home", "time_off" -> "Time off"). func ModuleLabel(name string) string { s := strings.TrimSpace(name) if s == "" { return "" } s = strings.NewReplacer("-", " ", "_", " ").Replace(s) return strings.ToUpper(s[:1]) + s[1:] }