package main import ( _ "embed" "html/template" "log" "net/http" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/webauthn" ) // shellHTML — the shell partial every page is wrapped in: "shellTop", the // "sidebar" it calls, and "shellBottom". It used to be two Go string constants // with the sidebar assembled by a strings.Builder, which is the one piece of // markup that was still concatenated in Go. // // Two template pieces wrap every page: // // {{template "shellTop" ""}} ← opens , topbar, sidebar, content // {{template "shellBottom"}} ← closes content, inspector, // // The page-key argument highlights the active sidebar link and sets breadcrumbs. // //go:embed shell.html var shellHTML string // sidebarSections maps sidebar section → page entries {label, url, icon} var sidebarSections = []struct { Label string Pages []struct{ Label, URL, Key string } }{ { Label: "Workspace", Pages: []struct{ Label, URL, Key string }{ {Label: "Dashboard", URL: "/dash", Key: "dash"}, }, }, { Label: "Infrastructure", Pages: []struct{ Label, URL, Key string }{ {Label: "History", URL: "/history", Key: "history"}, }, }, { Label: "Automation", Pages: []struct{ Label, URL, Key string }{ {Label: "Rule Trace", URL: "/trace", Key: "trace"}, {Label: "Notifications", URL: "/notifications", Key: "notifications"}, {Label: "Tasks", URL: "/tasks", Key: "tasks"}, {Label: "Reminders", URL: "/reminders", Key: "reminders"}, {Label: "Routines", URL: "/routines", Key: "routines"}, {Label: "Morning", URL: "/morning", Key: "morning"}, {Label: "Intake", URL: "/events", Key: "events"}, }, }, { Label: "Ecosystem", Pages: []struct{ Label, URL, Key string }{ {Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"}, }, }, { Label: "AI", Pages: []struct{ Label, URL, Key string }{ {Label: "Chat", URL: "/chat", Key: "chat"}, {Label: "Voice", URL: "/", Key: "voice"}, }, }, { Label: "Settings", Pages: []struct{ Label, URL, Key string }{ {Label: "Tools", URL: "/tools", Key: "tools"}, {Label: "Model", URL: "/models", Key: "models"}, {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, }, }, } // pageChrome is the per-page title and ethos-icons.svg symbol id, keyed by the // page key a page hands to shellTop. One table rather than two parallel // switches, so a new page cannot end up with a title and no icon. var pageChrome = map[string]struct{ Title, Icon string }{ "dash": {"Dashboard", "i-grid"}, "history": {"History", "i-clock"}, "trace": {"Rule Trace", "i-wave"}, "notifications": {"Notifications", "i-bell"}, "tasks": {"Tasks", "i-grid"}, "reminders": {"Reminders", "i-calendar"}, "routines": {"Routines", "i-repeat"}, "morning": {"Morning Routines", "i-calendar"}, "events": {"Intake", "i-download"}, "chat": {"Chat", "i-message"}, "voice": {"Voice", "i-mic"}, "ecosystem": {"Ecosystem", "i-grid"}, "tools": {"Tools", "i-settings"}, "models": {"Resident Model", "i-wave"}, "passkey": {"Passkey", "i-lock"}, } // pageIcon returns the ethos-icons.svg symbol id for the given page. The // sidebar template wraps it in the reference. func pageIcon(key string) string { if c, ok := pageChrome[key]; ok { return c.Icon } return "i-search" } // pageTitle returns the human-readable page title for the given key. An // unknown key renders as itself rather than as a blank crumb. func pageTitle(key string) string { if c, ok := pageChrome[key]; ok { return c.Title } return key } // shellFuncs returns the FuncMap shared by every server-rendered page template. func shellFuncs() template.FuncMap { return template.FuncMap{ "pageTitle": pageTitle, "pageIcon": pageIcon, "sidebarSections": func() any { return sidebarSections }, "ago": func(t time.Time) string { if t.IsZero() { return "never" } return time.Since(t).Round(time.Second).String() + " ago" }, } } // parsePage parses one server-rendered page: the shell partial plus the page's // own embedded markup, under the shared FuncMap. extra adds page-local // functions (/tools needs capability lookups, /trace a time format) and may be // nil. // // The name is also the page's log label, so a render failure says which page. func parsePage(name, body string, extra template.FuncMap) *template.Template { funcs := shellFuncs() for k, v := range extra { funcs[k] = v } return template.Must(template.New(name).Funcs(funcs).Parse(shellHTML + body)) } // renderPage writes one page. Every handler sent the same content type and // logged the same way on failure; the header is already written by then, so a // render error can only be logged, never reported. func renderPage(w http.ResponseWriter, t *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.Execute(w, data); err != nil { log.Printf("%s render: %v", t.Name(), err) } } // requireCore answers whether the surface has a core to read. mavweb runs // without -core (voice-only), and every page that needs mavend says so with a // 503 naming itself rather than a blank error. func requireCore(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, surface string) bool { if core == nil { writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable, surface+" disabled (no -core)", nil) return false } return true } // stepUpGate reports whether the caller may proceed through the AuthStepUp // gate, writing the 403 itself when it may not. See stepUpOK for the policy. func stepUpGate(w http.ResponseWriter, r *http.Request, session *webauthn.PasskeySession, requireStepUp bool) bool { if stepUpOK(session, requireStepUp) { return true } writeProblem(w, r, http.StatusForbidden, problemStepUpRequired, "step-up required: assert a passkey first", nil) return false } // stepUpOK is the single decision point for the AuthStepUp gate shared by // POST /tools and POST /api/revert. // // A nil session means WebAuthn is not configured (-webauthn-origin / // -webauthn-rpid unset), so step-up can never be asserted — not merely unmet. // The default is therefore fail-OPEN: gating on an unassertable session would // 403 those surfaces permanently. In that mode the actions rest on the // transport-level auth in front of mavweb (wg+nginx+auth), and main logs a // startup warning naming them. With -require-stepup the same situation fails // CLOSED instead: no assertable step-up ⇒ deny. func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool { if session == nil { return !requireStepUp } return session.IsStepUp() }