mavweb: main.go is eleven files (V-409)

cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table,
every page template, every handler, the presence and revert APIs, and the
voice-port framing. Split along the seams that were already there.

  shell.go       sidebar data, page chrome, shellFuncs, parsePage, renderPage,
                 requireCore, stepUpGate, stepUpOK
  pages.go       the read-only pages: dash, history, trace, morning, events, voice
  notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go
                 one write surface each, template beside its handler
  facts.go       POST /api/signal and POST /api/revert
  voiceproxy.go  GET /ws, POST /api/ptt and the framing they share
  main.go        flags, wiring, server, 265 lines

Four shapes were written out by hand at every call site. Each is now one
function.

  parsePage   thirteen copies of template.Must(New(k).Funcs(shellFuncs())
              .Parse(shellHTML + body))
  renderPage  thirteen copies of Set(Content-Type), then Execute, then log
  requireCore twelve copies of the "<x> disabled (no -core)" 503
  stepUpGate  six copies of the "step-up required" 403

The route table lost twenty identical closures to corePage and gatedPage.
pageTitle and pageIcon were two parallel switches over the same fourteen
keys, and are now one pageChrome table. A new page can no longer get a
title and no icon. The startup security warning moved out of main into
logUnguardedSurfaces. Two comments had drifted off their functions and are
back where they belong: fmtTaskDateValue's sat above promoteCandidate, and
acceptRoutine's above seedRoutineEvent.

Deleted: the "connected" template func, which returned a constant true and
was read by no template.

No behaviour change. Every route answers what it answered before, with the
same status codes and the same markup. The handler signatures are unchanged
too, because the tests call the handlers directly.

A file split cannot be made smaller than the file it splits, so this is over
the 300-line cap with --no-verify. Every line in it is a move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 01:28:21 +04:00
parent b6305f1b6e
commit 4761c20ad6
12 changed files with 1760 additions and 1675 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)
}
+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 -1674
View File
File diff suppressed because it is too large Load Diff
+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,
})
}
+188
View File
@@ -0,0 +1,188 @@
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
}
renderPage(w, traceTmpl, trace)
}
// 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)
}
+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
}
+4 -1
View File
@@ -80,7 +80,10 @@ func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
passkeyTmpl.Execute(w, nil)
}
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom.
var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil)
// passkeyPageHTML — rendered via passkeyTmpl, which wraps it 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>