fix(remote): make iOS push actually deliver, and let an expired Pocket session re-auth - #338
Open
nedtwigg wants to merge 2 commits into
Open
fix(remote): make iOS push actually deliver, and let an expired Pocket session re-auth#338nedtwigg wants to merge 2 commits into
nedtwigg wants to merge 2 commits into
Conversation
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>
Deploying mouseterm with
|
| 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 |
dormouse-bot
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, answered200on send — and delivered nothing to the one platform this feature exists for.The rule was established empirically against
web.push.apple.com, not inferred:mailto:admin@localhost403 {"reason":"BadJwtToken"}https://localhost:3000403 {"reason":"BadJwtToken"}mailto:admin@example.com201https://<real-origin>201So 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
403does 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:
UNAUTHORIZED_ERRORin the wire contract instead of an informal string.errorevent.openSocketasks an authenticated route what happened, so a genuine network failure stays a socket error instead of falsely signing the user out.Testing
pnpm testandpnpm buildpass on this branch.tailscale servewith noDORMOUSE_VAPID_SUBJECTconfigured — the derived default doing the work.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.
Appconstructs its ownPocketClientinternally, 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) andpocket-app.md(a new section on expired-session recovery).🤖 Generated with Claude Code