Version, authenticate and fully trace ecosystem calls #84

Merged
claude merged 135 commits from overnight/eco-versioned-traces into master 2026-08-01 14:50:26 +02:00
4 changed files with 189 additions and 6 deletions
Showing only changes of commit 4e4c9170e3 - Show all commits
+16 -1
View File
@@ -30,6 +30,13 @@ import (
// 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.
//
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
// meeting is good enough to recite and not good enough to stop a nudge —
// calendar_busy is still written only by the CalDAV poller. That is backwards,
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
// calendar_busy is a level rather than an event, so an ambient writer needs an
// expiry, which is its own task and not a change here.
// ambientMaxBody bounds the request. A notification is two short lines.
const ambientMaxBody = 8 << 10
@@ -120,8 +127,16 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
// 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 := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
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"))
}
+21
View File
@@ -166,6 +166,27 @@ func TestHandleAmbientAuth(t *testing.T) {
}
})
// RFC 7235 says the scheme is case-insensitive. A phone sending
// "bearer <tok>" used to fall through to the X-Maven-Token branch and get a
// 401 that looked, from the phone's side, like a wrong token.
t.Run("lowercase bearer scheme accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
}
})
// A bare token with no scheme is not a bearer header. Accepting it made the
// Authorization branch a second, undocumented X-Maven-Token.
t.Run("bare token in Authorization rejected", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
})
t.Run("X-Maven-Token accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
+61 -5
View File
@@ -36,13 +36,38 @@ type Notification struct {
Posted time.Time `json:"posted_at"`
}
// ambientPastGrace — how far before the notification a derived start may sit
// before the event is refused.
//
// The date is not in the clock reading, so it is inferred, and the inference is
// only safe while the event is still roughly now. A 21:00 reminder reading
// "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the
// past, and FactKey would file that wrong meeting under today's date. Storing a
// wrong meeting is the one outcome this file exists to avoid, so anything this
// stale is dropped instead. The grace covers the ordinary case of a phone
// reposting a notification for a meeting already under way.
const ambientPastGrace = 2 * time.Hour
// dayWords maps the words that move a notification off Posted's day. Only
// explicit ones: an offset is a claim about which day, and guessing which day
// is exactly the guess this parse refuses to make.
var dayWords = map[string]int{
"завтра": 1,
"tomorrow": 1,
"сегодня": 0,
"today": 0,
"tonight": 0,
"послезавтра": 2,
}
// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
// It needs two things: a clock reading, and a summary that is not just that
// clock reading. Everything else is defaulted — the date is Posted's day (a
// meeting notification is about today or it would not be firing now), and a
// bare start time gets DefaultReminderDuration.
// clock reading. The date comes from Posted's day, shifted by an explicit day
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
func EventFromNotification(n Notification) (Event, bool) {
if n.Posted.IsZero() {
return Event{}, false
@@ -57,9 +82,14 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{}, false
}
y, m, d := n.Posted.Date()
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
loc := n.Posted.Location()
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
// Too far in the past to be the meeting this notification is about. The day
// was inferred, so the honest reading is that the inference was wrong.
if s.Before(n.Posted.Add(-ambientPastGrace)) {
return Event{}, false
}
var e time.Time
if end != nil {
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
@@ -73,12 +103,38 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{Summary: summary, Start: s, End: e}, true
}
// dayOffset reports how many days off Posted's day the notification puts the
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
func dayOffset(line string) int {
for _, f := range strings.Fields(strings.ToLower(line)) {
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
if off, ok := dayWords[f]; ok {
return off
}
}
return 0
}
// stripDayWords removes the day word from a summary candidate. It named the
// date, which now lives in Start, and leaving it in makes "Завтра Планёрка"
// the name of the meeting.
func stripDayWords(s string) string {
out := make([]string, 0, 8)
for _, f := range strings.Fields(s) {
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
continue
}
out = append(out, f)
}
return strings.Join(out, " ")
}
// notificationSummary picks the text that names the meeting: the title when it
// carries words, otherwise the body. The clock reading is stripped out — it
// already lives in the times, and FactValue renders it again.
func notificationSummary(n Notification) string {
for _, cand := range []string{n.Title, n.Text} {
s := strings.TrimSpace(stripClock(cand))
s := strings.TrimSpace(stripDayWords(stripClock(cand)))
s = strings.Trim(s, " \t-–—,;:@|·")
s = strings.Join(strings.Fields(s), " ")
if hasLetters(s) {
+91
View File
@@ -110,6 +110,97 @@ func TestEventFromNotification(t *testing.T) {
}
}
// A notification is not always about today. A 21:00 reminder reading
// "Tomorrow at 09:00" used to be dated to the notification's own day, which put
// the meeting twelve hours in the past and filed it under today in FactKey. A
// wrong meeting stored is worse than nothing stored.
func TestEventFromNotificationDayWords(t *testing.T) {
loc := time.FixedZone("+04", 4*3600)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
tests := []struct {
name string
title, text string
posted time.Time
wantOK bool
wantDay int // day of month
wantSummary string
}{
{
name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup",
},
{
name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
},
{
name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
},
{
name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00",
posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро",
},
{
name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30",
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон",
},
// No day word: the 09:00 is twelve hours behind the notification, so the
// inferred day is wrong and there is nothing honest to store.
{
name: "stale morning time with no day word", title: "Standup", text: "at 09:00",
posted: evening, wantOK: false,
},
// Inside the grace: a phone reposting the notification for a meeting
// already under way must still store it.
{
name: "meeting already running", title: "Планёрка", text: "20:30-22:00",
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Package: "com.google.android.calendar",
Title: tt.title, Text: tt.text, Posted: tt.posted,
})
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
if !ok {
return
}
if got := ev.Start.Day(); got != tt.wantDay {
t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start)
}
if ev.Summary != tt.wantSummary {
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
}
if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) {
t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted)
}
})
}
}
// The day word named the date, which now lives in Start. Leaving it in the
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
})
if !ok {
t.Fatal("expected an event")
}
if ev.Summary != "Планёрка" {
t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка")
}
}
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
t.Error("a notification with no posted_at has no date to sit on")