Skip to content

Harden push notifications: state-valued subscribe, bounded delivery, rotation detection - #396

Merged
nedtwigg merged 14 commits into
mainfrom
push-notifications
Aug 18, 2026
Merged

Harden push notifications: state-valued subscribe, bounded delivery, rotation detection#396
nedtwigg merged 14 commits into
mainfrom
push-notifications

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

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/subscribe reported an eventdeviceRegistrationsReset — 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 upsert already 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/subscriptions read 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:

before after
pushEnableCompletionsRef (per-Host version map)
pushEnableCompletionVersionRef
pushRegistrationResetVersionRef pushRegistrationVersionRef
pushRegistrationResetPendingRef
completePushSubscriptionRegistration
reconcilePushSubscribedHosts two lines in the effect

Scoping 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

  • Rows are validated as they are read. push-subscriptions.json is 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.
  • Delivery is bounded by wall clock, not just socket idle. A push service that accepts the connection and then trickles bytes resets the inactivity timer indefinitely. A 15s deadline per send sits above the 10s inactivity bound and far below the 300s TTL. Applied in the route so it holds for any injected sender; a cut send reports failed, so the row survives to be retried. It bounds the route, not the socket — web-push accepts no AbortSignal.
  • Cache headers on the Pocket build. Hashed assets under /assets/ are immutable; the unhashed root is no-cache. The revalidation half is load-bearing: emptyOutDir deletes the previous build's hashed files, so a heuristically cached index.html did not merely serve stale code, it requested deleted files and the app failed to boot.
  • Endpoint rotation is noticed. A push service may replace an address without the VAPID key changing, leaving every stored row unreachable while the browser still reports a valid subscription. Pocket now records a digest of what it registered and compares on open. Absent reads as no opinion, so nobody is forced to re-register on upgrade.

A pushsubscriptionchange handler is deliberately not the mechanism. A worker can reach the device key (a non-extractable CryptoKey in 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 hostIds path in particular has never run against a real push service.

Note on the merge

origin/main is 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

nedtwigg and others added 13 commits July 29, 2026 16:58
`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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 18, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8df793b
Status: ✅  Deploy successful!
Preview URL: https://755cb9cf.mouseterm.pages.dev
Branch Preview URL: https://push-notifications.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • reconcilePushSubscribedHosts and 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.

Comment thread server/src/app.ts Outdated
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>
@nedtwigg
nedtwigg merged commit 75ae26f into main Aug 18, 2026
11 checks passed
@nedtwigg
nedtwigg deleted the push-notifications branch August 18, 2026 06:12
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