feat: wake a closed browser, over Web Push - #40
Conversation
The server registered wake tokens and woke nobody: `Waker`'s only
implementation was `Silent`. A messenger that does not wake the phone is not
a messenger, and this was the first thing between the deployment and somebody
able to use it.
The roadmap described FCM and APNs and called the missing half "the part that
requires secrets". That was true and it was not the hard part. Device-side
registration needs a Tauri plugin that does not exist, therefore Kotlin and
Swift, and none of it compiles or runs here — no NDK, no macOS host, no
device. Writing it would have produced what this repository refuses
elsewhere: integration code that has never been executed and looks like a
feature.
Web Push removed that wall for one reason. The wake-up carries nothing, so
there is no payload to encrypt, so the whole content-encryption half of Web
Push — RFC 8291, aes128gcm, the `p256dh` and `auth` secrets — is unused. What
is left is one ES256 signature. It needed no migration for the addresses
either: without a payload the only thing worth keeping is the endpoint, so
`Address { provider, token }` holds a subscription unchanged.
One variable turns it on, `VAPID_SUBJECT`, and there is no private key to
supply: the pair belongs to the server and is created on first start, the
shape `log_key` has had since the transparency log. Unset, the waker stays
`Silent`, the key route answers 503 and the client hides the control — the
second of the three limits in `0011_push.sql`, still the behaviour to
preserve first.
Verified against the real thing, which is what separates this from a hope:
Chrome subscribed through fcm.googleapis.com, the server signed and pushed,
Google accepted, and the worker showed "New message" with the tab closed. The
test that proves it is committed `#[ignore]` with the command to replay it.
Six more run against a fake service and check what no real one can be asked
to: that the body on the wire is empty, that the token verifies under the
advertised key, that a 410 drops the subscription and a 500 does not.
Two things this cost elsewhere, both stated in the roadmap:
- The server has an outbound HTTP client now, which it never had. reqwest
with rustls, no cookie store, no redirect following — a push endpoint
answering with a redirect is not one to follow carrying a bearer token.
- There is a service worker, and `notifications.ts` argued against one. The
objection was a cache of the application shell served by the server the
desktop build exists to stop trusting. This one caches nothing, registers
no fetch handler, and `push.test.ts` asserts that rather than trusting the
comment.
Found while using it: two toggles a second apart left the switch claiming
this browser would be woken with nothing subscribed, and the reverse after.
Subscribing and unsubscribing both reach the push service and finish in an
order nobody chose. The displayed state is now read back from the browser
instead of inferred from the call, and toggles are chained so only one is in
flight.
No FCM, no APNs: the packaged mobile application is still only notified while
it is open. `Vapid::wake` matches on the provider name, so a second emitter
lands beside it without touching the call site.
Settings were a route that replaced the centre column, so opening them made the whole window claim you had gone somewhere: the conversation you were reading disappeared to show a theme picker, and coming back meant navigating rather than closing. They are an interruption of the application, not a place in it, and now they say so — a dialog over whatever was on screen, with the rail and the thread still behind it. **The URL is kept, and that is the part worth defending.** `#/settings/notifications` still deep-links, the back gesture still steps out of a section before leaving, and `RouteAnnouncer` still has something to announce. A modal driven by local state would have been shorter and would have thrown all of that away. So `open` is derived from the route and closing navigates: one source of truth for whether settings are showing. `Dialog` gains `size="panel"` rather than a second modal being written beside it. That file exists for four things Radix does and every hand-rolled modal omits — the focus trap, `inert` on the rest, the scroll lock, focus restored on close — and a settings modal built next to it would have omitted the same four invisibly. `panel` changes layout only: wider, taller, no padding of its own, and no close button of its own because the content already carries one. A second cross would have been off-screen and still in the tab order, which is the mirror image of the hover-only control this project refuses. Checked in a browser: opened over a conversation, Escape closed it and landed back in that conversation, and a direct link to a section opened the dialog with the focus inside it.
§4 opened with "the server records tokens, decides who to wake, and sends nothing". That was true when it was written and stopped being true with Web Push, and a threat model describing a feature as inert while it delivers is worse than one that omits it: a reader checks what the project claims to do before checking what it does. What is written instead is what holds now — it works on browsers, only there, and only once a deployment sets `VAPID_SUBJECT`. The three limits that follow are re-pointed at the service that actually does the waking, and one of them gains the reason the wake-up is empty is now structural rather than a policy: with no payload there is nothing to encrypt, so RFC 8291 is unused and the subscription's secrets are never read. The limitations table needed no change. It described the cost, and the cost did not move — a server that chooses whom to wake still gains a targeted activity trigger, and no cryptography answers that.
There was a problem hiding this comment.
💡 Codex Review
whispee/crates/server/src/push.rs
Lines 357 to 360 in 39601f3
For subscribed devices with the tab closed, this query wakes every group member without considering the client's mute setting or blocked senders, and the empty push contains no group or sender information that would let the worker filter it. Consequently a muted conversation—or a blocked member in a group—still raises a system notification for every post, contradicting the existing notification semantics that explicitly make mute and blocking prevent interruptions.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let request = self | ||
| .http | ||
| .post(endpoint) |
There was a problem hiding this comment.
Restrict push endpoints before issuing requests
Any authenticated device can submit an arbitrary http or https string through /v1/push/token, and the next message to one of its groups makes the server POST to that URL. A malicious member can therefore target loopback, cloud metadata, or other private-network services and use delivery timing/status side effects for SSRF; validate endpoints against supported push-service origins or otherwise reject private/untrusted destinations before sending.
Useful? React with 👍 / 👎.
| const registration = await navigator.serviceWorker.getRegistration("/"); | ||
| const subscription = await registration?.pushManager.getSubscription(); | ||
| if (!subscription) return; | ||
|
|
||
| await api.setPushToken(PROVIDER, subscription.endpoint); |
There was a problem hiding this comment.
Remove subscriptions when forgetting an identity
When a user forgets an identity and creates or restores another account on the same origin, the browser retains the origin-wide subscription and this replay associates its endpoint with the new device, while the old device's server row is never removed. Both accounts then point at the same live subscription, so messages for the supposedly forgotten account continue producing notifications in the new session; the forget flow must unsubscribe and forget the old device's token before clearing its credentials.
Useful? React with 👍 / 👎.
| event.waitUntil( | ||
| self.registration.showNotification(TITLE, { |
There was a problem hiding this comment.
Suppress push notifications while a client is visible
A Push API event is delivered to the service worker even when a controlled Whispee tab is open; it is not limited to the closed-tab case. Because this handler always calls showNotification while App.tsx independently creates its local notification for the same arrival, subscribed users with the app open receive a generic push notification in addition to the normal conversation notification. Check for a visible window client and skip the worker notification in that case.
Useful? React with 👍 / 👎.
The suites check this server against RFC 8292 as we read it, using a fake service that verifies the signature and asserts the body is empty. A service disagreeing with that reading would pass every one of them. `a_real_push_service_accepts_the_token` is the other half, ignored by default because it needs a subscription minted by a real browser. It signs exactly as the emitter does and sends to the live endpoint, so the answer reaches the test instead of a `tracing::warn!`. Run once against Chrome's service: `201 Created`, and the notification appeared with every tab closed — server to FCM to service worker to screen. Mozilla has not been tried, and the roadmap says so rather than implying otherwise. `docs/DEPLOY.md` gains the command beside the browser pass, because it is what separates "the service refused us" from "the browser showed nothing".
…room A confirmation already landed well: portalled, animated, announced politely, expired by `report.ts` rather than by a timer inside the component. An error did not. It was a `Banner` with `rounded-none border-x-0 border-b-0` mounted as a flex child of the shell, so it took height from the conversation for as long as it stood, and up to four of those could stack. Both halves come through `ui/Toast.tsx` now. The lifetime does not move — an error still waits to be dismissed or replaced, because it usually means something is left to decide. Only the rendering does. # Radix, for one reason A message can now carry an action, and a button that appears unbidden has to be reachable by keyboard **without stealing focus**. That is a viewport in the tab order, a recall hotkey, and an announcement whose urgency matches the message — `type="foreground"` for an error, `"background"` for a confirmation, the same distinction `Banner` draws between `alert` and `status`. It also buys an exit. `Overlays.tsx` records that `useEntered` has no counterpart because Radix unmounts on close; a toast keeps its node through `data-state="closed"` and fades instead of blinking out. The expiry stays in `report.ts` and Radix's own `duration` is `Infinity`: two owners of one timer is one too many. # What the browser pass changed The action was wired nowhere, which would have shipped a path never executed. It is on the failing poll now: "Retry" does at once what the thirty-second timer would have done, and a second failure replaces the message rather than stacking. And Radix's `label` prop — documented as the accessible name, accepted by its types — reaches no attribute in 1.2.23: the rendered `<ol>` carried `tabindex` and `class` and nothing else. `aria-label` is set directly, and checked in the browser rather than assumed. Verified by hand, since `node --test` has no DOM and no component in this repository is testable: the confirmation expires by itself, the error persists and is cleared by the next successful poll, one `<li>` after several failures and a retry, and the whole thing draws above the settings dialog — which matters more since settings became one.
The web client shows a banner saying the server "could deliver a version
that exfiltrates your keys. No browser API fixes that." Making that sentence
false means letting somebody check the delivered code against something the
delivering server does not control — a manifest of hashes published by CI.
That was impossible while three variables were substituted into the bundle at
build time: every deployment produced different bytes, so a manifest could
only ever describe one instance.
Measured before and after, which is the only reason to believe any of this:
two builds with completely different configuration used to differ in four
files. They are now byte-identical, all 226 of them.
- `VITE_API_URL` is gone. The client asks the origin that served it, which
`deploy/` already arranges through Caddy, and development gets the same
code path through a Vite proxy rather than a second one. The desktop shell
is the one target whose own origin names nothing reachable, so it keeps a
literal — the same one `tauri.conf.json` already pins, and `csp.test.ts`
fails if the two disagree.
- `VITE_LOG_PUBKEY` leaves the web bundle and stays on the desktop.
`pinning.ts` already argued that on the web the pin is "not a defence
against the party that builds the bundle": the server ships the pin along
with the code it constrains. What it did buy — a substitution that breaks
every client at once instead of silently — is what a verifiable build
provides, and provides better.
- `VITE_MEDIA_URL` stays, and is the acknowledged exception. A media host has
to be named in the policy, so a deployment configuring calls stops matching
the published build: verifiable or calls, not both, until the media server
sits behind the same origin.
`connect-src` is now `'self'` and nothing else, which is strictly tighter
than naming an origin. It covers the WebSocket too — CSP level 3 matches
`wss:` under an `https:` document — and that was the doubtful part, so it was
checked in a browser rather than read in a specification: the API answers 200
on the page's own origin and `/v1/gateway` opens.
`socketUrl` exists because `BASE_URL.replace(/^http/, "ws")` was fine while
the base was absolute and is not now: an empty base leaves a bare path, and
`new WebSocket("/v1/gateway")` throws at the one moment the real-time session
is being opened.
What this simplifies rather than complicates: the deployment no longer takes
the domain as a build argument, and changing it is a restart instead of a
rebuild. `docs/DEPLOY.md` said the opposite in a section of its own; it says
this instead.
A browser that has Whispee closed now learns a message arrived. It was the first thing between the deployment shipped yesterday and somebody able to use this.
What this changes, and why this rather than the obvious alternative
The obvious alternative was the one the roadmap described: FCM and APNs. It said the missing half was "all of it the part that requires secrets", and that was true and it was not the hard part. The hard part is device-side registration — a Tauri plugin that does not exist, therefore Kotlin and Swift, none of which compiles or runs on this machine: no NDK, no macOS host, no device. Writing it would have produced exactly what this repository refuses elsewhere, integration code that has never been executed and looks like a feature.
Web Push removed that wall for one specific reason. The wake-up carries nothing — the third of the three limits in
0011_push.sql— so there is no payload to encrypt, so the whole content-encryption half of Web Push is unused: RFC 8291,aes128gcm, thep256dhandauthsubscription secrets, all of it. What is left is one ES256 signature per push service, cached until it nears expiry.It also needed no migration for the addresses. Without a payload the only thing worth keeping is the endpoint URL, so
push::Address { provider, token }holds a subscription unchanged, andVapid::wakematches on the provider name so FCM and APNs land beside it later without touching the call site.One variable turns it on, and there is no private key to supply.
VAPID_SUBJECTis the contact a push service is told to reach; the key pair belongs to the server and is created on first start (0020_vapid.sql), the shapelog_keyhas had since the transparency log. The alternative — aVAPID_PRIVATE_KEYvariable — means every operator produces a P-256 key in the one encoding this server accepts, and a private key travels through a shell history on its way in. Unset,Silentis the waker, the key route answers 503, and the client hides the control.Settings became a dialog in the second commit. They were a route that replaced the centre column, so opening them made the window claim you had gone somewhere: the conversation you were reading disappeared to show a theme picker. The URL is kept —
#/settings/notificationsstill deep-links, the back gesture still steps out of a section first — because a modal on local state would have been shorter and thrown that away.Dialoggainedsize="panel"rather than a second modal being written beside it: that file exists for the four things Radix does and every hand-rolled modal omits, and a settings modal built next to it would have omitted the same four invisibly.What it does not solve
#[ignore]d test that pushes to a real subscription, with the command to run it, and I ran it — see below.Security impact
Sealed sender is weakened wherever a deployment turns push on, in the direction §4 of the threat model already described: a server choosing whom to wake gains a targeted activity trigger. What compensates is that it is strictly optional, inert without configuration, and empty — and that the settings screen states both halves of the cost above the switch rather than under it.
This was already in the limitations table and the table needed no change: the cost did not move, only the feature's existence did. What did need changing is §4's opening, which said the server "sends nothing" — true when written, false now, and a threat model describing a live feature as inert is worse than one omitting it. That is the third commit.
The server has an outbound HTTP client for the first time.
reqwestwith rustls, no cookie store, no redirect following — a push endpoint answering with a redirect is not one to follow carrying a bearer token. No request is made unless the variable is set.There is a service worker, and
notifications.tsargued against one. That objection was about caching the application shell served by the server the desktop build exists to stop trusting. This one caches nothing: nofetchhandler, noCache, no precache manifest.push.test.tsasserts that rather than trusting the comment.Invariants
crypto-coredoes not depend onratchet-lab, directly, transitively, behind a feature flag, or in a test. Nor does any other crate a user executes.A second thing on this branch: errors stopped shrinking the room
Successes already landed well — portalled, animated, announced politely, expired by
report.tsrather than by a timer inside the component. Errors did not: aBannerwithrounded-none border-x-0 border-b-0mounted as a flex child of the shell, so it took height from the conversation for as long as it stood, and up to four could stack.Both halves come through
ui/Toast.tsxnow, on@radix-ui/react-toast. The lifetime did not move — an error still waits to be dismissed or replaced, because it usually means something is left to decide. Only the rendering did.Radix for one reason: a message can now carry an action, and a button that appears unbidden has to be reachable by keyboard without stealing focus. That is a viewport in the tab order, a recall hotkey, and an announcement whose urgency matches the message —
type="foreground"for an error,"background"for a confirmation, the same distinctionBannerdraws betweenalertandstatus. It also buys an exit animation, whichuseEnteredcannot give.The expiry stays in
report.tsand Radix's owndurationisInfinity: two owners of one timer is one too many.Two things the browser pass changed, and neither would have surfaced any other way:
labelprop reaches no attribute in 1.2.23. Its types accept it and the documentation calls it the accessible name; the rendered<ol>carriedtabindexandclassand nothing else.aria-labelis set directly instead, checked in the browser rather than assumed.What was run
cargo test --release— 425 passed, 0 failedcargo clippy --workspace --all-targets --release -- -D warnings— cleanwasm-pack test --node crates/crypto-wasm— not run: no crypto and no WASM changed on this branchcargo test -p server --releasewithdocker compose up -d— including the six newtests/webpush.rspnpm run typecheck,pnpm test(721),pnpm run lintinapps/webfcm.googleapis.comagainst the advertised key;{title: "Whispee", body: "New message", tag: "whispee-wake"}with the tab closed;push_tokens), and unsubscribed both halves on the way back;WEBPUSH_ENDPOINT=… cargo test -p server --release --test webpush -- --ignoredagainst a live subscription: Google answered201 Created. That test is ignored by default and is the only thing that can establish a real service accepts these tokens — every other one checks this server against RFC 8292 as we read it, and a service disagreeing with that reading would pass them all. Mozilla has not been tried;<li>remains after several failures and a retry, the toast draws above the settings dialog — which matters more now that settings are one — the focus is not stolen and the Retry button is reachable, and the announcing region isaria-live="assertive"for an error againstpolitefor a confirmation.Found while doing that: two toggles a second apart left the switch claiming this browser would be woken with nothing subscribed, and the reverse after — subscribing and unsubscribing both reach the push service and finish in an order nobody chose. The displayed state is now read back from the browser instead of inferred from the call, and toggles are chained so only one is in flight. The read-back I re-checked in the browser; the chaining I did not, because a second Vite on this machine and a shared browser took the tab away.
Migrations
0020_vapid.sqlis added, not edited — no existing migration's checksum changed, so no database needs recreating. It is one row holding the deployment's P-256 private scalar, with the same singleton and length constraintslog_keycarries.Checklist
dev, notmain.README.md,docs/ROADMAP.md,docs/DEPLOY.md,docs/THREAT-MODEL.md,deploy/.env.example.🤖 Generated with Claude Code
https://claude.ai/code/session_01PDvUtP9fUQRwHYszVpJtnS