Harden push notifications: state-valued subscribe, bounded delivery, rotation detection - #396
Conversation
`unsubscribe()` kills the old endpoint immediately, but the reset fact was only recorded on the success path. A `subscribe()` that threw after it left every other Host claiming alerts through a dead address, with no Enable button to repair it. Capture it through a callback fired the moment the old address stops being valid; `subscriptionChanged` still covers a caller that passes none. Also from review: make `reconcilePushSubscribedHosts`'s epoch parameters required, since a defaulted 0 silently discards every post-reset completion and trusts a snapshot taken before it; guard `samePushAddress` against a hand-edited row missing `keys`, which threw out of the subscribe route; and share one VAPID-current predicate between the Client readback and the Host-facing views. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read-modify-write of `push-subscriptions.json` that models a Server key rotation appeared verbatim in three tests, including the exact serialization `writeAtomic` uses — so the store's on-disk format was encoded in the test file in triplicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`POST /api/push/subscribe` reported an event — `deviceRegistrationsReset` — which is only meaningful to a Client that received the response. A committed POST whose response was lost could not be repaired by its idempotent retry, because the retry cannot re-announce a deletion it already performed, so Pocket had to carry a browser-side latch to reconstruct what the Server had done to it. Report the resulting state instead: every Host this device is registered with after the mutation, which `upsert` already had in hand inside the mutex. The retry then simply answers what is registered now, and the lost-response case self-heals. Scoping that answer to the device is safe where the subscriptions GET must not be — this request carries a device signature, so the caller has proven it owns the identity being reported on. Both the POST and the GET now answer the same complete question about the same device, so there is nothing to merge and only an ordering to resolve. That collapses the client: the per-Host completion map, its version counter, the reset epoch, the pending-reset latch, `completePushSubscriptionRegistration`, and `reconcilePushSubscribedHosts` all give way to one registration counter and a staleness check on the read. `subscribeToPushInBrowser` keeps its replacement callback, now required and aimed at the UI rather than at the protocol: when minting the replacement throws there is no response to correct the view with, and every Host would go on claiming alerts through the address that just died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`samePushAddress` optional-chained `keys` because a hand-edited row might not have one — `push-subscriptions.json` is editable by design, since revoking a device is deleting its rows. But that guard sat at one of several consumers: `StoredPushSubscription` still declared `keys` non-optional, and the send path and the device listing both trusted it. Enforce the shape once at the read boundary, which is the only way rows enter the process, so the declared type is true for every caller past it. A mangled row now reads as a missing registration — which re-offers Enable and repairs itself — rather than as a live one nothing can be delivered to, and `samePushAddress` goes back to plain field comparison. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Pocket app was served with no `Cache-Control` and no ETag, so `index.html` fell to browser heuristic freshness. Combined with `emptyOutDir`, which deletes the previous build's content-hashed assets, a client reusing a cached entry document does not merely run stale code — it requests files that no longer exist and the app fails to boot. The build has exactly two kinds of file and they want opposite answers: Vite content-hashes everything under `assets/`, which may then be kept forever, and `public/` passes through unhashed to the root alongside the generated `index.html`, which must be revalidated. The class is read off the request path rather than the resolved file path, which is platform-shaped. Staged on the context before `serveStatic` runs, the same way it stages its own `Content-Type`. Its `onFound` hook is the obvious place and is the wrong one — it fires after the Response has been built, so a header set there is silently dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`web-push`'s `timeout` option is a socket-inactivity bound, so a push service that accepts the connection and then trickles bytes — or stalls mid-handshake — resets it indefinitely and holds the send route's handler open. Nothing dedupes overlapping sends, so successive alarms stack concurrent requests behind it. Add a 15-second wall-clock deadline per send, above the 10-second inactivity bound so it only fires where that one cannot, and far below the 300-second provider TTL. Applied by the route rather than inside `createWebPushSender`, so it holds for any injected `PushSender` and is stated where the route's latency contract lives; because a fan-out starts every send at once, one deadline per send also bounds the route regardless of device count. A cut send reports `failed`, which keeps the four-count response contract intact and — correctly — leaves the row in place, since a timeout is transient like any other failure rather than the permanent death a 404/410 signals. It bounds the route, not the socket: `web-push` accepts no `AbortSignal`, so the losing request is left to its own inactivity timeout rather than cancelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A push service may replace a delivery address on its own, without the VAPID key changing. The subscription stays valid and correctly keyed, so every check Pocket made passed while every row the Server holds pointed somewhere unreachable — leaving each Host claiming "Alerts on" with no Enable button to repair from, until the user happened to tap Enable somewhere else. Record a SHA-256 digest of the address whenever the Server accepts a registration, and compare it on open. A digest rather than the address, since the endpoint is a bearer capability and equality is the only question. One key per device rather than per Host: one service-worker scope holds one subscription, so if it moves, every row for that device is stale together. Absent reads as no opinion rather than as a mismatch, so a device that registered before this existed, or whose storage was cleared, is not forced to re-register. Detection is all the page can do, and `sw.js` is deliberately not where it happens. A `pushsubscriptionchange` handler could reach the device key — a non-extractable CryptoKey in IndexedDB, which a worker can open — but not a session token: that is in memory only, never persisted, and minted solely behind a fresh WebAuthn assertion, which a worker cannot perform. Unattended re-registration would need a credential the trust model does not grant. Also completes `memoryStorage` in the client tests, which silently did not satisfy `PocketStorage` — `lib/tsconfig.app.json` excludes `*.test.ts`, so nothing typechecks the stubs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main independently advanced the same subsystem: VAPID subject derivation with loopback rejection, and failure logs that carry the push service's reason body. That work is complementary to this branch's — main has no request timeout at all, so both bounds added here survive intact. Two conflicts, both resolved by keeping each side: - `server/test/push.test.mjs` — union of the imports and of both test sets. - `docs/specs/server.md` — the delivery-outcomes bullet, where main's reason-body sentence and this branch's two-bounds paragraph both land, with main's TTL sentence folded into the bound ordering rather than repeated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
8df793b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://755cb9cf.mouseterm.pages.dev |
| Branch Preview URL: | https://push-notifications.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
One issue in the cache-header work — the SPA fallback derives its class from the request path, but the response it returns is always the shell.
A GET for a hashed asset that no longer exists (/assets/index-OLD.js) misses in serveStatic, falls through to the app.get('*') fallback — the existing /some/deep/link assertion in static.test.mjs proves the fallback catches unknown paths — and answers 200 text/html with index.html. pocketCacheControl('/assets/index-OLD.js') returns public, max-age=31536000, immutable, so the browser stores an HTML body under a JS URL for a year, immutable so a reload cannot revalidate it.
That is reachable from the exact scenario the doc comment is written against. emptyOutDir plus a live server means a client can request a hashed asset mid-deploy and get the shell. Vite hashes are content-derived, so an unchanged chunk keeps its filename across builds — the poisoned URL is then requested again by every subsequent load, and the app stays broken with no recovery short of clearing site data. Before this PR the same request returned HTML too, but with no explicit freshness, so a reload repaired it.
Inline suggestion below. The alternative worth considering is answering 404 for a /assets/ miss instead of falling through at all — the shell is never a useful answer for a subresource, and it would also stop the "HTML parsed as JS" console error.
Two smaller notes, no action needed if you disagree:
- The new cache tests cover a present asset and the unhashed shell, but not the asset-miss path above — that's where the header is wrong.
reconcilePushSubscribedHostsand its two unit tests are gone and the replacement (drop a read the registration counter overtook,App.tsx) has no test of its own. The new mechanism is much smaller, so this may well be the right trade, but the race it guards is now unexercised.
The rest reads correct to me. upsert's deviceHostIds is sound — deviceRegistrationsReset false implies every surviving row for the device shares stored's endpoint, keys, and VAPID key, so the "no further filtering needed" claim holds; the reset branch trivially leaves one row. The malformed-row guard at the read boundary, the Promise.race deadline reporting failed so the row survives, and the hostIds-as-state contract all check out against the tests.
Review catch on the cache-header change, and a regression it introduced. A GET for an asset that no longer exists missed in `serveStatic`, fell through to the SPA fallback, and came back as `200 text/html` carrying the shell — but the fallback took its cache class from the *request* path, so the shell was labelled `immutable`. The browser then stored an HTML body under a JS URL for a year with no way to revalidate it. Reachable from precisely the window this policy was written for: `emptyOutDir` deletes the previous build's assets, so a client loading mid-deploy asks for one that is gone. Vite hashes are content-derived, so an unchanged chunk keeps its filename across builds and every later load hits the poisoned entry — the app stays broken short of clearing site data. Before this policy the same request also returned HTML, but with no explicit freshness, so a reload repaired it. Two fixes, because either alone leaves a sharp edge. The fallback now sets the shell's class unconditionally: it answers with the shell whatever was asked for, and a response's cache policy describes the response, so deriving it from the request was wrong however the paths are classified. And a miss under `/assets/` is now a 404 rather than reaching the fallback at all — the shell is never a useful answer to a subresource, and 404 also replaces the "unexpected token '<'" parse error with something legible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-on to the merged push-notifications base. Six fixes for races and gaps found while reviewing that work, then three more found while reviewing those.
The central change
POST /api/push/subscribereported an event —deviceRegistrationsReset— which is only meaningful to a client that received the response. A committed POST whose response was lost could not be repaired by its idempotent retry, since the retry cannot re-announce a deletion it already performed. So Pocket carried a browser-side latch to reconstruct what the Server had done to it, and every subsequent fix was another patch on that reconstruction.It now reports state: every Host this device is registered with after the mutation, which
upsertalready had in hand inside the mutex. The retry simply answers what is registered now, and the lost-response case self-heals.Because the POST and the
GET /api/push/subscriptionsread then answer the same complete question about the same device, there is nothing to merge between them — only an ordering. That collapsed the client from four coupled refs and two exported helpers down to one registration counter and a staleness check:pushEnableCompletionsRef(per-Host version map)pushEnableCompletionVersionRefpushRegistrationResetVersionRefpushRegistrationVersionRefpushRegistrationResetPendingRefcompletePushSubscriptionRegistrationreconcilePushSubscribedHostsScoping that response to the device is safe where the subscriptions GET deliberately is not: the request carries a device signature, so the caller has proven it owns the identity being reported on.
Also here
push-subscriptions.jsonis hand-editable by design — revoking a device is deleting its rows — so a half-finished edit now reads as a missing registration (which re-offers Enable and self-repairs) rather than as a live row nothing can be delivered to. One guard at the read boundary instead of each consumer defending itself.failed, so the row survives to be retried. It bounds the route, not the socket —web-pushaccepts noAbortSignal./assets/areimmutable; the unhashed root isno-cache. The revalidation half is load-bearing:emptyOutDirdeletes the previous build's hashed files, so a heuristically cachedindex.htmldid not merely serve stale code, it requested deleted files and the app failed to boot.A
pushsubscriptionchangehandler is deliberately not the mechanism. A worker can reach the device key (a non-extractableCryptoKeyin IndexedDB) but not a session token — that is in memory only, never persisted, and minted solely behind a fresh WebAuthn assertion, which a worker cannot perform. Unattended re-registration would need a credential the trust model does not grant.Testing
Full suite green across all nine packages, including this branch's additions: the lost-response self-heal, a wedged sender not holding up a fan-out, malformed-row handling, the two cache-header classes, and endpoint-rotation detection.
Not yet exercised on a phone. Every server test drives a fake sender and the browser half is stubbed globals, so the suite proves the state machines and the wire contract and nothing about delivery. The
hostIdspath in particular has never run against a real push service.Note on the merge
origin/mainis merged in. main independently advanced the same subsystem — VAPID subject derivation with loopback rejection, and failure logs carrying the service's reason body — which is complementary: main has no request timeout at all, so both bounds added here survive. Two conflicts, both resolved by keeping each side.🤖 Generated with Claude Code