Files
Pete/internal/storage/push.go
T
prosolis b19ab5eff0 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
2026-07-24 18:22:34 -07:00

142 lines
5.2 KiB
Go

package storage
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
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 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, 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,
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)
}
return nil
}
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
// the digest sender's prune path, where a push service has reported the endpoint
// gone (404/410) and there's no caller identity to scope by. User-initiated
// opt-outs must use RemovePushSubscriptionForUser.
func RemovePushSubscription(endpoint string) error {
_, err := Get().Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
if err != nil {
return fmt.Errorf("remove push subscription: %w", err)
}
return nil
}
// RemovePushSubscriptionForUser drops an endpoint only if it belongs to sub, so
// a signed-in user can't unsubscribe another account's device by presenting its
// endpoint string. A no-op (no matching row) is not an error.
func RemovePushSubscriptionForUser(sub, endpoint string) error {
_, err := Get().Exec(
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_sub = ?`, endpoint, sub)
if err != nil {
return fmt.Errorf("remove push subscription: %w", err)
}
return nil
}
// ListPushSubscriptions returns every stored subscription, for the digest sender.
func ListPushSubscriptions() ([]PushSubscription, error) {
rows, err := Get().Query(
`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
}
defer rows.Close()
var out []PushSubscription
for rows.Next() {
var p PushSubscription
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)
}
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 {
_, err := Get().Exec(
`UPDATE push_subscriptions SET last_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
if err != nil {
return fmt.Errorf("touch push subscription: %w", err)
}
return nil
}
// NewClassifiedSince returns classified stories first seen after sinceUnix,
// newest first, capped at limit. The digest sender uses it to count and preview
// what's new for a subscriber; it carries just the fields a digest needs.
func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) {
rows, err := Get().Query(
`SELECT id, headline, source, channel, seen_at
FROM stories
WHERE classified = 1 AND channel NOT IN ('_discarded', '_duplicate') AND seen_at > ?
ORDER BY seen_at DESC
LIMIT ?`, sinceUnix, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.ID, &s.Headline, &s.Source, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}