35c6ff5a71
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.
198 lines
6.6 KiB
Go
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()
|
|
}
|