Skip to content

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_dispatch of deploy-alpha.yml, or Tuesday's scheduled run) and an iPhone and an Android phone.
  • [x] PWP.1 — VAPID: VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT from 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): createMessage pushes 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: push shows the notification and sets the badge; notificationclick focuses 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.ts end to end — a real createMessage against the harness database with only web-push replaced: 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.
  • [ ] 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.0 deploys it to next), and an installed iPhone and Android phone with the switch on.

Bugs found in testing

  • [x] "Add by username" in the Share modal still makes LastPass offer to fill it, despite data-lpignore and 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.
  • [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, which useCampaigns has 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.
  • [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 /play on 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. useRouteRefResolution discarded the /api/resolve answer whenever its effect re-ran while the request was out — a new authFetch when 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.tsx reproduces both and fails on the old hook.