cc72f69769
The ambient endpoint writes calendar_event_* and never calendar_busy, so a notification-derived meeting was good enough to recite out loud and not good enough to stop a nudge during it. Backwards: being wrong here costs one nudge. The loop gatherer now derives busy from the event facts themselves, so the expiry IS the meeting's span. No new level, no interval to choose, and no way for the suppression to outlive the meeting. calendar.FactSpan reads back what FactValue wrote; anything that does not parse says nothing about now.
150 lines
5.5 KiB
Go
150 lines
5.5 KiB
Go
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.
|
|
//
|
|
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
|
|
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
|
|
// family and asks whether any span covers the instant. So there is no ambient
|
|
// calendar_busy and no expiry to pick — a level needs one and an event carries
|
|
// its own. calendar_busy stays the CalDAV poller's key.
|
|
|
|
// 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.
|
|
//
|
|
// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone
|
|
// client sending "bearer <tok>" used to fall through to the X-Maven-Token
|
|
// branch and get a silent 401 with nothing to see from the phone's side.
|
|
func ambientAuthorized(r *http.Request, token string) bool {
|
|
got := ""
|
|
if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") &&
|
|
strings.EqualFold(authz[:len("Bearer")], "Bearer") {
|
|
got = strings.TrimSpace(authz[len("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)
|
|
}
|