diff --git a/internal/storage/db.go b/internal/storage/db.go index f9528a3..83842b2 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -117,6 +117,18 @@ func runMigrations(d *sql.DB) error { addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''") // Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots. addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0") + // Adventure alerts. A subscription made before they existed knows only the OIDC + // subject, and the adventure ownership join needs the Matrix localpart — so an + // existing row gets "" here and is skipped for owner-scoped alerts until the + // browser re-subscribes, which it does on every page load that has push on. + // Realm-wide alerts (the Siege) need no localpart and work immediately. + addColumnIfMissing(d, "push_subscriptions", "user_localpart", "TEXT NOT NULL DEFAULT ''") + // The adventure watermark is deliberately separate from last_notified_at: the + // digest and the alerts run on different clocks (6 hours vs 2 minutes), and + // sharing one column would let whichever ran last decide what the other had + // already seen. 0 on a pre-existing row is corrected to "now" on the first + // pass rather than replaying every dispatch Pete has ever stored. + addColumnIfMissing(d, "push_subscriptions", "last_adv_notified_at", "INTEGER NOT NULL DEFAULT 0") // FTS5 virtual tables don't support IF NOT EXISTS reliably. // Check sqlite_master before creating. diff --git a/internal/storage/push.go b/internal/storage/push.go index ed5c338..ae01341 100644 --- a/internal/storage/push.go +++ b/internal/storage/push.go @@ -5,29 +5,40 @@ import "fmt" // PushSubscription is one browser/device endpoint a signed-in user has opted in // for Web Push digests. See the push_subscriptions schema for the field roles. type PushSubscription struct { - Endpoint string - UserSub string - P256dh string - Auth string - CreatedAt int64 - LastNotifiedAt int64 + Endpoint string + UserSub string + Localpart string + P256dh string + Auth string + CreatedAt int64 + LastNotifiedAt int64 + LastAdvNotifiedAt int64 } // AddPushSubscription records (or refreshes) a push endpoint for a user. The // endpoint is the primary key, so a re-subscribe from the same browser updates -// the keys and resets the digest watermark to now — the user shouldn't be -// paged for everything published before they opted in. -func AddPushSubscription(sub, endpoint, p256dh, auth string) error { +// the keys and resets both watermarks to now — the user shouldn't be paged for +// everything published before they opted in. +// +// localpart is the session's Matrix handle, refreshed on every re-subscribe so a +// row stored by a build that predated adventure alerts heals itself the first +// time that browser subscribes again. It may legitimately be empty (a session +// minted before the game economy existed carries no username); such a row simply +// never matches an owner-scoped alert. +func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error { now := nowUnix() _, err := Get().Exec(` - INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO push_subscriptions + (endpoint, user_sub, user_localpart, p256dh, auth, created_at, last_notified_at, last_adv_notified_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_sub = excluded.user_sub, + user_localpart = excluded.user_localpart, p256dh = excluded.p256dh, auth = excluded.auth, - last_notified_at = excluded.last_notified_at`, - endpoint, sub, p256dh, auth, now, now) + last_notified_at = excluded.last_notified_at, + last_adv_notified_at = excluded.last_adv_notified_at`, + endpoint, sub, localpart, p256dh, auth, now, now, now) if err != nil { return fmt.Errorf("add push subscription: %w", err) } @@ -61,7 +72,8 @@ func RemovePushSubscriptionForUser(sub, endpoint string) error { // ListPushSubscriptions returns every stored subscription, for the digest sender. func ListPushSubscriptions() ([]PushSubscription, error) { rows, err := Get().Query( - `SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at + `SELECT endpoint, user_sub, user_localpart, p256dh, auth, + created_at, last_notified_at, last_adv_notified_at FROM push_subscriptions`) if err != nil { return nil, err @@ -70,7 +82,8 @@ func ListPushSubscriptions() ([]PushSubscription, error) { var out []PushSubscription for rows.Next() { var p PushSubscription - if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil { + if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.Localpart, &p.P256dh, &p.Auth, + &p.CreatedAt, &p.LastNotifiedAt, &p.LastAdvNotifiedAt); err != nil { return nil, err } out = append(out, p) @@ -78,6 +91,19 @@ func ListPushSubscriptions() ([]PushSubscription, error) { return out, rows.Err() } +// TouchAdvPushSubscription advances an endpoint's *adventure alert* watermark, so +// the next pass only considers dispatches that occurred after ts. Kept separate +// from TouchPushSubscription for the reason the schema gives: the digest and the +// alerts must not be able to consume each other's backlog. +func TouchAdvPushSubscription(endpoint string, ts int64) error { + _, err := Get().Exec( + `UPDATE push_subscriptions SET last_adv_notified_at = ? WHERE endpoint = ?`, ts, endpoint) + if err != nil { + return fmt.Errorf("touch adventure push watermark: %w", err) + } + return nil +} + // TouchPushSubscription advances an endpoint's digest watermark so its next // digest only considers stories seen after ts. func TouchPushSubscription(endpoint string, ts int64) error { diff --git a/internal/storage/push_adventure.go b/internal/storage/push_adventure.go new file mode 100644 index 0000000..6e2d03e --- /dev/null +++ b/internal/storage/push_adventure.go @@ -0,0 +1,86 @@ +package storage + +import ( + "database/sql" +) + +// The reads behind adventure push alerts. The alert sender needs two things the +// rest of the storage layer does not: dispatches ordered by when they *happened* +// rather than by subject, and a way to turn a signed-in identity into the +// character name a fact would carry. + +// AdvEventsSince returns dispatches that occurred after sinceUnix, newest first, +// capped at limit. +// +// The clock is occurred_at, not an arrival time, and that choice has a +// consequence worth stating: a dispatch that reaches Pete late but describes +// something old — a queue row unparked months after the fact, which W0's +// inversion made possible — sorts behind the watermark and is never alerted on. +// That is the outcome we want. An alert is a claim that something is happening +// now, and a phone buzzing about a hire from March would be a lie told urgently. +func AdvEventsSince(sinceUnix int64, limit int) ([]AdvEvent, error) { + if limit <= 0 { + return nil, nil + } + rows, err := Get().Query(` + SELECT guid, event_type, tier, subject, opponent, boss, zone, region, + level, tally, outcome, milestone, stakes, run_id, occurred_at + FROM adventure_events + WHERE occurred_at > ? + ORDER BY occurred_at DESC + LIMIT ?`, sinceUnix, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdvEvent + for rows.Next() { + var e AdvEvent + var tier, subject, opponent, boss, zone, region sql.NullString + var outcome, milestone, stakes, runID sql.NullString + if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent, + &boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone, + &stakes, &runID, &e.OccurredAt); err != nil { + return nil, err + } + e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String + e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String + e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String + e.RunID = runID.String + out = append(out, e) + } + return out, rows.Err() +} + +// AdvCharacterForOwner returns the character name currently on the board for a +// signed-in user's localpart, which is the join an owner-scoped alert needs: a +// fact carries a character *name*, a session carries a localpart, and the only +// thing that connects them is the owner-private (localpart -> token) row gogobee +// pushes alongside the public (token -> name) board. +// +// It fails closed, and every way it can fail is a way it should: +// +// - no self-detail row: gogobee has stopped pushing for this player, so Pete +// has no current basis to claim any name is theirs. +// - no roster row for the token: the player is off the board — removed, or +// opted out of the news entirely. An opted-out player's facts are anonymised +// on the wire anyway, so there is no name left to match even in principle. +// +// Both cases mean the caller sends nothing, which is the correct answer to "is +// this dispatch about you" when Pete cannot honestly tell. +func AdvCharacterForOwner(localpart string) (string, bool) { + if localpart == "" { + return "", false + } + var name string + err := Get().QueryRow(` + SELECT r.name + FROM player_self_detail d + JOIN adventure_roster r ON r.token = d.token + WHERE d.localpart = ?`, localpart).Scan(&name) + if err != nil || name == "" { + return "", false + } + return name, true +} diff --git a/internal/storage/push_adventure_test.go b/internal/storage/push_adventure_test.go new file mode 100644 index 0000000..dfc3348 --- /dev/null +++ b/internal/storage/push_adventure_test.go @@ -0,0 +1,155 @@ +package storage + +import "testing" + +// seedOwnedCharacter puts a player on the board and gives them an owner, which +// is the two-row arrangement AdvCharacterForOwner has to walk: the public +// (token -> name) board plus the owner-private (localpart -> token) detail row. +func seedOwnedCharacter(t *testing.T, localpart, token, name string) { + t.Helper() + if err := ReplaceRoster([]RosterEntry{{ + Token: token, Name: name, Level: 14, ClassRace: "Cleric", Status: "idle", + }}, 1000); err != nil { + t.Fatal(err) + } + if err := ReplacePlayerDetail([]PlayerDetail{{Localpart: localpart, Token: token}}, 1000); err != nil { + t.Fatal(err) + } +} + +// TestAdvCharacterForOwnerNeedsBothHalves is the privacy contract behind every +// owner-scoped alert. The sender asks "which character is this subscriber's" and +// then compares that name against a dispatch's subject; if this function ever +// answered generously, somebody's phone would buzz about another player's death. +// +// Each half of the join is removed in turn, because each one goes missing for a +// real reason in production: the roster row disappears when a player opts out of +// the news or leaves the board, and the self-detail row disappears when gogobee +// stops pushing for them. Both must fail closed. +func TestAdvCharacterForOwnerNeedsBothHalves(t *testing.T) { + setupTestDB(t) + seedOwnedCharacter(t, "josie", "tok-josie", "Josie") + + if name, ok := AdvCharacterForOwner("josie"); !ok || name != "Josie" { + t.Fatalf("owner lookup = %q/%v, want Josie/true", name, ok) + } + if _, ok := AdvCharacterForOwner("quack"); ok { + t.Error("a localpart with no self-detail row resolved to a character") + } + if _, ok := AdvCharacterForOwner(""); ok { + t.Error("an empty localpart resolved to a character") + } + + // Off the board — opted out of the news, or removed. The self-detail row is + // still there and still points at tok-josie, so only the roster join stops + // this. An opted-out player's facts are anonymised on the wire anyway, so + // there would be no name left to match even if this leaked. + if err := ReplaceRoster(nil, 2000); err != nil { + t.Fatal(err) + } + if name, ok := AdvCharacterForOwner("josie"); ok { + t.Errorf("off-the-board player still resolved to %q; alerts must close with the board", name) + } + + // And the mirror case: on the board, but gogobee has stopped pushing the + // owner-private half, so Pete has no basis to claim the name is theirs. + seedOwnedCharacter(t, "josie", "tok-josie", "Josie") + if err := ReplacePlayerDetail(nil, 3000); err != nil { + t.Fatal(err) + } + if name, ok := AdvCharacterForOwner("josie"); ok { + t.Errorf("resolved %q with no self-detail row; the ownership claim has no source", name) + } +} + +// TestAdvEventsSinceIsOrderedNewestFirst pins the ordering the sender depends on +// twice over: it takes the first match as "the newest thing to tell you about", +// and it advances every watermark to events[0]. Reverse this and the alert names +// the oldest unseen dispatch while the watermark skips the rest. +func TestAdvEventsSinceIsOrderedNewestFirst(t *testing.T) { + setupTestDB(t) + for _, e := range []AdvEvent{ + {GUID: "g1", EventType: "death", Subject: "Josie", OccurredAt: 100}, + {GUID: "g2", EventType: "zone_clear", Subject: "Josie", OccurredAt: 300}, + {GUID: "g3", EventType: "retreat", Subject: "Josie", OccurredAt: 200}, + } { + if err := InsertAdventureEvent(&e); err != nil { + t.Fatal(err) + } + } + + got, err := AdvEventsSince(150, 10) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d events after ts=150, want 2 (the ts=100 one is behind the watermark)", len(got)) + } + if got[0].GUID != "g2" || got[1].GUID != "g3" { + t.Fatalf("order = %s,%s; want g2,g3 (newest first)", got[0].GUID, got[1].GUID) + } + + // limit <= 0 means "nothing", not "everything": the sender treats the result + // as a bounded window and an unbounded read here would be a surprise. + if got, _ := AdvEventsSince(0, 0); len(got) != 0 { + t.Errorf("limit 0 returned %d events, want 0", len(got)) + } +} + +// TestAdvEventsSinceCarriesRunID pins that the run link survives the read. The +// alert for an ended expedition points at the run report when there is one, and +// that field is the only thing distinguishing it from the plain story permalink. +func TestAdvEventsSinceCarriesRunID(t *testing.T) { + setupTestDB(t) + if err := InsertAdventureEvent(&AdvEvent{ + GUID: "g1", EventType: "zone_clear", Subject: "Josie", + RunID: "run-7", OccurredAt: 500, + }); err != nil { + t.Fatal(err) + } + got, err := AdvEventsSince(0, 10) + if err != nil || len(got) != 1 { + t.Fatalf("read back %d events (err %v), want 1", len(got), err) + } + if got[0].RunID != "run-7" { + t.Errorf("run id = %q, want run-7", got[0].RunID) + } +} + +// TestAdvWatermarkIsIndependentOfTheDigest pins the schema note. The two senders +// run on different clocks; if they shared a column, whichever ran last would +// decide what the other had already seen, and one of the two channels would go +// permanently quiet in a way nobody would think to look for. +func TestAdvWatermarkIsIndependentOfTheDigest(t *testing.T) { + setupTestDB(t) + const ep = "https://push.example/ep" + if err := AddPushSubscription("sub-1", "josie", ep, "p", "a"); err != nil { + t.Fatal(err) + } + + if err := TouchAdvPushSubscription(ep, 4242); err != nil { + t.Fatal(err) + } + subs, _ := ListPushSubscriptions() + if len(subs) != 1 { + t.Fatalf("got %d subscriptions, want 1", len(subs)) + } + if subs[0].LastAdvNotifiedAt != 4242 { + t.Errorf("adventure watermark = %d, want 4242", subs[0].LastAdvNotifiedAt) + } + if subs[0].LastNotifiedAt == 4242 { + t.Error("touching the adventure watermark moved the digest watermark too") + } + if subs[0].Localpart != "josie" { + t.Errorf("localpart = %q, want josie; owner-scoped alerts have nothing to join on without it", subs[0].Localpart) + } + + // The reverse direction, so neither can quietly consume the other's backlog. + if err := TouchPushSubscription(ep, 99); err != nil { + t.Fatal(err) + } + subs, _ = ListPushSubscriptions() + if subs[0].LastAdvNotifiedAt != 4242 { + t.Errorf("digest touch moved the adventure watermark to %d", subs[0].LastAdvNotifiedAt) + } +} diff --git a/internal/storage/push_test.go b/internal/storage/push_test.go index ce55af9..dea75fe 100644 --- a/internal/storage/push_test.go +++ b/internal/storage/push_test.go @@ -5,15 +5,15 @@ import "testing" func TestPushSubscriptionLifecycle(t *testing.T) { setupTestDB(t) - if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil { + if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil { t.Fatal(err) } // A second endpoint for the same user (e.g. a second device). - if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil { + if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil { t.Fatal(err) } // A different user. - if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil { + if err := AddPushSubscription("sub-2", "quack", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil { t.Fatal(err) } @@ -26,7 +26,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) { } // Re-subscribing the same endpoint updates keys in place, not a new row. - if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil { + if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil { t.Fatal(err) } subs, _ = ListPushSubscriptions() @@ -54,7 +54,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) { func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) { setupTestDB(t) - if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil { + if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil { t.Fatal(err) } subs, _ := ListPushSubscriptions() diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 21de8c6..abd0b06 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -487,13 +487,26 @@ CREATE TABLE IF NOT EXISTS source_health ( -- server needs them to encrypt each push. last_notified_at is the per-endpoint -- digest watermark: the sender only counts stories seen after it. A user can -- have several endpoints (phone, desktop) — each is notified independently. +-- +-- user_localpart is the same identity one level down: user_sub is the OIDC +-- subject, but every adventure ownership join in this schema is keyed on the +-- Matrix localpart (see player_self_detail), and nothing else persists that +-- mapping outside a live session. It is captured at subscribe time so the +-- adventure alert sender — which runs on a ticker with no request to read a +-- session from — can answer "whose adventurer is this" at all. +-- +-- last_adv_notified_at is the adventure alerts' own watermark, kept apart from +-- the digest's on purpose: the two senders run on different clocks and one +-- column would let each silently consume the other's backlog. CREATE TABLE IF NOT EXISTS push_subscriptions ( - endpoint TEXT PRIMARY KEY, - user_sub TEXT NOT NULL, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - created_at INTEGER NOT NULL, - last_notified_at INTEGER NOT NULL + endpoint TEXT PRIMARY KEY, + user_sub TEXT NOT NULL, + user_localpart TEXT NOT NULL DEFAULT '', + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_notified_at INTEGER NOT NULL, + last_adv_notified_at INTEGER NOT NULL DEFAULT 0 ); -- Privacy-preserving daily unique estimate. visitor is a salted hash of diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 26230c6..62d4344 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -146,6 +146,7 @@ type pageData struct { IsAdmin bool // signed-in user is on the admin allowlist (shows /status link) PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users) PushPublicKey string // VAPID public key handed to the client to subscribe + AdvEnabled bool // the adventure section is configured (shows its alert categories in settings) TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null" NoIndex bool // emit — used by the adventure section OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none @@ -224,6 +225,7 @@ func (s *Server) base(r *http.Request) pageData { PostingEnabled: s.postingEnabled, PushEnabled: s.auth != nil && s.cfg.Push.Enabled, PushPublicKey: s.cfg.Push.VAPIDPublicKey, + AdvEnabled: s.adv.Enabled, TTS: template.JS("null"), } if s.tts != nil { diff --git a/internal/web/push_adventure.go b/internal/web/push_adventure.go new file mode 100644 index 0000000..260d942 --- /dev/null +++ b/internal/web/push_adventure.go @@ -0,0 +1,420 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "pete/internal/storage" +) + +// Adventure alerts: the only channel that reaches a player who isn't looking at +// the site or at Matrix. +// +// This rides entirely on facts gogobee is already sending. Every trigger below +// is a dispatch that already lands in adventure_events, so the whole phase is +// Pete-side and no new wire, event type or deploy ordering is involved. +// +// Two things separate it from the news digest it sits beside: +// +// - Its own clock. A digest is a summary and six hours late is fine; "the +// Siege has begun" six hours late is worse than silence, because the bout +// the alert is asking for may already be over. +// - Its own watermark (last_adv_notified_at). Sharing the digest's column +// would let each sender consume the other's backlog. + +// advAlertInterval matches the roster tick that delivers the facts. Checking +// faster than they can arrive only burns queries. +const advAlertInterval = 2 * time.Minute + +// advAlertScan caps how many dispatches one pass inspects. Generously above the +// realm's real rate (a busy day is tens of dispatches, not hundreds), so hitting +// it means something is wrong rather than something is busy — see the warning in +// sendAdventureAlerts. +const advAlertScan = 200 + +// advPrefsKey is where the client stores the per-category opt-ins, alongside the +// other synced preferences. Its value is a JSON object of {category: true}. +const advPrefsKey = "pete.advPush.v1" + +// The alert categories. Every one is opt-in and defaults OFF: a user who turned +// on notifications did so for news, and quietly enrolling them in game alerts +// they never asked for is how a notification permission gets revoked for good. +// +// advCatSiege is realm-wide — it needs no ownership and reaches everyone who +// asked for it. The other three are owner-scoped and reach exactly one person. +const ( + advCatSiege = "siege" + advCatRun = "run" + advCatDeparture = "departure" + advCatContract = "contract" +) + +// advAlert is one notification a pass has decided to send. +type advAlert struct { + Category string + Title string + Body string + URL string +} + +// StartAdventureAlerts launches the alert loop when push and the adventure +// section are both configured. Safe to call unconditionally. +func (s *Server) StartAdventureAlerts(ctx context.Context) { + if !s.cfg.Push.Enabled || s.auth == nil || !s.adv.Enabled { + return + } + go s.runAdventureAlerts(ctx) +} + +func (s *Server) runAdventureAlerts(ctx context.Context) { + slog.Info("web: adventure alert sender started", "interval", advAlertInterval) + ticker := time.NewTicker(advAlertInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sendAdventureAlerts() + } + } +} + +// sendAdventureAlerts runs one pass: read the dispatches nobody has been told +// about yet, and for each subscription decide whether any of them is that +// person's business. +func (s *Server) sendAdventureAlerts() { + subs, err := storage.ListPushSubscriptions() + if err != nil { + slog.Error("adv-push: list subscriptions failed", "err", err) + return + } + if len(subs) == 0 { + return + } + + now := time.Now().Unix() + + // A row written before adventure alerts existed carries watermark 0, which + // read literally means "has never been told about anything" and would page the + // subscriber for the entire history of the realm on the first tick after + // deploy. Stamp those to now and let them start from the next dispatch. This + // is the deploy-safety valve for the whole phase. + live := subs[:0] + for _, sub := range subs { + if sub.LastAdvNotifiedAt == 0 { + if err := storage.TouchAdvPushSubscription(sub.Endpoint, now); err != nil { + slog.Error("adv-push: seed watermark failed", "sub", sub.UserSub, "err", err) + } + continue + } + live = append(live, sub) + } + if len(live) == 0 { + return + } + + // One scan serves every subscriber: read from the oldest watermark in the set + // and let each row be filtered per subscription below. + oldest := live[0].LastAdvNotifiedAt + for _, sub := range live { + if sub.LastAdvNotifiedAt < oldest { + oldest = sub.LastAdvNotifiedAt + } + } + events, err := storage.AdvEventsSince(oldest, advAlertScan) + if err != nil { + slog.Error("adv-push: scan dispatches failed", "err", err) + return + } + if len(events) == 0 { + return + } + if len(events) == advAlertScan { + // The scan was capped, so dispatches older than the window exist and are + // about to be skipped by the watermark advance below. Alerts are + // time-sensitive enough that dropping the tail is right; being quiet about + // it is not. + slog.Warn("adv-push: scan hit its cap; older dispatches skipped", + "cap", advAlertScan, "since", oldest) + } + // events[0] is the newest in the window (occurred_at DESC) and is where every + // watermark lands this pass, sent or not. + newest := events[0].OccurredAt + + // Both lookups are per-user, and a user can hold several endpoints (phone, + // desktop). Cache within the pass so a two-device user costs one prefs parse + // and one ownership join rather than two. + catsByUser := make(map[string]map[string]bool) + nameByLocalpart := make(map[string]string) + + sent, pruned := 0, 0 + for _, sub := range live { + cats, ok := catsByUser[sub.UserSub] + if !ok { + cats = advCategoriesFor(sub.UserSub) + catsByUser[sub.UserSub] = cats + } + if len(cats) == 0 { + // Nothing enabled. Still advance, so turning a category on later starts + // from that moment rather than replaying the backlog it was off for. + s.touchAdv(sub.Endpoint, newest) + continue + } + + // The ownership join is re-read every pass rather than trusted from the + // subscription row, and that is deliberate: an opt-out, a removal or a + // character change has to close the channel immediately, exactly as + // runReportLinkFor re-resolves rather than trusting a stored id. + mine := "" + if sub.Localpart != "" { + if cached, ok := nameByLocalpart[sub.Localpart]; ok { + mine = cached + } else { + if name, ok := storage.AdvCharacterForOwner(sub.Localpart); ok { + mine = name + } + nameByLocalpart[sub.Localpart] = mine + } + } + + var top *advAlert + extra := 0 + for _, ev := range events { + if ev.OccurredAt <= sub.LastAdvNotifiedAt { + continue // this endpoint has already been told + } + alert, ok := advAlertFor(ev, mine) + if !ok || !cats[alert.Category] { + continue + } + if top == nil { + // events are newest-first, so the first match is the newest match. + a := alert + top = &a + continue + } + extra++ + } + if top == nil { + s.touchAdv(sub.Endpoint, newest) + continue + } + + gone, err := s.sendPush(sub, buildAdvPayload(*top, extra)) + if gone { + if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil { + slog.Error("adv-push: prune gone subscription failed", "err", derr) + } else { + pruned++ + } + continue + } + if err != nil { + // Leave the watermark alone: a transient push-service failure should be + // retried on the next tick, not swallowed. The alert is at most + // advAlertInterval late, and the events are still in the window. + slog.Warn("adv-push: send failed", "sub", sub.UserSub, "err", err) + continue + } + s.touchAdv(sub.Endpoint, newest) + sent++ + } + if sent > 0 || pruned > 0 { + slog.Info("adv-push: pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(live)) + } +} + +func (s *Server) touchAdv(endpoint string, ts int64) { + if err := storage.TouchAdvPushSubscription(endpoint, ts); err != nil { + slog.Error("adv-push: advance watermark failed", "endpoint", endpoint, "err", err) + } +} + +// advAlertFor decides whether one dispatch is worth waking somebody for, and in +// what words. mine is the character name belonging to the subscriber, or "" when +// Pete cannot establish one — in which case only realm-wide alerts can match. +// +// Every owner-scoped branch compares against mine and nothing else. There is no +// path here where an unresolved owner falls through to a broadcast: a game alert +// naming somebody's adventurer, delivered to the wrong phone, is a privacy leak +// dressed as a feature. +func advAlertFor(ev storage.AdvEvent, mine string) (advAlert, bool) { + switch ev.EventType { + case "siege_start": + boss := ev.Boss + if boss == "" { + boss = "Something" + } + return advAlert{ + Category: advCatSiege, + Title: "The Siege has begun", + Body: fmt.Sprintf("%s is camped outside the town. Everyone gets one bout a day.", boss), + URL: "/adventure/siege", + }, true + case "siege_win": + return advAlert{ + Category: advCatSiege, + Title: "The town holds", + Body: siegeOutcomeBody(ev, "went down"), + URL: "/adventure/siege", + }, true + case "siege_loss": + return advAlert{ + Category: advCatSiege, + Title: "The Siege is over", + Body: siegeOutcomeBody(ev, "walked away still standing"), + URL: "/adventure/siege", + }, true + } + + // Everything below is about one person, so an unmatched subject ends it here. + if mine == "" || ev.Subject != mine { + return advAlert{}, false + } + + switch ev.EventType { + case "death": + return advAlert{ + Category: advCatRun, + Title: fmt.Sprintf("%s fell in %s", mine, orPlace(ev.Zone)), + Body: "The expedition is over. They'll need picking up.", + URL: advRunOrStoryURL(ev), + }, true + case "zone_clear": + return advAlert{ + Category: advCatRun, + Title: fmt.Sprintf("%s cleared %s", mine, orPlace(ev.Zone)), + Body: "They're through it and on the way home. Read how it went.", + URL: advRunOrStoryURL(ev), + }, true + case "retreat": + return advAlert{ + Category: advCatRun, + Title: fmt.Sprintf("%s backed out of %s", mine, orPlace(ev.Zone)), + Body: "Everyone came home breathing. The run's finished either way.", + URL: advRunOrStoryURL(ev), + }, true + case "departure": + return advAlert{ + Category: advCatDeparture, + Title: fmt.Sprintf("%s got bored and left", mine), + Body: fmt.Sprintf("No orders, no escort. They packed the cheap kit and set off into %s on their own.", + orPlace(ev.Zone)), + URL: advStoryURL(ev.GUID), + }, true + case "mischief_contract": + body := "Somebody paid to have something sent after them, and isn't saying who. Survive it and the money's theirs." + if ev.Opponent != "" { + body = fmt.Sprintf("%s paid for it and signed the thing. It's out there looking right now.", ev.Opponent) + } + return advAlert{ + Category: advCatContract, + Title: fmt.Sprintf("There's a contract out on %s", mine), + Body: body, + URL: advStoryURL(ev.GUID), + }, true + } + return advAlert{}, false +} + +// siegeOutcomeBody names the boss when the fact carried one. The verb differs by +// outcome, so it is the caller's. +func siegeOutcomeBody(ev storage.AdvEvent, verb string) string { + if ev.Boss == "" { + return fmt.Sprintf("It %s. See how the town did.", verb) + } + return fmt.Sprintf("%s %s. See how the town did.", ev.Boss, verb) +} + +func orPlace(zone string) string { + if zone == "" { + return "the dark" + } + return zone +} + +// advRunOrStoryURL prefers the run report, which is the richer landing place for +// an expedition that has just ended, and falls back to the dispatch permalink. +// Runs that predate the liveblog carry no run id and land on the story, which is +// what they have always done. +func advRunOrStoryURL(ev storage.AdvEvent) string { + if ev.RunID != "" { + return "/adventure/run/" + ev.RunID + } + return advStoryURL(ev.GUID) +} + +func advStoryURL(guid string) string { + return "/adventure/" + guid +} + +// buildAdvPayload renders the notification JSON the service worker expects. The +// tag is per-category so a Siege alert can't silently replace an unread alert +// about somebody's own adventurer on the lock screen. +func buildAdvPayload(a advAlert, extra int) []byte { + body := a.Body + if extra == 1 { + body += " Plus 1 other update." + } else if extra > 1 { + body += fmt.Sprintf(" Plus %d other updates.", extra) + } + b, _ := json.Marshal(map[string]string{ + "title": a.Title, + "body": body, + "url": a.URL, + "tag": "pete-adv-" + a.Category, + }) + return b +} + +// advCategoriesFor returns the alert categories a user has switched on. An +// absent key, an unparseable blob or a user who has never opened the settings +// drawer all yield an empty set, which sends nothing — the safe direction for a +// channel that interrupts people. +func advCategoriesFor(sub string) map[string]bool { + return userPrefBoolSet(sub, advPrefsKey) +} + +// userPrefBoolSet reads one {name: bool} map out of a user's stored preferences +// blob, keeping only the true entries. +// +// The blob mirrors localStorage: a JSON object whose values are themselves JSON +// *strings*. That double encoding is the client's doing, not ours, so unwrap it +// — while tolerating a bare object, since a hand-written or migrated blob may +// carry one. Any parse failure yields an empty set, and every caller treats an +// empty set as "no", so a corrupt blob costs a feature rather than misfiring it. +func userPrefBoolSet(sub, key string) map[string]bool { + out := map[string]bool{} + blob, err := storage.GetUserPrefs(sub) + if err != nil || blob == "" { + return out + } + var prefs map[string]json.RawMessage + if err := json.Unmarshal([]byte(blob), &prefs); err != nil { + return out + } + raw, ok := prefs[key] + if !ok { + return out + } + inner := []byte(raw) + var asStr string + if err := json.Unmarshal(raw, &asStr); err == nil { + inner = []byte(asStr) + } + var set map[string]bool + if err := json.Unmarshal(inner, &set); err != nil { + return out + } + for name, on := range set { + if on { + out[strings.TrimSpace(name)] = true + } + } + return out +} diff --git a/internal/web/push_adventure_test.go b/internal/web/push_adventure_test.go new file mode 100644 index 0000000..90444f6 --- /dev/null +++ b/internal/web/push_adventure_test.go @@ -0,0 +1,283 @@ +package web + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "pete/internal/storage" +) + +// TestOwnerScopedAlertsNeverBroadcast is the security regression for this whole +// phase, and it is the one to keep if the rest are ever thinned out. +// +// Three of the four categories name somebody's adventurer. The sender resolves +// "which character belongs to this subscriber" from a join that can legitimately +// come back empty — a player off the board, an opt-out, a session with no +// username. If an empty answer ever fell through to "matches anything", every +// subscriber's phone would light up with a stranger's death, naming them. +func TestOwnerScopedAlertsNeverBroadcast(t *testing.T) { + owned := []storage.AdvEvent{ + {EventType: "death", Subject: "Josie", Zone: "The Crypt"}, + {EventType: "zone_clear", Subject: "Josie", Zone: "The Crypt"}, + {EventType: "retreat", Subject: "Josie", Zone: "The Crypt"}, + {EventType: "departure", Subject: "Josie", Zone: "The Crypt"}, + {EventType: "mischief_contract", Subject: "Josie", Stakes: "500 gold"}, + } + + for _, ev := range owned { + // No resolvable owner at all. + if _, ok := advAlertFor(ev, ""); ok { + t.Errorf("%s matched with no owner resolved; that is a broadcast of a private event", ev.EventType) + } + // An owner, but somebody else's dispatch. + if _, ok := advAlertFor(ev, "Quack"); ok { + t.Errorf("%s about Josie matched a subscriber who plays Quack", ev.EventType) + } + // The actual owner. + if _, ok := advAlertFor(ev, "Josie"); !ok { + t.Errorf("%s about Josie did not match Josie", ev.EventType) + } + } +} + +// TestSiegeAlertsAreRealmWide is the other half: the Siege is the one communal +// mechanic, so it must reach a subscriber whose ownership join came back empty — +// including someone who has never made a character at all. +func TestSiegeAlertsAreRealmWide(t *testing.T) { + for _, kind := range []string{"siege_start", "siege_win", "siege_loss"} { + a, ok := advAlertFor(storage.AdvEvent{EventType: kind, Boss: "The Hollow King"}, "") + if !ok { + t.Fatalf("%s did not match a subscriber with no character", kind) + } + if a.Category != advCatSiege { + t.Errorf("%s filed under %q, want %q", kind, a.Category, advCatSiege) + } + if a.URL != "/adventure/siege" { + t.Errorf("%s links to %q, want the war room", kind, a.URL) + } + } +} + +// TestUntemplatedDispatchIsSilent pins that adding an event type upstream does +// not silently start paging people. W0 made an unknown event_type render as a +// neutral card rather than 400 — the right call for a *page*, and the wrong one +// for a phone. A dispatch nobody has written alert copy for gets no alert. +func TestUntemplatedDispatchIsSilent(t *testing.T) { + for _, kind := range []string{"treasure_found", "milestone", "arrival", "companion_hire", "brand_new_thing"} { + if _, ok := advAlertFor(storage.AdvEvent{EventType: kind, Subject: "Josie"}, "Josie"); ok { + t.Errorf("%s produced an alert; new event types must opt in, not opt out", kind) + } + } +} + +// TestEndedRunAlertPrefersTheRunReport pins the landing page. A finished +// expedition has a report worth reading; a dispatch that predates the liveblog +// has no run id and must still land somewhere real rather than on /adventure/. +func TestEndedRunAlertPrefersTheRunReport(t *testing.T) { + withRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g1", RunID: "run-7"} + a, ok := advAlertFor(withRun, "Josie") + if !ok || a.URL != "/adventure/run/run-7" { + t.Errorf("url = %q, want the run report", a.URL) + } + + noRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g2"} + a, ok = advAlertFor(noRun, "Josie") + if !ok || a.URL != "/adventure/g2" { + t.Errorf("url = %q, want the story permalink fallback", a.URL) + } +} + +// TestAlertCopySurvivesAnEmptyZone guards the copy against the blank-noun defect +// that W2b hit twice: a fact is allowed to arrive with fields missing, and the +// result must still read as a sentence rather than "Josie fell in ". +func TestAlertCopySurvivesAnEmptyZone(t *testing.T) { + a, ok := advAlertFor(storage.AdvEvent{EventType: "death", Subject: "Josie"}, "Josie") + if !ok { + t.Fatal("death with no zone produced no alert") + } + if a.Title != "Josie fell in the dark" { + t.Errorf("title = %q; a missing zone must still read as a sentence", a.Title) + } + // Same for a siege with no boss name on the fact. + b, _ := advAlertFor(storage.AdvEvent{EventType: "siege_win"}, "") + if b.Body != "It went down. See how the town did." { + t.Errorf("body = %q; a nameless boss must still read as a sentence", b.Body) + } +} + +// TestUnsignedContractKeepsItsSecret. The anonymity of an unsigned mischief +// contract is the mechanic — the buyer's name is the reward for surviving it. A +// notification that leaked it would hand the payoff to the target for free. +func TestUnsignedContractKeepsItsSecret(t *testing.T) { + signed, _ := advAlertFor(storage.AdvEvent{ + EventType: "mischief_contract", Subject: "Josie", Opponent: "Quack", + }, "Josie") + if !strings.Contains(signed.Body, "Quack") { + t.Errorf("a signed contract hid its buyer: %q", signed.Body) + } + + anon, _ := advAlertFor(storage.AdvEvent{EventType: "mischief_contract", Subject: "Josie"}, "Josie") + if strings.Contains(anon.Body, "Quack") || !strings.Contains(anon.Body, "isn't saying who") { + t.Errorf("an unsigned contract gave something away: %q", anon.Body) + } +} + +// TestCategoriesDefaultToNothing pins the consent model. A user who turned on +// news notifications has not asked to be told about the game, and every way the +// preference can be missing or broken must mean "no". +func TestCategoriesDefaultToNothing(t *testing.T) { + s, _ := newAdvServer(t, "tok") + _ = s + + for name, blob := range map[string]string{ + "no prefs row at all": "", + "prefs but no key": `{"pete.weather.loc.v1":"\"London\""}`, + "key is not JSON": `{"pete.advPush.v1":"not json at all"}`, + "key is a JSON null": `{"pete.advPush.v1":null}`, + "all boxes unchecked": `{"pete.advPush.v1":"{\"siege\":false,\"run\":false}"}`, + } { + if blob != "" { + if err := storage.PutUserPrefs("sub-1", blob, "Josie", "j@example.com"); err != nil { + t.Fatal(err) + } + } + if got := advCategoriesFor("sub-1"); len(got) != 0 { + t.Errorf("%s: enabled %v, want nothing", name, got) + } + } +} + +// TestCategoriesReadTheDoubleEncodedBlob. The client mirrors localStorage, whose +// values are strings, so the stored map arrives as JSON inside a JSON string. +// Both that shape and a bare object must parse — the bare form is what a +// hand-edited or migrated blob looks like. +func TestCategoriesReadTheDoubleEncodedBlob(t *testing.T) { + s, _ := newAdvServer(t, "tok") + _ = s + + nested, _ := json.Marshal(map[string]string{ + advPrefsKey: `{"siege":true,"run":true,"departure":false}`, + }) + if err := storage.PutUserPrefs("sub-1", string(nested), "Josie", ""); err != nil { + t.Fatal(err) + } + got := advCategoriesFor("sub-1") + if !got[advCatSiege] || !got[advCatRun] { + t.Errorf("double-encoded blob parsed to %v, want siege+run", got) + } + if got[advCatDeparture] { + t.Error("an explicitly false category was treated as enabled") + } + + bare := `{"pete.advPush.v1":{"contract":true}}` + if err := storage.PutUserPrefs("sub-2", bare, "Quack", ""); err != nil { + t.Fatal(err) + } + if got := advCategoriesFor("sub-2"); !got[advCatContract] { + t.Errorf("bare-object blob parsed to %v, want contract", got) + } +} + +// TestFirstPassNeverReplaysTheBacklog is the deploy safety valve. +// +// Every subscription that exists when this ships carries watermark 0. Read +// literally that means "has never been told anything", and the first tick would +// page every subscriber for every dispatch Pete has ever stored — on the same +// pass, from a feature they never switched on. The seeding branch stamps those +// rows to now and sends nothing, so alerts begin at the next real dispatch. +func TestFirstPassNeverReplaysTheBacklog(t *testing.T) { + s, _ := newAdvServer(t, "tok") + + // A subscriber with everything switched on and a character on the board — + // i.e. the person most exposed to a replay. + seedSubscriberWithEverythingOn(t, "sub-1", "josie", "Josie") + + // A realm with history, all of it well before now. + old := time.Now().Add(-90 * 24 * time.Hour).Unix() + for i, kind := range []string{"death", "zone_clear", "siege_start", "departure"} { + if err := storage.InsertAdventureEvent(&storage.AdvEvent{ + GUID: string(rune('a'+i)) + "-guid", EventType: kind, Subject: "Josie", + Boss: "The Hollow King", Zone: "The Crypt", OccurredAt: old + int64(i), + }); err != nil { + t.Fatal(err) + } + } + + before := time.Now().Unix() + // No push service is reachable from a test, so a send would surface as an + // error rather than silence. What this asserts is that we never get that far: + // the watermark is seeded and the pass returns having considered nobody. + s.sendAdventureAlerts() + + subs, err := storage.ListPushSubscriptions() + if err != nil || len(subs) != 1 { + t.Fatalf("read back %d subscriptions (err %v), want 1", len(subs), err) + } + if subs[0].LastAdvNotifiedAt < before { + t.Fatalf("watermark = %d, want >= %d: a 0 watermark must be stamped to now, not read as 'tell them everything'", + subs[0].LastAdvNotifiedAt, before) + } + + // And the pass after it is quiet, because everything in the realm is now + // behind the watermark. + s.sendAdventureAlerts() + subs, _ = storage.ListPushSubscriptions() + if subs[0].LastAdvNotifiedAt < before { + t.Error("second pass moved the watermark backwards") + } +} + +// TestCategoriesOffStillAdvanceTheWatermark. Someone with push on but no +// adventure categories must not accumulate a backlog: switching a category on +// should start from that moment, not replay everything it was off for. +func TestCategoriesOffStillAdvanceTheWatermark(t *testing.T) { + s, _ := newAdvServer(t, "tok") + + if err := storage.AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil { + t.Fatal(err) + } + // Move off the seeding branch so the pass actually evaluates this row. + if err := storage.TouchAdvPushSubscription("https://push.example/ep", 1000); err != nil { + t.Fatal(err) + } + if err := storage.InsertAdventureEvent(&storage.AdvEvent{ + GUID: "g1", EventType: "siege_start", Boss: "The Hollow King", OccurredAt: 5000, + }); err != nil { + t.Fatal(err) + } + + s.sendAdventureAlerts() + + subs, _ := storage.ListPushSubscriptions() + if len(subs) != 1 || subs[0].LastAdvNotifiedAt != 5000 { + t.Fatalf("watermark = %d, want 5000: a subscriber with nothing enabled must still move past what they were not told", + subs[0].LastAdvNotifiedAt) + } +} + +// seedSubscriberWithEverythingOn wires the full happy path: a push endpoint, a +// character on the board with an owner, and every alert category enabled. +func seedSubscriberWithEverythingOn(t *testing.T, sub, localpart, character string) { + t.Helper() + if err := storage.AddPushSubscription(sub, localpart, "https://push.example/"+sub, "p", "a"); err != nil { + t.Fatal(err) + } + blob, _ := json.Marshal(map[string]string{ + advPrefsKey: `{"siege":true,"run":true,"departure":true,"contract":true}`, + }) + if err := storage.PutUserPrefs(sub, string(blob), character, ""); err != nil { + t.Fatal(err) + } + if err := storage.ReplaceRoster([]storage.RosterEntry{{ + Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle", + }}, time.Now().Unix()); err != nil { + t.Fatal(err) + } + if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{ + Localpart: localpart, Token: "tok-" + localpart, + }}, time.Now().Unix()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/web/push_sender.go b/internal/web/push_sender.go index 7921d54..5e01fda 100644 --- a/internal/web/push_sender.go +++ b/internal/web/push_sender.go @@ -180,40 +180,10 @@ func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bo } // disabledSourcesFor returns the set of source names a user has hidden, read -// from their stored prefs blob. The blob mirrors localStorage: a JSON object -// whose "pete.disabledSources.v1" value is itself a JSON string encoding a -// {sourceName: true} map. Any parse failure yields an empty (deny-nothing) set. +// from their stored prefs blob. Any parse failure yields an empty (deny-nothing) +// set — see userPrefBoolSet for the blob's shape and why it is double-encoded. func disabledSourcesFor(sub string) map[string]bool { - out := map[string]bool{} - blob, err := storage.GetUserPrefs(sub) - if err != nil || blob == "" { - return out - } - var prefs map[string]json.RawMessage - if err := json.Unmarshal([]byte(blob), &prefs); err != nil { - return out - } - raw, ok := prefs["pete.disabledSources.v1"] - if !ok { - return out - } - // The value is normally a JSON *string* containing JSON; unwrap that first, - // but tolerate a bare object too. - inner := []byte(raw) - var asStr string - if err := json.Unmarshal(raw, &asStr); err == nil { - inner = []byte(asStr) - } - var set map[string]bool - if err := json.Unmarshal(inner, &set); err != nil { - return out - } - for name, on := range set { - if on { - out[name] = true - } - } - return out + return userPrefBoolSet(sub, "pete.disabledSources.v1") } // pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used diff --git a/internal/web/pwa.go b/internal/web/pwa.go index cce8b31..8af54d7 100644 --- a/internal/web/pwa.go +++ b/internal/web/pwa.go @@ -47,7 +47,11 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid endpoint"}`, http.StatusBadRequest) return } - if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil { + // The localpart is captured here because this is the only place the mapping is + // available: the alert sender runs on a ticker with no session to read. It may + // be empty for a session minted before the game economy existed — that costs + // only the owner-scoped alerts, and heals on the next re-subscribe. + if err := storage.AddPushSubscription(u.Sub, buyerLocalpart(u), req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil { slog.Error("push: subscribe failed", "sub", u.Sub, "err", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return diff --git a/internal/web/static/js/prefs.js b/internal/web/static/js/prefs.js index 4723a6c..ab3891a 100644 --- a/internal/web/static/js/prefs.js +++ b/internal/web/static/js/prefs.js @@ -10,7 +10,13 @@ (function () { // The localStorage keys we sync. The weather *cache* is deliberately excluded: // it's transient and per-device. - var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"]; + // pete.advPush.v1 is read by the *server* — the adventure alert sender parses + // it out of the stored blob to decide who to notify — where every other key + // here is only ever read back by a feature script. If it stops syncing, the + // toggles keep working locally and no alert is ever sent, which is the kind of + // failure nobody reports. + var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off", + "pete.advPush.v1"]; var user = window.PETE_USER || null; var serverPrefs = window.PETE_PREFS || null; diff --git a/internal/web/static/js/pwa.js b/internal/web/static/js/pwa.js index 320a09b..17155e0 100644 --- a/internal/web/static/js/pwa.js +++ b/internal/web/static/js/pwa.js @@ -65,6 +65,68 @@ }); } + // ---- adventure alert categories ------------------------------------------- + // Stored as a JSON string under a synced prefs key, because this is the one + // preference the *server* reads back: the alert sender parses the same blob to + // decide who to notify. Absent key means nothing enabled, on both sides. + var ADV_KEY = "pete.advPush.v1"; + + function advRead() { + try { + var raw = localStorage.getItem(ADV_KEY); + if (!raw) return {}; + var v = JSON.parse(raw); + return v && typeof v === "object" ? v : {}; + } catch (e) { return {}; } + } + + function advWrite(set) { + try { localStorage.setItem(ADV_KEY, JSON.stringify(set)); } catch (e) {} + if (window.PetePrefs) window.PetePrefs.push(); + } + + // initAdvUI wires the category boxes. `on` is whether a push subscription + // currently exists — a category switch with no subscription behind it is wired + // to nothing, so the block stays hidden until there is one. + function initAdvUI(slot, on) { + var box = slot.querySelector("[data-adv-push]"); + if (!box) return; + box.hidden = !on; + if (!on) return; + + var note = box.querySelector("[data-adv-push-note]"); + var inputs = box.querySelectorAll("[data-adv-cat]"); + var set = advRead(); + + function paintNote() { + if (!note) return; + var n = 0; + for (var i = 0; i < inputs.length; i++) if (inputs[i].checked) n++; + note.textContent = n === 0 + ? "Nothing selected. You'll only get the news digest." + : (n === 1 ? "1 alert type on." : n + " alert types on."); + } + + for (var i = 0; i < inputs.length; i++) { + (function (el) { + el.checked = !!set[el.getAttribute("data-adv-cat")]; + // This function re-runs every time the subscription state changes; bind + // the listener once or a toggle-off-toggle-on writes the pref twice. + if (!el.dataset.advBound) { + el.dataset.advBound = "1"; + el.addEventListener("change", function () { + var cur = advRead(); + var key = el.getAttribute("data-adv-cat"); + if (el.checked) cur[key] = true; else delete cur[key]; + advWrite(cur); + paintNote(); + }); + } + })(inputs[i]); + } + paintNote(); + } + // ---- settings-panel toggle ------------------------------------------------ function initPushUI() { var slot = document.querySelector("[data-push-section]"); @@ -80,6 +142,7 @@ btn.setAttribute("aria-pressed", on ? "true" : "false"); btn.textContent = on ? "Notifications on" : "Turn on notifications"; if (note && text != null) note.textContent = text; + initAdvUI(slot, on); } function refresh() { diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index b360b98..15d510f 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -201,6 +201,35 @@ Turn on notifications + {{if .AdvEnabled}} + +
Uncheck a feed to hide its stories. Saved in this browser.
diff --git a/main.go b/main.go index 229aa8b..ef1714d 100644 --- a/main.go +++ b/main.go @@ -298,6 +298,7 @@ func main() { } else { go ws.Start(ctx) ws.StartPushSender(ctx) + ws.StartAdventureAlerts(ctx) ws.StartAdventureDigest(ctx) ws.StartTriviaBank(ctx) ws.StartTableClock(ctx) @@ -415,8 +416,11 @@ func runLocal(cfg *config.Config) { go ws.Start(ctx) // Push only needs the web auth layer, not Matrix, so a web-only deployment // still runs the digest sender — otherwise subscriptions accumulate but no - // digest ever fires. + // digest ever fires. Adventure alerts are the same: they read facts already in + // the database and never touch Matrix, so -local is a full-fidelity way to + // exercise them. ws.StartPushSender(ctx) + ws.StartAdventureAlerts(ctx) slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr) storage.RunMaintenance()