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
This commit is contained in:
prosolis
2026-07-24 18:22:34 -07:00
parent 1dfd3ac9fb
commit b19ab5eff0
15 changed files with 1135 additions and 62 deletions
+19 -6
View File
@@ -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