Merge the mavweb file split (#217)

V-409. cmd/mavweb/main.go went from 1868 lines to 265, split along seams that
were already there: shell, pages, tasks, routines, the voice proxy, and one file
per write surface. The last two markup constants moved into passkey.html and
models.html, so no page markup lives in Go any more.

Four hand-written shapes became one function each. parsePage and renderPage had
13 copies, requireCore had 12, stepUpGate had 6. The route table lost about 20
identical closures. The parallel title and icon switches merged into one
pageChrome table, so a page can no longer have a title and no icon.

The server struct that would hold core, session and voiceAddr was left undone.
Four test files call the handlers with their full parameter lists, and those
signatures are contract.

Found and filed V-578: /events has no chrome entry, so its tab is lowercase and
its icon falls through to the default.

Conflict resolved by hand. V-564 grew /trace a turn-decisions table in
main.go while this branch was moving handleTrace to pages.go. The split file
wins and V-564's read plus traceData were ported onto it, so the page keeps
both tables.

--no-verify: the pre-commit hook refuses master, and this unattended run merges
to master by the owner's instruction.
This commit is contained in:
2026-08-06 01:34:06 +04:00
16 changed files with 1882 additions and 1797 deletions
+92
View File
@@ -0,0 +1,92 @@
package main
import (
_ "embed"
"log"
"net/http"
"net/url"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
//go:embed chat.html
var chatPageHTML string
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
// and the handler redirects back to /chat with the response.
var chatTmpl = parsePage("chat", chatPageHTML, nil)
// chatMsg — one message in the conversation history.
type chatMsg struct {
Role string // "user" | "assistant"
Text string
// Source — the query source that claimed the turn, shown as a badge beside
// the reply. Empty for a turn no source claimed (V-539).
Source string
}
// handleChatPage renders the chat conversation page.
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "chat") {
return
}
msgs := []chatMsg{}
// Read user message + reply from query params (set by /api/chat redirect).
if q := r.URL.Query().Get("q"); q != "" {
msgs = append(msgs, chatMsg{Role: "user", Text: q})
}
if reply := r.URL.Query().Get("r"); reply != "" {
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
}
renderPage(w, chatTmpl, struct {
Error string
Messages []chatMsg
}{Messages: msgs})
}
// handleChatAPI processes a chat message POST and redirects back to /chat.
//
// State-changing, and the widest surface on this server: the text reaches the
// router, the LLM, and through mavend's applyAction the whole action path
// including `act` — so it is gated on the same step-up as POST /tools and
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
// denies, which is the point of that flag.
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "chat") {
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
http.Redirect(w, r, "/chat", http.StatusSeeOther)
return
}
// One conversation id for the whole web chat, and a different one from
// telegram or the mic. A parked question belongs to the reach that was
// asked; before this, a clarify nobody answered on the web ate the next
// utterance spoken at the mic (Vikunja #466). This server has no
// per-browser session, so every browser tab is the same conversation —
// which is right for a single-owner box.
reply, err := core.Chat(r.Context(), "web", text)
if err != nil {
log.Printf("chat api: %v", err)
http.Redirect(w, r, "/chat", http.StatusSeeOther)
return
}
// The claiming query source rides back on the redirect so the page can show
// it. Empty for a turn no source claimed, which is most of them.
dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply)
if reply.Source != "" {
dest += "&s=" + url.QueryEscape(reply.Source)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
+1 -4
View File
@@ -117,8 +117,5 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core
d.Calls.Rows = rows
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ecosystemTmpl.Execute(w, d); err != nil {
log.Printf("ecosystem render: %v", err)
}
renderPage(w, ecosystemTmpl, d)
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// The two fact-writing API routes: POST /api/signal appends a presence
// observation, POST /api/revert voids the latest fact for a key. Neither
// renders a page.
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
// network-facing surface inside wg; an allowlist keeps a compromised caller
// boxed to forging weak presence signals (reachability, multi-source, never
// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a
// per-signal token belongs here if the tunnel ever hosts untrusted devices.
var presenceSignals = map[string]string{
"desk_active": "infer:hyprland",
"page_heartbeat": "infer:heartbeat",
"wg_handshake": "infer:wg",
}
// handleSignal ingests one presence signal and writes a fresh fact through
// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value
// is a marker. Only allowlisted keys are accepted (see presenceSignals).
func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "presence ingest") {
return
}
key := r.URL.Query().Get("key")
source, ok := presenceSignals[key]
if !ok {
http.Error(w, "unknown signal key", http.StatusBadRequest)
return
}
// kind=env: an observation about the device/surface, NOT a self-fact — a
// passive signal never writes truth about you (spec), it only feeds
// presence. confidence 1.0: the reading ("input happened") is certain;
// presence applies its own per-signal weight/decay on top.
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
Ts: time.Now(),
Kind: "env",
Key: key,
Value: `"active"`,
Source: source,
Confidence: 1.0,
}); err != nil {
log.Printf("signal %s: %v", key, err)
http.Error(w, "write failed", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
}
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "revert") {
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
key := strings.TrimSpace(r.FormValue("key"))
if key == "" {
http.Error(w, "key required", http.StatusBadRequest)
return
}
newID, err := core.RevertFact(r.Context(), key)
if err != nil {
log.Printf("revert %q: %v", key, err)
if errors.Is(err, ipc.ErrNoFact) {
http.Error(w, "no fact to revert", http.StatusNotFound)
return
}
http.Error(w, "revert failed", http.StatusBadGateway)
return
}
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID})
}
+71 -1691
View File
File diff suppressed because it is too large Load Diff
+5 -41
View File
@@ -2,8 +2,8 @@ package main
import (
"context"
_ "embed"
"errors"
"html/template"
"log"
"net/http"
"strconv"
@@ -31,43 +31,10 @@ type modelController interface {
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
//go:embed models.html
var modelsHTML string
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
<section class=card>
<h2 class=card-title>swap not configured</h2>
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
</section>
{{else}}
<section class=card>
<h2 class=card-title>loaded now</h2>
<div class=scroll><table>
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
</table></div>
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
</section>
<section class=card>
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
<td><form method=post action=/models class=inline-form>
<input type=hidden name=model_path value="{{.}}">
<button class=btn>load this one</button></form></td></tr>{{end}}
</table></div>
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
</section>
{{end}}
{{template "shellBottom"}}`
var modelsTmpl = parsePage("models", modelsHTML, nil)
type modelsPage struct {
Msg string
@@ -154,8 +121,5 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
}
}
page.Status = st
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := modelsTmpl.Execute(w, page); err != nil {
log.Printf("models render: %v", err)
}
renderPage(w, modelsTmpl, page)
}
+35
View File
@@ -0,0 +1,35 @@
{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
<section class=card>
<h2 class=card-title>swap not configured</h2>
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
</section>
{{else}}
<section class=card>
<h2 class=card-title>loaded now</h2>
<div class=scroll><table>
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
</table></div>
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
</section>
<section class=card>
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
<td><form method=post action=/models class=inline-form>
<input type=hidden name=model_path value="{{.}}">
<button class=btn>load this one</button></form></td></tr>{{end}}
</table></div>
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
</section>
{{end}}
{{template "shellBottom"}}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
_ "embed"
"log"
"net/http"
"strconv"
"github.com/kami/maven/internal/ipc"
)
//go:embed notifications.html
var notificationsHTML string
var notificationsTmpl = parsePage("notifications", notificationsHTML, nil)
// deliveryRow is one outbox line, with every timestamp already formatted so
// the template holds no date logic — same shape as taskRow.
type deliveryRow struct {
Kind string
Target string
Channel string
Status string
Created string
Completed string
}
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
out := make([]deliveryRow, 0, len(as))
for _, a := range as {
target := a.Rule
if target == "" && a.ReminderID != 0 {
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
}
row := deliveryRow{
Kind: a.Kind,
Target: target,
Channel: a.Channel,
Status: a.Status,
Created: a.Created.Format("02.01 15:04"),
}
if a.Completed != nil {
row.Completed = a.Completed.Format("15:04")
}
out = append(out, row)
}
return out
}
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "notifications") {
return
}
ctx := r.Context()
nudges, err := core.RecentNudges(ctx, 50)
if err != nil {
log.Printf("notifications: %v", err)
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
return
}
// The outbox, on the page that already answers "what did she send".
// A failed or dropped attempt is why she went quiet, and until now it was
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
status := r.URL.Query().Get("status")
attempts, err := core.DeliveryAttempts(ctx, status, 50)
if err != nil {
// The nudge list is still worth showing, so this is a note on the page
// rather than a dead page.
log.Printf("notifications: delivery attempts: %v", err)
}
renderPage(w, notificationsTmpl, map[string]any{
"Nudges": nudges,
"Attempts": deliveryRows(attempts),
"Status": status,
})
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"cmp"
_ "embed"
"html/template"
"log"
"net/http"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
)
// The read-only pages: dash, history, trace, morning, events, and the voice
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
// The write surfaces live next to their own handlers (tasks.go, tools.go,
// routines.go, chat.go).
//go:embed dash.html
var dashHTML string
//go:embed history.html
var historyHTML string
//go:embed trace.html
var traceHTML string
//go:embed morning.html
var morningHTML string
//go:embed events.html
var eventsHTML string
//go:embed voice.html
var voiceHTML string
//go:embed ecosystem.html
var ecosystemHTML string
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = parsePage("dash", dashHTML, nil)
var historyTmpl = parsePage("history", historyHTML, nil)
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
"fmtTime": func(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Format("15:04:05")
},
"join": strings.Join,
})
// morningTmpl — read-only view of today's checklist state per configured
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
var morningTmpl = parsePage("morning", morningHTML, nil)
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
var eventsTmpl = parsePage("events", eventsHTML, nil)
var voiceTmpl = parsePage("voice", voiceHTML, nil)
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "dash") {
return
}
ctx := r.Context()
pres, err1 := core.Presence(ctx)
facts, err2 := core.RecentFacts(ctx, 50)
nudges, err3 := core.RecentNudges(ctx, 50)
notes, err4 := core.RecentNotes(ctx, 50)
if err := cmp.Or(err1, err2, err3, err4); err != nil {
log.Printf("dash: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
renderPage(w, dashTmpl, struct {
Presence ipc.Presence
Facts []ipc.Fact
Nudges []ipc.Nudge
Notes []ipc.Note
}{pres, facts, nudges, notes})
}
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "history") {
return
}
facts, err := core.RecentFacts(r.Context(), 200)
if err != nil {
log.Printf("history: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
renderPage(w, historyTmpl, struct {
Facts []ipc.Fact
}{facts})
}
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "trace") {
return
}
trace, err := core.TickTrace(r.Context())
if err != nil {
log.Printf("trace: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
// The turn records share this page rather than getting one of their own
// (V-564). Both answer the same question, who won and who lost and why, and
// one is about nudges while the other is about utterances. A read failure
// here is not fatal to the page. The rule trace above it still renders, and
// a daemon too old to know the method is the ordinary case during a rolling
// deploy.
turns, err := core.TurnDecisions(r.Context(), 25)
if err != nil {
log.Printf("trace: turn decisions: %v", err)
}
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
}
// traceData — what trace.html renders: the last tick's rule arbitration and the
// last turns' claim arbitration.
type traceData struct {
Tick ipc.TickTrace
Turns []ipc.TurnDecision
}
// morningView — what /morning renders: today's plan on top, the checklist
// state under it. PlanErr is set instead of Plan when the core could not build
// a plan, so the page says so rather than showing an empty day.
type morningView struct {
Plan *ipc.DayPlan
PlanErr string
Routines []ipc.MorningRoutineStatus
}
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "morning") {
return
}
ctx := r.Context()
status, err := core.MorningStatus(ctx)
if err != nil {
log.Printf("morning: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
view := morningView{Routines: status}
// The day plan (#128) shows on this page because it is the same question at
// a different scale. A plan read that fails must not take the checklist
// down with it — the page degrades to what it had before.
plan, err := core.DayPlan(ctx)
if err != nil {
log.Printf("morning: day plan: %v", err)
view.PlanErr = err.Error()
} else {
view.Plan = &plan
}
renderPage(w, morningTmpl, view)
}
// eventsView — what /events renders. Err is set instead of Events when the
// core could not serve the journal, so the page says why rather than showing an
// empty intake and implying nothing arrived.
type eventsView struct {
Events []ipc.IntakeEvent
Err string
}
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
// page is for scanning what just happened, not for archaeology.
const eventsPageLimit = 200
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "intake journal") {
return
}
var view eventsView
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
if err != nil {
log.Printf("events: %v", err)
view.Err = err.Error()
} else {
view.Events = evs
}
renderPage(w, eventsTmpl, view)
}
func handleVoice(w http.ResponseWriter, r *http.Request) {
renderPage(w, voiceTmpl, nil)
}
+58
View File
@@ -0,0 +1,58 @@
{{template "shellTop" "passkey"}}
<h1>Passkey</h1>
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
<div class=flex gap-2>
<button class=btn onclick=enroll()>enroll passkey</button>
<button class=btn onclick=assert()>assert (step-up)</button>
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
<a href=/tools><button class=btn-primary>→ tools</button></a>
</div>
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
<div id=msg></div>
{{template "shellBottom"}}
<script>
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
async function enroll(){try{
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
options.challenge=ub64(options.challenge);
options.user.id=ub64(options.user.id);
const c=await navigator.credentials.create({publicKey:options});
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
// The wrapped key can only be written from an assertion: PRF results are
// not produced at create() time on most authenticators. Enrolment reports
// whether PRF is available at all so he is not told cold-start works when
// it cannot.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prfOK=!!(ext.prf&&ext.prf.enabled);
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
}catch(e){say('enroll error: '+e,false);}}
async function assert(explicit){try{
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
options.challenge=ub64(options.challenge);
const c=await navigator.credentials.get({publicKey:options});
// The PRF result is the cold-start secret. It never touches localStorage
// and is posted once, over the same request as the assertion.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
signature:b64u(c.response.signature)}}})});
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
'stepped up ✓ — enable tools now',true);
}catch(e){say('assert error: '+e,false);}}
// Rewriting the wrapped key is a separate gesture, never a side effect of a
// step-up. Only this button sets explicit, and only explicit lets the daemon
// replace a blob that already exists.
async function rewrapKey(){
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
await assert(true);}
</script>
+74
View File
@@ -0,0 +1,74 @@
package main
import (
_ "embed"
"encoding/json"
"log"
"net/http"
"strings"
"github.com/kami/maven/internal/ipc"
)
//go:embed reminders.html
var remindersHTML string
var remindersTmpl = parsePage("reminders", remindersHTML, nil)
// reminderRow is one line on /reminders, with the payload unwrapped and both
// timestamps already in his clock.
//
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
// shape he never chose, and a time on a page he reads is the time on his wall.
type reminderRow struct {
Created string
Fires string
Status string
Text string
}
// reminderText unwraps the {"text":...} payload the router writes.
//
// A copy of store.ReminderText rather than a call to it, because mavweb is one
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
// whoever renders it. Payload that is not that shape is shown as he said it.
func reminderText(payload string) string {
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if t, ok := m["text"]; ok {
if s, isStr := t.(string); isStr && s != "" {
return s
}
}
}
return strings.TrimSpace(payload)
}
func reminderRows(rs []ipc.Reminder) []reminderRow {
out := make([]reminderRow, 0, len(rs))
for _, r := range rs {
out = append(out, reminderRow{
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
Status: r.Status,
Text: reminderText(r.Payload),
})
}
return out
}
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "reminders") {
return
}
reminders, err := core.ListReminders(r.Context(), 50)
if err != nil {
log.Printf("reminders: %v", err)
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
return
}
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
_ "embed"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/webauthn"
)
//go:embed routines.html
var routinesHTML string
// routinesTmpl — the proposed-routine review surface. One row per thing maven
// noticed, in her words, with at most two actions: accept or dismiss.
var routinesTmpl = parsePage("routines", routinesHTML, nil)
// routineView is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it. A view model, not a database row — the template
// never formats an interval or a timestamp itself.
type routineView struct {
ID int64
Phrase string
Noticed string
}
// handleRoutines serves the routine review surface (GET) and answers a
// proposal (POST id + action=accept|dismiss).
//
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
// hands the trigger loop a new standing reason to speak to the human, so it
// moves the boundary and only an authed surface may do it. Dismiss is not
// gated — it only ever removes a reason to speak, so the worst a weaker caller
// can do is make maven quieter.
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if !requireCore(w, core, "routines") {
return
}
ctx := r.Context()
var msg string
if r.Method == http.MethodPost {
var ok bool
if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok {
return
}
}
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
log.Printf("routines: list: %v", err)
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
return
}
renderPage(w, routinesTmpl, struct {
Msg string
Proposed []routineView
}{msg, toRoutineViews(proposed)})
}
// applyRoutinePost performs one write and returns the message to show. Unlike
// the task form, a bad request here is an HTTP status rather than an inline
// note, so the second return says whether the response was already written.
func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) {
ctx := r.Context()
action := r.FormValue("action")
// "seed" is the one action with no routine to act on — it is what
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
// lives on this route rather than a page of its own because it is
// already the step-up-gated surface for this table, and a second gated
// surface is a second thing to get wrong.
if action == "seed" {
if !stepUpGate(w, session, requireStepUp) {
return "", false
}
out, err := seedRoutineEvent(ctx, core, r)
if err != nil {
log.Printf("routines: seed: %v", err)
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
return "", false
}
return out, true
}
idStr := r.FormValue("id")
var rid int64
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
http.Error(w, "invalid id", http.StatusBadRequest)
return "", false
}
switch action {
case "accept":
if !stepUpGate(w, session, requireStepUp) {
return "", false
}
if err := acceptRoutine(ctx, core, rid); err != nil {
log.Printf("routines: accept %d: %v", rid, err)
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
return "", false
}
return "accepted routine — maven will remind you", true
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
return "", false
}
return "dismissed routine", true
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return "", false
}
}
// toRoutineViews turns the wire rows into view models. The phrase comes from
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
out := make([]routineView, 0, len(rs))
for _, r := range rs {
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
noticed := "just now"
if r.CreatedTs > 0 {
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
}
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
}
return out
}
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
// may do it (Vikunja #367): accepting gives the tick loop a standing new
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
// behind step-up. Voice can park the question and dismiss, never accept.
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
return err
}
var found *ipc.ProposedRoutine
for i := range proposed {
if proposed[i].ID == id {
found = &proposed[i]
break
}
}
if found == nil {
return errors.New("no such proposed routine")
}
// No reminder is created here. Accepting only flips the status; the tick
// loop reads accepted routines and nudges on the interval (Vikunja #366).
// The old code made a one-shot reminder, so a non-weekly routine fired
// once and then went quiet forever.
return core.AcceptProposedRoutine(ctx, id)
}
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
// so the pattern detector can be exercised against a running daemon instead of
// over real days. Refused unless mavend was started with -allow-seed; on an
// ordinary box the error says so and nothing is written.
//
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
// so a QA sitting can space four seeds three hours apart without doing clock
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
// meaning now.
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
key := strings.TrimSpace(r.FormValue("key"))
value := strings.TrimSpace(r.FormValue("value"))
if key == "" || value == "" {
return "", errors.New("seed needs a key and a value")
}
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
if err != nil {
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
}
if agoHours < 0 {
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
}
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
Key: key,
Value: value,
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
})
if err != nil {
return "", err
}
if !resp.Extracted {
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
}
if !resp.Proposed {
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
}
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
}
+194
View File
@@ -0,0 +1,194 @@
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"},
"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, core ipc.CoreAPI, surface string) bool {
if core == nil {
http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable)
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, session *webauthn.PasskeySession, requireStepUp bool) bool {
if stepUpOK(session, requireStepUp) {
return true
}
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
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()
}
+408
View File
@@ -0,0 +1,408 @@
package main
import (
"context"
_ "embed"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/tasks"
)
//go:embed tasks.html
var tasksHTML string
var tasksTmpl = parsePage("tasks", tasksHTML, nil)
// now — the wall clock, indirected so the task page can be rendered at a fixed
// instant in a test. internal/tasks is pure and the daemon path already ranks
// through a clock it is handed; the page had no reason to be the one surface
// that could only be tested at whatever time it happened to run.
var now = time.Now
// resolvedShown — how many finished tasks the page renders. The list is
// history, it only grows, and the rows below the first screen are read by
// nobody.
const resolvedShown = 50
// taskRow is one line on /tasks, with every timestamp already formatted so the
// template holds no date logic.
type taskRow struct {
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
ResolvedBy string
// DueValue and Weight are the raw values the edit form posts back
// (Vikunja #509). Due above is for reading and says "—" for no date; a
// date input needs "2026-08-07" or the empty string.
DueValue string
Weight int
// Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest
// rendering: he never said this one mattered more.
Why string
}
// rowOf renders one wire task into the shared read-only columns. The two call
// sites below add what only they need: the live rows carry the edit form's raw
// values and the ranker's reason, the resolved rows carry neither.
func rowOf(t ipc.Task) taskRow {
return taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
ResolvedBy: t.ResolvedBy,
}
}
// handleTasks serves the task review surface (GET) and the five writes it
// offers (POST): add, edit, confirm, done, drop.
//
// Not step-up gated, unlike /tools and /routines, and the difference is the
// point: enabling a tool defines argv Maven will execute, and accepting a
// routine hands the tick loop a new standing reason to interrupt him. A task is
// neither — nothing in the tick loop reads the tasks table, so the worst a
// weaker caller can do here is write a line onto a list he reads himself. It
// still sits behind whatever transport auth fronts mavweb, like every other
// page.
//
// "edit" was re-argued on the same terms rather than inheriting the exemption
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
// himself, the same blast radius "drop" already has on this page, and the store
// refuses the two edits that would cost something: a resolved task keeps the
// text it was finished under, and a text collision with another live row is
// named instead of merged.
//
// "confirm" is the only interesting move: it promotes a candidate Maven derived
// from something she read into work he owns. That review step is why derived
// tasks are captured as candidates in the first place.
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "tasks") {
return
}
ctx := r.Context()
var msg, errMsg string
if r.Method == http.MethodPost {
var err error
msg, err = applyTaskPost(ctx, core, r)
if err != nil {
log.Printf("tasks: %v", err)
errMsg = err.Error()
}
}
all, err := core.ListTasks(ctx, "")
if err != nil {
log.Printf("tasks: list: %v", err)
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
return
}
// Live rows are ordered by the same ranker the spoken list uses, so the page
// and the voice reply can never disagree about what comes first. Resolved
// rows keep store order (newest first) — ranking finished work is pointless.
var live []tasks.Item
var resolved []taskRow
resolvedTotal := 0
for _, t := range all {
switch t.Status {
case "candidate", "open":
live = append(live, tasks.Item{
ID: t.ID, Text: t.Text, Status: t.Status,
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
})
default:
resolvedTotal++
// Finished work is history, and the history only grows. The page
// showed every row that ever existed, which is a page that gets
// slower every month for a section nobody reads past the top of.
if len(resolved) >= resolvedShown {
continue
}
resolved = append(resolved, rowOf(t))
}
}
byID := make(map[int64]ipc.Task, len(all))
for _, t := range all {
byID[t.ID] = t
}
var cands, open []taskRow
for _, r := range tasks.Rank(live, now()) {
t := byID[r.ID]
row := rowOf(t)
row.DueValue = fmtTaskDateValue(t.Due)
row.Weight = t.Weight
row.Why = r.Reason
if t.Status == "candidate" {
// A candidate's due date is Maven's reading of a mail, so its
// ranking reason is not shown as if he had set a priority.
row.Why = ""
cands = append(cands, row)
} else {
open = append(open, row)
}
}
renderPage(w, tasksTmpl, struct {
Msg, Err string
Stalls []tasks.Stall
Candidates []taskRow
Open []taskRow
Resolved []taskRow
ResolvedMore bool
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
}
// applyTaskPost performs one write and returns the message to show. A bad
// request returns an error, which the page renders inline rather than as a
// bare 400 — this is a form surface, not an API.
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
action := r.FormValue("action")
if action == "add" {
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
wgt, err := formWeight(r)
if err != nil {
return "", err
}
req.Weight = wgt
due, err := formDue(r, now())
if err != nil {
return "", err
}
req.Due = due
resp, err := core.CaptureTask(ctx, req)
if err != nil {
return "", err
}
if resp.Promoted {
return "confirmed a candidate maven had found", nil
}
if !resp.Created {
return "already on the list", nil
}
return "added task", nil
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
return "", errors.New("invalid id")
}
if action == "promote" {
msg, err := promoteCandidate(ctx, core, r, id)
if err != nil {
return "", err
}
return msg, nil
}
if action == "edit" {
// The three fields capture set, and only those (Vikunja #509). Status
// is not editable here: that ladder is one-way and has its own buttons.
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
wgt, err := formWeight(r)
if err != nil {
return "", err
}
due, err := formDue(r, now())
if err != nil {
return "", err
}
switch err := core.EditTask(ctx, id, text, due, wgt); {
case err == nil:
return "saved task", nil
case errors.Is(err, ipc.ErrTaskDuplicate):
// Naming the collision instead of merging: two live rows carry two
// provenances, and picking one is not the page's call.
return "", errors.New("another open task already says this — drop one of the two")
case errors.Is(err, ipc.ErrTaskResolved):
return "", errors.New("a resolved task keeps the text it was finished under")
default:
return "", err
}
}
var status, msg string
switch action {
case "confirm":
status, msg = "open", "confirmed task"
case "done":
status, msg = "done", "task done"
case "drop":
status, msg = "dropped", "dropped task"
default:
return "", fmt.Errorf("unknown action %q", action)
}
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
return "", statusWriteErr(err)
}
return msg, nil
}
// errNoDoneWhen — the refusal has to name what is missing, or the button looks
// broken. The field it asks for arrives with the intake form (Vikunja #511).
var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate")
// statusWriteErr translates a SetTaskStatus failure into what the page says.
func statusWriteErr(err error) error {
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
return errNoDoneWhen
}
return err
}
// promoteCandidate turns a candidate into open work with the three things the
// board needs (Vikunja #511): a definition of done, an optional blocker, and an
// optional date.
//
// The definition of done is required, and the refusal is the store's — this
// only reaches it in a readable order. The blocker is a NAME here and an entity
// id in the row: identity lives in Nexus, so the name is resolved first and a
// name Nexus cannot resolve stops the promotion instead of being stored.
//
// A date set here writes a reminder, which is the one unprompted delivery the
// persona allows: he asked to be told, on a day he named.
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
if doneWhen == "" {
return "", errors.New("write a definition of done — what has to be true for this to be finished")
}
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
due, err := formDue(r, now())
if err != nil {
return "", err
}
blockedOn, err := resolveBlocker(ctx, core, r.FormValue("blocked_on"))
if err != nil {
return "", err
}
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
return "", err
}
if due != nil {
wgt, err := formWeight(r)
if err != nil {
return "", err
}
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
return "", err
}
}
if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil {
return "", statusWriteErr(err)
}
if due == nil {
return "confirmed", nil
}
// A date-only field has no hour. Nine in the morning, because the reminder
// is about a day's work and being told at midnight is being told the night
// before.
fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location())
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
// The task IS promoted; only the reminder failed. Saying "confirmed"
// and nothing else would leave him expecting a nudge that will not come.
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
}
return "confirmed, and maven will remind you that morning", nil
}
// resolveBlocker turns the blocked-on NAME the form posts into the entity id
// the row stores. Identity lives in Nexus, so an unresolvable name stops the
// promotion instead of being written as free text. An empty field is no
// blocker and reaches Nexus not at all.
func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) {
name := strings.TrimSpace(field)
if name == "" {
return "", nil
}
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
switch {
case errors.Is(err, ipc.ErrNotImplemented):
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
case errors.Is(err, ipc.ErrNoEntity):
return "", fmt.Errorf("nexus does not know %q", name)
case err != nil:
return "", fmt.Errorf("resolving %q: %w", name, err)
case ref.Ambiguous:
// Asking, not picking: a task blocked on the wrong person is a
// mistake nobody can see afterwards.
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
}
return ref.ID, nil
}
// formWeight reads the importance select. Out-of-range clamps rather than
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
// because strconv is not Sscanf and "3junk" is not a 3.
func formWeight(r *http.Request) (int, error) {
v := r.FormValue("weight")
if v == "" {
return 0, nil
}
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return 0, fmt.Errorf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
wgt = tasks.MaxWeight
}
return wgt, nil
}
// formDue reads the date input. An empty field is nil, which on an edit means
// "clear the date" — the form has no other way to say it.
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
d := r.FormValue("due")
if d == "" {
return nil, nil
}
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
if err != nil {
return nil, fmt.Errorf("bad due date %q", d)
}
return &due, nil
}
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
func fmtTaskDateValue(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Local().Format("2006-01-02")
}
func fmtTaskTime(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Local().Format("02 Jan 15:04")
}
func fmtTaskDate(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Local().Format("02 Jan")
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"cmp"
_ "embed"
"html/template"
"log"
"net/http"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/webauthn"
)
//go:embed tools.html
var toolsHTML string
// toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form
// POSTs back to /tools to enable a proposal. html/template escapes tool names +
// utterances (they came from voice STT — untrusted text).
var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{
"join": strings.Join,
"capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() },
"risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) },
})
// handleTools serves the enable surface (GET) and applies an enable (POST).
// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
// shell-word parsing; the box owner controls this input, quote a wrapper script
// if an arg needs spaces).
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if !requireCore(w, core, "tools") {
return
}
ctx := r.Context()
var msg string
if r.Method == http.MethodPost {
if !stepUpGate(w, session, requireStepUp) {
return
}
action := r.FormValue("action")
name := strings.TrimSpace(r.FormValue("name"))
switch action {
case "enable":
scope := r.FormValue("scope")
cmd := strings.Fields(r.FormValue("cmd"))
destructive := r.FormValue("destructive") != ""
if name == "" || len(cmd) == 0 {
http.Error(w, "name and cmd required", http.StatusBadRequest)
return
}
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
log.Printf("tools: enable %q: %v", name, err)
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "enabled " + name
case "disable":
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if err := core.DisableTool(ctx, name); err != nil {
log.Printf("tools: disable %q: %v", name, err)
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "disabled " + name
case "dismiss":
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if err := core.DeleteTool(ctx, name); err != nil {
log.Printf("tools: dismiss %q: %v", name, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "dismissed " + name
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
}
proposed, err1 := core.ListTools(ctx, "proposed")
enabled, err2 := core.ListTools(ctx, "enabled")
if err := cmp.Or(err1, err2); err != nil {
log.Printf("tools: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
// MCP is off by default and an older core may not know the method at all,
// so a failure here renders an empty section rather than breaking the page.
servers, err := core.MCPServers(ctx)
if err != nil {
log.Printf("tools: mcp servers: %v", err)
servers = nil
}
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
// flat list stops answering "what can she do to the house" somewhere
// around fifteen rows, and that is the question this page exists for.
renderPage(w, toolsTmpl, struct {
Msg string
Proposed []ipc.Tool
Enabled []ipc.Tool
Groups []tool.CapabilityGroup
MCP []ipc.MCPServerStatus
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers})
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"time"
"github.com/coder/websocket"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
// The two proxies onto mavend's voice port: GET /ws streams turns over a
// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same
// step-up gate, because speaking an act is not a smaller act than typing one
// (Vikunja #317). The length-prefixed framing they share is at the bottom.
// maxFrame caps a single voice frame in either direction.
const maxFrame = 64 << 20
// pushToTalk builds the one request either proxy sends. Surface is
// SurfacePCClient for both: the browser is standing in for the PC client.
func pushToTalk(pcm []byte) voice.Request {
return voice.Request{
ID: uint64(time.Now().UnixNano()),
Method: voice.MethodPushToTalk,
Params: mustMarshal(voice.PushToTalkReq{
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
Lang: "mixed",
Surface: voice.SurfacePCClient,
}),
}
}
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if !stepUpGate(w, session, requireStepUp) {
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
log.Printf("ws accept: %v", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
ctx := r.Context()
var d net.Dialer
tc, err := d.DialContext(ctx, "tcp", voiceAddr)
if err != nil {
log.Printf("dial voice: %v", err)
writeWSErr(conn, ctx, "voice unavailable")
return
}
defer tc.Close()
for {
_, msg, err := conn.Read(ctx)
if err != nil {
log.Printf("ws read: %v", err)
return
}
if len(msg) < 4 {
log.Printf("ws msg too short (%d bytes)", len(msg))
continue
}
log.Printf("ws got %d bytes from client", len(msg))
req := pushToTalk(msg)
if err := writeFrame(tc, &req); err != nil {
log.Printf("write voice req: %v", err)
return
}
// Read frames until we get the matching Response (handling any interleaved Pushes)
for {
resp, push, err := readOneFrame(tc)
if err != nil {
log.Printf("read voice: %v", err)
return
}
if push != nil {
data, _ := json.Marshal(push)
conn.Write(ctx, websocket.MessageText, data)
continue
}
if resp.Error != nil {
writeWSErr(conn, ctx, resp.Error.Message)
break
}
var pttResp voice.PushToTalkResp
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
log.Printf("unmarshal resp: %v", err)
break
}
if pttResp.ReplyText != "" {
conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
}
if len(pttResp.ReplyAudio.Bytes) > 0 {
conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
}
break
}
}
}
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", 405)
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
if len(body) < 4 {
http.Error(w, "too short", 400)
return
}
log.Printf("ptt got %d bytes from client", len(body))
var d net.Dialer
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
if err != nil {
log.Printf("ptt dial voice: %v", err)
http.Error(w, "voice unavailable", 503)
return
}
defer tc.Close()
req := pushToTalk(body)
if err := writeFrame(tc, &req); err != nil {
log.Printf("ptt write: %v", err)
http.Error(w, err.Error(), 500)
return
}
for {
resp, push, err := readOneFrame(tc)
if err != nil {
log.Printf("ptt read: %v", err)
http.Error(w, err.Error(), 500)
return
}
if push != nil {
continue
}
if resp.Error != nil {
http.Error(w, resp.Error.Message, 500)
return
}
var pttResp voice.PushToTalkResp
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
// as "+", which is form encoding, and the client decodes this header
// with decodeURIComponent, which only knows "%20" — so every space in a
// spoken reply reached the on-page log as a plus sign. PathEscape is the
// flavour decodeURIComponent actually reverses, which keeps the encoding
// a property of the header rather than something the client has to know.
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
w.Write(pttResp.ReplyAudio.Bytes)
return
}
}
func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
}
func writeFrame(w io.Writer, v any) error {
body, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if len(body) > maxFrame {
return fmt.Errorf("frame too large: %d", len(body))
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err = w.Write(body)
return err
}
func readFrame(r io.Reader, v any) error {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > maxFrame {
return fmt.Errorf("frame too large: %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
return json.Unmarshal(buf, v)
}
func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
var raw struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"r,omitempty"`
Error *voice.RpcError `json:"e,omitempty"`
Kind voice.PushKind `json:"kind,omitempty"`
Params json.RawMessage `json:"p,omitempty"`
}
if err := readFrame(r, &raw); err != nil {
return nil, nil, err
}
if raw.Kind != "" && raw.ID == 0 {
return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
}
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
}
func mustMarshal(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
+10 -61
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
@@ -76,69 +77,17 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
// on: assert here (bumps the daemon session to L3 for the assertion TTL), then
// enable a tool on /tools within that window.
func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
passkeyTmpl.Execute(w, nil)
renderPage(w, passkeyTmpl, nil)
}
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom.
const passkeyPageHTML = `{{template "shellTop" "passkey"}}
<h1>Passkey</h1>
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
<div class=flex gap-2>
<button class=btn onclick=enroll()>enroll passkey</button>
<button class=btn onclick=assert()>assert (step-up)</button>
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
<a href=/tools><button class=btn-primary>→ tools</button></a>
</div>
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
<div id=msg></div>
{{template "shellBottom"}}
<script>
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
async function enroll(){try{
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
options.challenge=ub64(options.challenge);
options.user.id=ub64(options.user.id);
const c=await navigator.credentials.create({publicKey:options});
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
// The wrapped key can only be written from an assertion: PRF results are
// not produced at create() time on most authenticators. Enrolment reports
// whether PRF is available at all so he is not told cold-start works when
// it cannot.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prfOK=!!(ext.prf&&ext.prf.enabled);
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
}catch(e){say('enroll error: '+e,false);}}
async function assert(explicit){try{
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
options.challenge=ub64(options.challenge);
const c=await navigator.credentials.get({publicKey:options});
// The PRF result is the cold-start secret. It never touches localStorage
// and is posted once, over the same request as the assertion.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
signature:b64u(c.response.signature)}}})});
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
'stepped up ✓ — enable tools now',true);
}catch(e){say('assert error: '+e,false);}}
// Rewriting the wrapped key is a separate gesture, never a side effect of a
// step-up. Only this button sets explicit, and only explicit lets the daemon
// replace a blob that already exists.
async function rewrapKey(){
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
await assert(true);}
</script>`
// passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl
// with shellTop/shellBottom. It was a Go string constant, which is the one
// place page markup still lived in Go.
//
//go:embed passkey.html
var passkeyPageHTML string
var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil)
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")