v0.111.0 — PWA: push notifications
Merged 2026-10-02 — built on version/0.111.0; the merge hash and tag are in
the changelog. Goes to production together with
v0.112.0, which fixes what its device test found;
PWP.0 and the rest of PWP.8 carry into v0.112.0 as PWU.0.
Part three of the Progressive Web App: push notifications. The one part with a schema change and a secret per environment. No module release.
- [ ] PWP.0 — carried from PWA.11 and PWO.0: Lighthouse's installability audit on alpha; the kill switch tried on alpha (
ALPHA_PWA_KILL_SWITCH=1, then unset); a device pass on an iPhone and an Android phone, installed, through two deploys- Blocked on a deploy and devices: needs v0.109.0+ on alpha (a
workflow_dispatchofdeploy-alpha.yml, or Tuesday's scheduled run) and an iPhone and an Android phone.
- Blocked on a deploy and devices: needs v0.109.0+ on alpha (a
- [x] PWP.1 — VAPID:
VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECTfrom the environment, the public key from a small unauthenticated route, push disabled (not failing) without them; the handoff says the owner sets them - [x] PWP.2 —
push_subscriptions:user_id,endpoint(unique),p256dh,auth,user_agent,created_at,last_success_at—dbSchema.ts, the sqlite DDL, the generated pg migration - [x] PWP.3 — subscribe and unsubscribe:
POST/DELETE /api/push/subscriptions, the actor's own rows only; signing out removes this device's subscription - [x] PWP.4 — delivery (PWA-D6):
createMessagepushes to each subscription when the recipient has no connected socket, after the commit; 404/410 deletes the subscription; failures logged, never failing the message; the payload is a title, a line, the deep link and the unread count - [x] PWP.5 — the worker's side:
pushshows the notification and sets the badge;notificationclickfocuses and navigates an open client or opens one at the link - [x] PWP.6 — the switch: "Push notifications on this device" in Settings, where permission is asked from a click; on iOS in a tab, the install-first explanation
- [x] PWP.7 — tests: skipped with a live socket and sent without; a gone endpoint cleaned up; the payload pinned; one user cannot list or delete another's subscription
- Done: unit tests beside each piece (delivery, payload, presence, routes, the worker's push and click, the switch), and
utils/messages.push.test.tsend to end — a realcreateMessageagainst the harness database with onlyweb-pushreplaced: pushed without a socket and not with one, the body never in the payload, a 410 cleaned up with the Message standing, a failing push service never failing it, nobody else's devices touched.
- Done: unit tests beside each piece (delivery, payload, presence, routes, the worker's push and click, the switch), and
- [ ] PWP.8 — verified: on an installed iPhone and Android phone, app closed, an invitation and a chat mention arrive and open the page they name
- Blocked on keys, a deploy and devices: needs VAPID keys set on the environment's API (see the roadmap's Handoff), this branch deployed (pushing
version/0.111.0deploys it to next), and an installed iPhone and Android phone with the switch on.
- Blocked on keys, a deploy and devices: needs VAPID keys set on the environment's API (see the roadmap's Handoff), this branch deployed (pushing
Bugs found in testing
- [x] "Add by username" in the Share modal still makes LastPass offer to fill it, despite
data-lpignoreand the other opt-outs (they reach the input; LastPass goes by how the field looks). Stop it looking like a login field. - [x] Beside that field, a down-chevron that lists who can be picked, for when you do not know the username to type ahead.
- Done: it lists the people you play with — anyone who owns, is a member of, or holds a grant on a campaign you are in (
GET /api/permissions/users/known) — not every account, which would hand the user list to anyone signed in. Typing still narrows it.
- Done: it lists the people you play with — anyone who owns, is a member of, or holds a grant on a campaign you are in (
- [x] Joining a campaign by invite link says it worked, but the first page the new member sees says the campaign was not found; a hard reload shows it.
- Done: the invite page called
fetchCampaigns, whichuseCampaignshas not had since the TanStack Query refactor; the TypeError (after "success" was already shown) skipped both the redirect and the refresh, and the campaign page reads a campaign list cached for two minutes from before the user joined. Accepting now refetches that list, then goes to the campaign.
- Done: the invite page called
- [x] On a phone, the main menu on the map's bottom bar too: a Menu button (hamburger) leftmost, ruled off from the Map / Me / Chat tabs, opening the same drawer as off the map — the global nav with icons and labels. The rest of the site was otherwise unreachable from
/playon a phone. - [x] The phone's menu drawer: Play is always in it (to the campaign list when there is nowhere to resume); every choice is a route — Help and Settings too, which on the map opened yet another sidebar — and lands on its page with any full-screen layer cleared (a character sheet, the Me tab, a maximized context). The drawer stops at the bottom bar, which stays live: the hamburger closes it without going anywhere, any other bar button closes it and does its own thing. And a choice is no longer undone: closing the drawer stepped back through its own history entry even after the choice had navigated past it, landing where you started (since v0.98.0).
- [x] On a slow connection a page whose URL has a slug (a campaign, map or character by name) never loaded: the content area sat on a bare spinner (
RouteResolvingPage) forever.useRouteRefResolutiondiscarded the/api/resolveanswer whenever its effect re-ran while the request was out — a newauthFetchwhen Clerk refreshed its token, or a slug-store update from another request — and the re-run asked nothing because the ref was still marked in flight. Present since v0.91.0; fast connections answered before anything re-rendered. The answer is now always filed (it is a fact about the slug, not about the page), and a resolve that gets no answer retries after 1, 3, 10 and then every 30 seconds instead of waiting for a navigation.useRouteRefResolution.race.test.tsxreproduces both and fails on the old hook.