Skip to content

feat: wake a closed browser, over Web Push - #40

Merged
Sycatle merged 6 commits into
devfrom
feat/web-push
Aug 25, 2026
Merged

feat: wake a closed browser, over Web Push#40
Sycatle merged 6 commits into
devfrom
feat/web-push

Conversation

@Sycatle

@Sycatle Sycatle commented Aug 25, 2026

Copy link
Copy Markdown
Owner

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, the p256dh and auth subscription 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, and Vapid::wake matches 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_SUBJECT is 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 shape log_key has had since the transparency log. The alternative — a VAPID_PRIVATE_KEY variable — 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, Silent is 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/notifications still 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. Dialog gained size="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

  • No FCM, no APNs. The packaged mobile application is still only notified while it is open. The wall described above has not moved.
  • iOS needs the site installed to the home screen before it will subscribe at all.
  • The notification is generic. "New message", nothing else. The worker cannot decrypt — the MLS keys are in the page's memory, not the worker's — and moving them would hand the decryption keys to a context that outlives every tab.
  • Push degrades sealed sender, and nothing cryptographic answers it. A server that stops waking four members of five makes the next post attributable to the fifth.
  • No test can establish that Google and Mozilla accept these tokens. The suite checks the token against RFC 8292 and against the advertised key; the residual risk is a service disagreeing with that reading. There is an #[ignore]d test that pushes to a real subscription, with the command to run it, and I ran it — see below.
  • The retry queue for a failed vault drop is still in memory, unchanged by this branch and still in the roadmap.

Security impact

  • No security or privacy property is affected.
  • A property is affected. Which one, in which direction, and what compensates:

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. 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. No request is made unless the variable is set.

There is a service worker, and notifications.ts argued 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: no fetch handler, no Cache, no precache manifest. push.test.ts asserts that rather than trusting the comment.

Invariants

  • crypto-core does not depend on ratchet-lab, directly, transitively, behind a feature flag, or in a test. Nor does any other crate a user executes.
  • No behaviour was added that only works in a debug build.

A second thing on this branch: errors stopped shrinking the room

Successes already landed well — portalled, animated, announced politely, expired by report.ts rather than by a timer inside the component. Errors did not: 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 could stack.

Both halves come through ui/Toast.tsx now, 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 distinction Banner draws between alert and status. It also buys an exit animation, which useEntered cannot give.

The expiry stays in report.ts and Radix's own duration is Infinity: two owners of one timer is one too many.

Two things the browser pass changed, and neither would have surfaced any other way:

  • The action was wired nowhere. That would have shipped a path never executed — the thing this repository refuses. 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.
  • Radix's label prop reaches no attribute in 1.2.23. Its types accept it and the documentation calls it the accessible name; the rendered <ol> carried tabindex and class and nothing else. aria-label is set directly instead, checked in the browser rather than assumed.

What was run

  • cargo test --release — 425 passed, 0 failed
  • cargo clippy --workspace --all-targets --release -- -D warnings — clean
  • wasm-pack test --node crates/crypto-wasm — not run: no crypto and no WASM changed on this branch
  • cargo test -p server --release with docker compose up -d — including the six new tests/webpush.rs
  • pnpm run typecheck, pnpm test (721), pnpm run lint in apps/web
  • Verified by hand, and this is the half that separates the feature from a hope:
    • Chrome subscribed through fcm.googleapis.com against the advertised key;
    • the server signed and pushed, and Google accepted it — no refusal logged, the subscription survived;
    • the service worker showed {title: "Whispee", body: "New message", tag: "whispee-wake"} with the tab closed;
    • the settings switch subscribed and registered the address server-side (checked in push_tokens), and unsubscribed both halves on the way back;
    • the settings dialog opened over a conversation, Escape closed it and landed back in that conversation, and a direct link to a section opened it with the focus inside;
    • WEBPUSH_ENDPOINT=… cargo test -p server --release --test webpush -- --ignored against a live subscription: Google answered 201 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;
    • the feedback channel, on all six points of its plan: a confirmation expires by itself, an error persists and is cleared by the next successful poll, one <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 is aria-live="assertive" for an error against polite for 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

  • No SQL migration was added or edited.
  • A migration was added or edited.

0020_vapid.sql is 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 constraints log_key carries.

Checklist

  • Targets dev, not main.
  • Conventional Commits, one logical change per commit.
  • Comments argue the why, in the pattern decision → why → what it does not solve.
  • Documentation updated: 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

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

"SELECT p.provider, p.token
FROM push_tokens p
JOIN group_members m ON m.device_id = p.device_id
WHERE m.group_id = $1 AND ($2::text IS NULL OR p.device_id <> $2)",

P2 Badge Preserve mute and block suppression for closed-tab pushes

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".

Comment thread crates/server/src/push.rs
Comment on lines +220 to +222
let request = self
.http
.post(endpoint)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread apps/web/src/lib/push.ts
Comment on lines +192 to +196
const registration = await navigator.serviceWorker.getRegistration("/");
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return;

await api.setPushToken(PROVIDER, subscription.endpoint);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread apps/web/public/sw.js
Comment on lines +47 to +48
event.waitUntil(
self.registration.showNotification(TITLE, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.
@Sycatle
Sycatle merged commit 974dd9b into dev Aug 25, 2026
5 checks passed
@Sycatle
Sycatle deleted the feat/web-push branch August 25, 2026 14:57
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.

1 participant