35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
158 lines
5.9 KiB
Go
158 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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 {
|
|
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
|
"POST only", nil)
|
|
return
|
|
}
|
|
if token == "" {
|
|
writeProblem(w, r, http.StatusServiceUnavailable, problemIntegrationOff,
|
|
"ambient ingest disabled (no -ambient-token)", nil)
|
|
return
|
|
}
|
|
if !ambientAuthorized(r, token) {
|
|
writeProblem(w, r, http.StatusUnauthorized, problemUnauthorized,
|
|
"unauthorized", nil)
|
|
return
|
|
}
|
|
if core == nil {
|
|
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
|
"ambient ingest disabled (no -core)", nil)
|
|
return
|
|
}
|
|
|
|
var n calendar.Notification
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
|
if err != nil {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"read failed", fmt.Errorf("read ambient request: %w", err))
|
|
return
|
|
}
|
|
if err := json.Unmarshal(body, &n); err != nil {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"bad json", fmt.Errorf("decode ambient request: %w", err))
|
|
return
|
|
}
|
|
if n.Posted.IsZero() {
|
|
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
|
"posted_at is required", nil)
|
|
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) {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
|
"read failed", fmt.Errorf("read ambient fact %q: %w", key, err))
|
|
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 {
|
|
writeProblem(w, r, http.StatusBadGateway, problemCoreWriteFailed,
|
|
"write failed", fmt.Errorf("write ambient fact %q: %w", key, err))
|
|
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)
|
|
}
|