Skip to content

fix(remote): make iOS push actually deliver, and let an expired Pocket session re-auth - #338

Open
nedtwigg wants to merge 2 commits into
mainfrom
fix/push-vapid-and-session-recovery
Open

fix(remote): make iOS push actually deliver, and let an expired Pocket session re-auth#338
nedtwigg wants to merge 2 commits into
mainfrom
fix/push-vapid-and-session-recovery

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Aug 3, 2026

Copy link
Copy Markdown
Member

Two bugs found while testing push notifications on a real iPhone. Both are in code already merged to main (#332), not in anything unreleased.

1. Web Push never reached any iPhone

The default VAPID subject was mailto:admin@localhost. Apple rejects a loopback subject, so a self-hoster following the spec got a server that booted clean, logged nothing unusual, answered 200 on send — and delivered nothing to the one platform this feature exists for.

The rule was established empirically against web.push.apple.com, not inferred:

subject result
mailto:admin@localhost 403 {"reason":"BadJwtToken"}
https://localhost:3000 403 {"reason":"BadJwtToken"}
mailto:admin@example.com 201
https://<real-origin> 201

So it is loopback specifically — Apple does not check that the contact is reachable.

The fix defaults the subject to the server's own origin: a real operator contact, and any deployment that can serve Pocket already has a valid https origin because WebAuthn requires one. A loopback dev server has no usable contact and could not reach a phone anyway, so push is disabled there rather than left half-working — the VAPID key and the sender are now advertised together or not at all, since a phone registering against a key the server cannot sign with is subscribed to a push it can never receive. An explicitly configured loopback subject is now a startup error.

Also logs the push service's own reason body next to the status. A bare 403 does not separate a bad subject from a bad key from a bad payload, and that log line is the only place the explanation is ever visible — it is what turned this from an hour of guessing into a one-line diagnosis.

2. An expired Pocket session had no way back in

Sessions live only in the server's memory, so they end on their 12h expiry and on every server restart, while the passkey and paired-host markers outlive both — recovery is one passkey prompt. Pocket never offered it. A dead token surfaced as the raw string relay socket error, Refresh re-sent the same dead token, and there is no sign-out; an installed PWA has no address bar either, so force-quitting the app was the only way back in.

A session-gate 401 now clears the token and throws SessionExpiredError; the app tears down any live adapter and returns to sign-in carrying that message. Pairing and push registration are untouched.

Two things the trigger has to get right:

  • A 401 alone is ambiguous. A wrong setup password and a rejected device signature answer 401 too, and signing the user out for those would be worse than the bug being fixed. Both sides now agree on a shared UNAUTHORIZED_ERROR in the wire contract instead of an informal string.
  • A rejected relay upgrade carries no status — the browser reports a bare error event. openSocket asks an authenticated route what happened, so a genuine network failure stays a socket error instead of falsely signing the user out.

Testing

  • pnpm test and pnpm build pass on this branch.
  • 6 new tests: loopback subject rejection and the origin-derived default (server), both 401 flavors and both socket outcomes (Pocket).
  • Verified by hand end to end: a terminal event on the laptop produced a push notification on a real iPhone, through tailscale serve with no DORMOUSE_VAPID_SUBJECT configured — the derived default doing the work.
  • Also verified the disabled path (localhost origin warns at startup and serves applicationServerKey: null) and the fail-fast path (an explicit loopback subject exits with a message naming Apple).

Known gap: the App-level "drop to sign-in" state transition has no test. App constructs its own PocketClient internally, so testing it would mean refactoring the component for injection — more churn than the six lines deserve. The decision logic it depends on (which errors count, token clearing) is fully covered at the client layer; the transition itself was verified by hand.

Specs updated alongside: server.md (config table, the disabled/loopback rules, delivery logging, run instructions) and pocket-app.md (a new section on expired-session recovery).

🤖 Generated with Claude Code

nedtwigg and others added 2 commits August 2, 2026 17:06
The default `mailto:admin@localhost` made Web Push non-functional on iOS,
the one platform the feature exists for. Apple rejects a loopback subject
with `403 {"reason":"BadJwtToken"}`, so a self-hoster following the spec
got a server that booted clean, answered 200 on send, and silently
delivered nothing. Verified against web.push.apple.com: both
`mailto:admin@localhost` and `https://localhost:3000` are refused, while
`mailto:admin@example.com` and an ordinary https origin return 201 — the
rule is loopback specifically, not reachability of the contact.

The server's own origin is the right zero-config default: it is a real
operator contact, and any deployment that can serve Pocket already has a
valid https origin because WebAuthn requires one. A loopback dev server
has none, so push is disabled there rather than left half-working — the
key and sender are now advertised together or not at all, since a phone
registering against a key the server cannot sign with is subscribed to a
push it can never receive.

Also log the push service's own reason body alongside the status. A bare
403 does not separate a bad subject from a bad key from a bad payload,
and that log line is the only place the explanation is ever visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sessions live only in the server's memory, so they end on their 12h
expiry and on every server restart, while the passkey and paired-host
markers outlive both — recovery is one passkey prompt. Pocket never
offered it: a dead token surfaced as the raw string `relay socket error`,
Refresh re-sent the same dead token, and there is no sign-out. An
installed Pocket has no address bar either, so force-quitting the app was
the only way back in.

A session-gate 401 now clears the token and throws SessionExpiredError;
the app tears down any live adapter and returns to sign-in carrying that
message. Pairing and push registration are untouched.

Two things the trigger has to get right:

- A 401 alone is ambiguous — a wrong setup password and a rejected device
  signature answer 401 too, and signing the user out for those would be
  worse than the bug being fixed. Both sides now agree on a shared
  UNAUTHORIZED_ERROR rather than an informal string.
- A rejected relay upgrade carries no status; the browser reports a bare
  `error` event. openSocket asks an authenticated route what happened, so
  a genuine network failure stays a socket error instead of falsely
  signing the user out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: a14cb07
Status: ✅  Deploy successful!
Preview URL: https://5dda0e53.mouseterm.pages.dev
Branch Preview URL: https://fix-push-vapid-and-session-r.mouseterm.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants