From b19ab5eff0d1c4ba1acd63a8713afc19831cbb79 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:22:34 -0700 Subject: [PATCH] adventure: tell a player what happened while they were away The site could only reach somebody who was already looking at it. Push existed and adventure used none of it, so the one communal event in the game -- the Siege -- was invisible to anyone not sitting in Matrix, and a player whose adventurer died found out whenever they next opened a tab. Four opt-in categories, every one of them off until asked for: the Siege (realm-wide, begins and ends), your expedition ending, your adventurer wandering off, and a contract landing on you. Turning on news notifications is not consent to be told about the game, so nothing here enrolls anybody automatically. No new wire. Every trigger is a dispatch already landing in adventure_events, so this is Pete-side only and gogobee is untouched. Two things it needed from storage. push_subscriptions now keeps the Matrix localpart alongside the OIDC subject, because every ownership join in the schema is keyed on the localpart and the sender runs on a ticker with no session to read one from -- without it there is no way to answer "whose adventurer is this". And the alerts carry their own watermark, kept apart from the digest's: the two run on different clocks and one column would let each consume the other's backlog. The ownership join is re-read on every pass rather than trusted from the subscription row, so an opt-out or a removal closes the channel at once. It fails closed in both directions, and an unresolved owner can never fall through to a broadcast -- a game alert naming somebody's adventurer, delivered to the wrong phone, is a privacy leak dressed as a feature. An existing subscription carries watermark 0, which read literally means "has never been told anything" and would page every subscriber for the whole history of the realm on the first tick after deploy. Those rows are stamped to now and start from the next dispatch. Verified against a running Pete with a real push service, real P-256 client keys and real encryption: the right person is notified, the wrong one is not, a second pass is silent, and dropping the player from the board takes the channel with it. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ --- internal/storage/db.go | 12 + internal/storage/push.go | 56 +++- internal/storage/push_adventure.go | 86 +++++ internal/storage/push_adventure_test.go | 155 +++++++++ internal/storage/push_test.go | 10 +- internal/storage/schema.go | 25 +- internal/web/handlers.go | 2 + internal/web/push_adventure.go | 420 ++++++++++++++++++++++++ internal/web/push_adventure_test.go | 283 ++++++++++++++++ internal/web/push_sender.go | 36 +- internal/web/pwa.go | 6 +- internal/web/static/js/prefs.js | 8 +- internal/web/static/js/pwa.js | 63 ++++ internal/web/templates/layout.html | 29 ++ main.go | 6 +- 15 files changed, 1135 insertions(+), 62 deletions(-) create mode 100644 internal/storage/push_adventure.go create mode 100644 internal/storage/push_adventure_test.go create mode 100644 internal/web/push_adventure.go create mode 100644 internal/web/push_adventure_test.go 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()