Files
Maven/cmd/mavweb/shell.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

198 lines
6.6 KiB
Go

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" "<page-key>"}} ← opens <html>, topbar, sidebar, content
// {{template "shellBottom"}} ← closes content, inspector, </html>
//
// 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 <use> 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()
}