package main import ( "crypto/subtle" "encoding/json" "errors" "io" "log" "net/http" "strings" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) // POST /api/ambient — the work calendar read (Vikunja #126). // // Maven does not hold a work credential. A corp mail or calendar session on the // homelab ties the box's blast radius to the employer's data, so the work // calendar is read as a SIGNAL instead: an Android notification-listener on the // owner's phone posts meeting notifications here over wg/LAN, and the ones that // clearly describe a meeting become calendar events at source=ambient:notif, // confidence below 1.0. Mail as a notification signal, not a mailbox. // // Off unless configured: no -ambient-token, no route. The token is a shared // secret because the poster is a phone service, not a browser — WebAuthn has no // answer for a background Android service. The endpoint is write-only and // accepts exactly one shape of write; it cannot read anything back out. // // A notification with no recognisable clock reading stores NOTHING. Maven is // not a guesser-of-truth, and a mailbox of noise rendered as invented meetings // is worse than a gap. // ambientMaxBody bounds the request. A notification is two short lines. const ambientMaxBody = 8 << 10 type ambientResp struct { Stored bool `json:"stored"` Key string `json:"key,omitempty"` Reason string `json:"reason,omitempty"` } // handleAmbient ingests one relayed notification. token is the configured // shared secret; an empty token means the capability is off and the handler is // never registered, so it is treated as a hard failure here too. func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if token == "" { http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable) return } if !ambientAuthorized(r, token) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if core == nil { http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable) return } var n calendar.Notification body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody)) if err != nil { http.Error(w, "read failed", http.StatusBadRequest) return } if err := json.Unmarshal(body, &n); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if n.Posted.IsZero() { writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"}) return } ev, ok := calendar.EventFromNotification(n) if !ok { // Not an event. 202: the relay did its job, there is just nothing here // worth remembering, and it must not retry. writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"}) return } key := calendar.FactKey(ev) val := calendar.FactValue(ev) // Append-only discipline, same as cmd/mavcaldav: a phone reposts the same // notification many times, and each repost is the same event. if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val { writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"}) return } else if err != nil && !errors.Is(err, ipc.ErrNoFact) { log.Printf("ambient: read %s: %v", key, err) http.Error(w, "read failed", http.StatusBadGateway) return } // kind=env: an observation about the world, never a self-fact — a passive // signal does not write truth about the owner. Confidence below 1.0 is the // honest part: this is a notification about a meeting, not a reading of a // calendar, and the query path hedges when it recites one. if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{ Ts: ev.Start, Kind: "env", Key: key, Value: val, Source: calendar.SourceAmbient, Confidence: calendar.AmbientConfidence, }); err != nil { log.Printf("ambient: write %s: %v", key, err) http.Error(w, "write failed", http.StatusBadGateway) return } log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package) writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key}) } // ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token // header, compared in constant time. func ambientAuthorized(r *http.Request, token string) bool { got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) if got == "" { got = strings.TrimSpace(r.Header.Get("X-Maven-Token")) } return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 } func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(resp) }