49f089d8a6
Maven does not get a work credential. A corp mail or calendar session living on the homelab ties the box's blast radius to the employer's data, which is the thing this task exists to refuse. What she reads instead is the signal: an Android notification-listener on the phone relays meeting notifications over wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become calendar events at source=ambient:notif, confidence 0.6. The provenance is the point. A notification is evidence about a meeting, not a reading of a calendar, so it is never indistinguishable from one: it is stored below full confidence, store.CalendarEvents keeps the source and confidence on every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for a relayed event, plain text for a CalDAV read. The parse is deliberately conservative (internal/calendar/ambient.go). It needs a real clock reading and a summary that is not just that clock reading; otherwise it stores nothing at all. A bare hour is not a time, an unread count is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose digits in a notification are far more often a badge or a date, and a mailbox of noise rendered as invented meetings is worse than a gap. The ingest is off unless configured: no -ambient-token, no route registered. The token is a shared secret compared in constant time, because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts one shape of write, and cannot read anything back out. Reposts of the same notification dedupe against the latest fact for that key+source, the same append-only discipline cmd/mavcaldav follows. Not shipped: the Android relay app itself, which is a separate artifact and a device, not Go in this repo.
136 lines
4.8 KiB
Go
136 lines
4.8 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.
|
|
|
|
// 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)
|
|
}
|