Skip to content

Push notifications for unattended alarms - #332

Merged
nedtwigg merged 29 commits into
mainfrom
push-notifications
Jul 29, 2026
Merged

Push notifications for unattended alarms#332
nedtwigg merged 29 commits into
mainfrom
push-notifications

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Delivers scope alarm-push from docs/specs/alert.md: when a Session rings
and stays unattended for pushDelayMs, the derived Pane label goes to the
paired phones. All four staged items are built and promoted above the fold, and
that spec no longer has a ## Future section.

PUSH_NOTIFICATION.md (the handoff note that asked for this) is deleted.

Why this is more than a new sink

The relay routes between two live sockets and answers host <id> is offline
when a peer is missing. A push exists to reach a phone whose app is closed, so
this is a new capability rather than an extension of one — and on iOS, Web Push
is granted only to a Home Screen web app, never to a Safari tab. Verified
against WebKit's documentation
rather than assumed.

So the branch is three layers:

  1. Pocket becomes installable — manifest, icons, and a service worker that
    is a push transport and nothing else (no fetch handler, no cache: Pocket is
    useless without a live relay, and a cache would fight the server's
    per-request index.html re-read).
  2. Server-side subscriptions and delivery — five routes, a
    push-subscriptions.json store, VAPID key custody, delivery through
    web-push.
  3. The triggeralert-speech.ts's state machine extracted to
    alert-ring-watch.ts, with speech and push as two sinks over it, so
    fresh-ring detection, the delay, the fire-time re-check, and every
    cancellation rule have one implementation. The speech tests pass unchanged
    through the extraction.

Decisions worth reviewing

The Host names its push targets; the Server rejects a send that does not.
Targets are the Host's active ACL records, read at send time. Nothing
propagates a revocation today, so a revoked Client keeps its subscription row —
a Server that chose recipients itself would keep pushing Pane labels to a phone
already de-authorized. The Host also does not ask which devices are subscribed
first: the Server applies that filter anyway, so one round trip instead of two
on the path whose whole value is timeliness.

Push subscriptions get their own signature domain. PUSH_SUBSCRIBE_DOMAIN,
not DEVICE_AUTH_DOMAIN — the Server relays Host-issued challenges during
connect and so sees them in transit, and deviceKey.ts's stated contract is
that a device-auth signature can never be replayed as another statement. The
signature covers the endpoint, so a captured one cannot register a different
endpoint under the same identity. A subscription authorizes nothing.

Reads are scoped by credential, never by a supplied identity. A host token
reads its own subscribers; a session reads the account's registrations and the
Client filters to its own device. Neither takes a devicePublicKey as input, so
no endpoint reports on an identity the caller does not hold. Both return
identities only — the endpoint and its keys never leave the Server.

Sanitization has one implementation. boundedPushText in
server-lib-common is called by both the Host and the Server; sw.js mirrors
it a third time at the render sink (it is copied verbatim and can import
nothing), and a test loads the real worker in a VM and compares both against a
shared corpus, so the mirror is enforced rather than promised. It is
deliberately not toSpokenText: that strips angle brackets only because
WebKit's synthesizer wedges on them. This sink strips control, bidi, and
zero-width characters instead, and caps on code points so a cut never ships half
a surrogate.

Account-centric UX over a device-centric security model. Signing in on any
browser profile with a synced passkey is now enough to ask to pair —
SigninFinishResponse returns the asserted passkey's public key, which the
Client needs to build pair/connect requests. Previously only the registering
browser had it, which forced a redundant second passkey on every new install.
The key is public and the Host receives it in every ConnectionRequest anyway.
Reaching a machine still requires that machine's approval; the security model is
unchanged. This removed UI rather than adding it.

New dependency

web-push — the server's first non-Hono runtime dependency. The alternative was
hand-rolling RFC 8291 (ECDH → HKDF → AES-128-GCM with aes128gcm framing), where a
subtly wrong HKDF info string produces a push the phone silently fails to
decrypt with no error on our side.

Verification

pnpm test green: spec-lint OK, 1315 lib, 106 server, 111 server-lib-common,
99 dor, 97 sidecar, 56 website, 47 standalone. Plus tsc -b, storybook build,
build:vscode, and build:pocket — worth running separately, since pnpm test
does not typecheck stories and that gap hid a real break three times on this
branch.

Beyond unit tests, the encryption chain was checked end to end: a real ECDH
P-256 browser keypair decrypts what createWebPushSender produces, verified
against an independently written RFC 8291 receiver over a TLS fake push service,
with aes128gcm/TTL/VAPID headers asserted. Auto-generated VAPID keys were
confirmed to be a 65-byte point and 32-byte scalar at 0o600, stable across
restart.

Not verified

No push has been observed arriving on a real phone. Everything here is
reasoning plus automated coverage. End-to-end needs a TLS origin
(tailscale serve), DORMOUSE_ORIGIN set to it, a paired phone, and Pocket
added to the Home Screen.

Setup order matters: install to the Home Screen first, then sign in, approve
the pairing on the machine, and enable alerts from inside the installed app —
iOS partitions its storage from the Safari tab, so the install is a separate
Client and needs its own approval.

Follow-up

#331 — there is no account-wide device list; pairing, revocation, and push
targets are all per-Host. Filed rather than built because it needs Server-held
device state, which is a deliberate change to the trust model's storage story.

🤖 Generated with Claude Code

nedtwigg and others added 24 commits July 28, 2026 21:25
Web Push cannot ride the existing relay: `RelayHub` holds `#hosts` and
`#clients` as live socket maps and answers "host <id> is offline" when a peer
is missing, so reaching a backgrounded phone is a new capability rather than an
extension. On iOS it is also gated on installation — Web Push is granted only
to a Home Screen web app, never to a Safari tab — so making Pocket installable
is step zero for alarm push, not a nicety.

Adds the manifest, icons, and a service worker. They live in `lib/pocket/public/`
so Vite copies them verbatim: a worker must be served from the scope it
controls, under a stable path, with no content hash in its name, and
`emptyOutDir` rules out dropping them into the build output by hand.

The worker is a push transport and nothing else — no `fetch` handler, no cache.
Pocket is useless without a live relay connection, so an offline cache would buy
no working screens while fighting `registerPocketServing`, which re-reads
index.html per request precisely because a rebuild swaps in new content-hashed
assets. Malformed and payload-less pushes fall back to generic text rather than
returning early, because `userVisibleOnly` promises the browser that every
delivery becomes visible and a browser that catches us showing none substitutes
its own notice against the subscription's budget.

Registration is best-effort and never awaited; every screen works without it,
and failure is ordinary on an insecure origin.

No server change was needed — `serveStatic` already answers
application/manifest+json and text/javascript, verified against a running
server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the five push routes, the subscription store, and the Pocket side that
registers one. Delivery is plain HTTP rather than a new relay frame: the relay
routes between two live sockets, and a push exists precisely to reach a phone
whose app is closed, so it is a separate capability rather than an extension of
one.

Subscriptions are keyed on the pair (hostId, devicePublicKey). That falls out of
the security model — the Host addresses a push by the same Client identity its
ACL records — and it means a Host can only ever read or reach its own
subscribers. The send route takes hostId from the token and never from the body,
so naming a device explicitly cannot escape the calling Host's scope.

The Client proves the binding by signing (hostId, challenge, devicePublicKey,
endpoint) with its device key. Deliberately under a new PUSH_SUBSCRIBE_DOMAIN
rather than DEVICE_AUTH_DOMAIN: the Server relays Host-issued challenges during
connect and so sees them in transit, and deviceKey.ts's whole stated contract is
that a device-auth signature can never be replayed as another kind of statement.
Binding the endpoint is what stops a captured signature registering a different
endpoint under the same identity. A subscription authorizes nothing — it is a
delivery address, and the ACL remains the only thing deciding access.

Subscribe requires an https endpoint. The server POSTs to whatever a subscriber
names, so an unconstrained value is an SSRF primitive.

push-subscriptions.json is the first store that deletes: a push service reports
a dead subscription with 404/410, and an endpoint rotation must replace the row
rather than leave one per rotation. vapid.json holds a private key, so both rely
on the inherited 0o600 handling.

web-push is the server's first non-Hono runtime dependency. Hand-rolling RFC
8291 (ECDH -> HKDF -> AES-128-GCM with aes128gcm framing) was the alternative; a
subtly wrong HKDF info string yields a push the phone silently fails to decrypt,
with no error visible on our side.

Delivery is behind an injectable PushSender seam, mirroring the existing
injectable clock, so all 22 new tests cover the send path without a real push
service. Verified separately against a live boot: auto-generated VAPID keys are
a 65-byte P-256 point and 32-byte scalar, written 0o600, and stable across
restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the alarm-push scope. When a Session rings and stays unattended for
pushDelayMs, the derived Pane label goes to every paired phone that has enabled
alerts.

The trigger is not a second copy of the speech machine. alert-speech.ts's state
machine moves to alert-ring-watch.ts as watchUnattendedRings, with speech and
push as two sinks over it — so fresh-ring detection, the delay, the fire-time
re-check, and every cancellation rule have one implementation. The speech tests
pass unchanged through the extraction, which is the evidence it is
behavior-preserving; the machine's own cases move to alert-ring-watch.test.ts so
nothing is covered twice.

toPushText is written fresh rather than reusing toSpokenText. The bracket rule
there exists only because WebKit's synthesizer wedges on them, which has nothing
to do with a notification and would mangle a fine title like <idle>. What
matters at this sink is different: the string crosses a network and is rendered
by the OS, so it strips the Unicode bidi and zero-width characters that could
reorder or hide text on a lock screen.

The Host names its push targets rather than letting the Server fan out. Targets
are the intersection of the Server's subscriptions and the Host's *active* ACL
records, re-read at send time. Nothing propagates a revocation today, so a
revoked Client keeps its subscription row — letting the Server choose recipients
would keep pushing Pane labels to a phone already de-authorized. This keeps the
access decision on the Host, where the security model puts it.

Two untested-by-anyone cases from the handoff notes now have coverage: several
panes ringing at once each fire once, and a restored session blob arriving
already latched fires nothing — the difference between "works" and "buzzes your
phone every time you open your laptop".

The dialog's device line names every reachable device, and otherwise says why
there is none (no Host, nothing subscribed, server unreachable). A push that
silently goes nowhere should not look the same as a broken one.

Verified beyond the unit tests: a real ECDH P-256 browser keypair decrypts what
createWebPushSender produces, checked against an independently written RFC 8291
receiver over a TLS fake push service, with aes128gcm/TTL/VAPID headers
asserted. That is the failure mode unit tests cannot see, since a wrong HKDF
info string fails silently on the phone. Also confirmed the push sink lands in
the lazy RemotePairingModalHost chunk, so the website and vscode webviews never
fetch it.

Promotes all four alarm-push items above the fold in alert.md and removes its
Future section, which is now empty. Deletes PUSH_NOTIFICATION.md, as it asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On iOS, localStorage and IndexedDB are not shared between Safari and a Home
Screen web app. That makes the installed app a different Client with its own
device key, and — because PocketStorage caches a passkey's public key at
registration and the wire never returns it on sign-in — signing in there with a
synced passkey still cannot pair, surfacing as PASSKEY_UNAVAILABLE_MESSAGE.

It is the existing POC limitation ("pairing can only happen on the device that
created the passkey") meeting installability, where the installed app counts as
a different device. The fix is setup order, so the spec now states it: install
first, then set up, pair, and enable alerts from within the installed app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Push had three ways to fail with no visible cause. Each now says what happened
and what to do about it.

The service worker registration promise is tracked, and both the availability
check and the subscribe path await it. Previously subscribe used
navigator.serviceWorker.ready, which never settles when registration failed —
so on an insecure origin the "Enable alerts" button would spin forever with no
error. It now fails with an explanation, and does so *before* prompting for
permission, so the user is not asked a question that cannot help.

Sign-in is checked for local passkey material up front rather than on a failed
Pair tap. Signing in with a synced passkey succeeds on a profile that never
created one — the iOS Home Screen install's partitioned storage — and only the
later pair or connect fails, with a message ("pair from the device that first
created the passkey") that is actively misleading when it IS the same device.
The notice now explains the partition and offers first-time setup inline.

The install prompt is surfaced as a banner rather than only as a line under a
paired host, since an unpaired user on iOS would never have seen it.

Install detection keys on the *presence* of navigator.standalone, which is
iOS/iPadOS Safari only and undefined elsewhere — including macOS Safari, where
Web Push works in a plain tab and an install prompt would be wrong. Verified
against MDN rather than assumed, since the alternative (user-agent parsing) is
unreliable by design on iPadOS.

Documents what cannot be detected, because it shapes the copy: a tab cannot see
whether the app is also installed (separate storage, no shared signal), so the
install notice necessarily shows to someone who installed it and opened the
wrong window — hence the "Already added it?" line. And iOS has no
beforeinstallprompt, so installing can only be described, never triggered.

18 new tests over the availability state machine. Both notices rendered in
Storybook and checked in a dark and a light theme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quality pass over the push branch. No intended behavior change except where
noted.

alert-push.test.ts was committed as BINARY — two literal NUL bytes from writing
control characters directly into the toPushText cases. Git showed "Bin 0 ->
7704 bytes" and no diff, so a 230-line test file was invisible to every review.
It now uses escape sequences.

Sanitization had drifted into a strong copy and a weak one: the Host stripped
control and bidi characters, the server only collapsed whitespace, and the
comment claimed they were the same rule. Anything holding a host token could
push a right-to-left override to a lock screen. Both now call one
boundedPushText in server-lib-common. sw.js keeps its own copy — it is a
verbatim-copied public/ file that can import nothing — and says so.

The send path made two round trips per alarm: GET the subscribed devices, then
POST. The server intersects the names it is given with its own subscriptions
anyway, so sending the ACL keys directly yields an identical target set for one
round trip instead of two, on the one path whose whole value is timeliness. It
is also fresher, since the ACL is read at send time. The server now *requires*
non-empty devicePublicKeys: a Server that picked recipients itself would keep
notifying a Client the Host had revoked.

hostFetch replaces two hand-rolled authed fetches that had already drifted —
only one checked response.ok, so a 401 from a revoked host token resolved
normally and left push permanently broken with nothing in the console.

Deduped: signPushSubscribe/verifyPushSubscribeSignature now delegate to generic
signDevicePayload/verifyDevicePayload (the separate PUSH_SUBSCRIBE_DOMAIN is
untouched — it is the load-bearing part); requireSession/requireHost share
bearerToken; the setup-password form is one PasskeySetupFields used by both the
auth screen and the local-passkey notice; the switch-gates-a-delay layout is one
AlarmSinkSection used by speech and push.

Derived what was duplicated state: needsHomeScreenInstall was a second source
for pushState === 'needs-install' and the two provably disagreed in a story;
needsLocalPasskey was React state mirroring a synchronous getter.

The VAPID key is prefetched with availability, so the Enable tap reaches
requestPermission with no network await in front of it — iOS drops transient
activation across one, and the comment claiming this already held was false.

The settings dialog now re-reads the device list when it opens. It was fetched
once at Host start, so a phone that enabled alerts afterwards was pushed to but
never named.

Also: batched removeEndpoints replaces a per-endpoint read-modify-write loop;
dropped a hostId the challenge route never read and a dead
unsubscribeFromPushInBrowser; corrected a VapidStore comment claiming
cross-process mutex protection it cannot give.

Caught by the existing suite mid-refactor: hoisting payload construction out of
the verify try/catch broke the "returns false, never throws" contract on
malformed base64url. Restored, and now covered on the push route too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stories

The service worker cannot import boundedPushText, so the shared sanitization
rule exists twice and the spec claims one mirrors the other. That claim is now
enforced: the worker is loaded into a VM context, its own `text` and
TEXT_LIMIT are pulled out of that realm, and both are compared against
boundedPushText over a corpus of controls, bidi marks, zero-width characters,
whitespace, the cap boundary, and non-string inputs. Verified the test fails
when either side drifts.

HostsView.stories.tsx did not compile after per-host push tracking landed:
`pushState: 'subscribed'` is no longer a PushAvailability member, and the
now-required `isPushSubscribed` was missing from meta.args, so every story
would have thrown at render. `pnpm test` does not typecheck stories and
vitest never renders them, so nothing caught it. PushSubscribed now drives the
per-Host marker, and a PushSubscribedOneHost story covers the mixed case the
old scope-wide check got wrong.

App.test.tsx moves to the house component-test harness — jsdom, createRoot,
act — matching Wall/MobileWall/SurfacePaneHeader. It now queries each Host's
push row through that Host's label rather than asserting on substring offsets
in serialized markup, so it cannot pass by accident when rows reorder, and it
covers the click target, the denied state, and the unconfigured-server state.
Verified all of it fails when per-Host tracking is reverted.

Also documents what a null applicationServerKey means in sameBytes: it rotates,
which is the safe direction but invalidates other Hosts' endpoints, so it is
only correct because every Push-capable browser populates that property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In an iOS Safari tab, Notification and PushManager are themselves absent —
they appear only in an installed web app — so probing capabilities first
answered 'unsupported' and the UI showed the dead-end "This browser cannot
receive push notifications" instead of the Add to Home Screen notice. The
install check now runs before any probe; the tests cover the real iOS tab
shape (service worker present, Notification absent) that only the analogous
PushManager case covered before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pushKey conflated its three states: a tap on Enable alerts while the config
fetch was still in flight threw "This server has push notifications
disabled", and a transient fetch failure latched null — the same false
accusation, held for the rest of the session with no retry path. Only a real
server answer may set null now; a failure leaves the tri-state at unknown,
and the Enable tap re-asks on demand — paying the one round trip (which iOS
transient activation normally forbids fronting the permission prompt) beats
refusing a tap the server would have honored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
InstallNotice rendered on pushState alone, so an iOS-tab user of a push-less
server read "Alerts only reach you from the installed app" directly above
host rows saying "This server has push notifications disabled" — install
advice whose ritual ends at that same dead end. The notice is now gated on
pushConfigured like the rest of the push UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps in the shared push-text rule, applied to boundedPushText and its
sw.js mirror in lockstep. The bidi strip class omitted exactly one
Bidi_Control character — U+061C ARABIC LETTER MARK — contradicting the
stated "strips the Unicode bidi format characters" intent. And every cap was
a UTF-16 .slice, so a title whose limit landed mid-surrogate shipped a lone
half that renders as U+FFFD on the phone; the caps now count code points,
the same idiom terminal-protocol.ts already uses. The server's tag cap and
toSpokenText's cap get the same treatment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readPayload's non-JSON fallback called event.data.text() unguarded, so a
payload failing both reads threw out of the push handler before
showNotification — incurring the exact userVisibleOnly penalty the worker
exists to avoid. The raw-text read is now guarded and falls back to the
generic text like every other unreadable shape.

The mirror test gets the teeth it was missing: TEXT_LIMIT is pinned to a
literal (the corpus borrowed the subject's own constant as the oracle's
limit, so no drift could ever fail), the spec-mandated malformed and
payload-less fallbacks are covered including the both-reads-throw case, and
server-lib-common is aliased to source so watch-mode runs stop comparing
against whatever dist was last built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refreshPushDevices wrote its resolved result unconditionally, so a fetch
still in flight when stopRemoteHost ran (or when enroll stopped and replaced
the Host) overwrote the reset's no-host state with a stale ready list — and
since the reset had already nulled the dialog's refresher, the lie stuck for
the rest of the session; on the enroll path it could even name the previous
enrollment's devices. Writes are now fenced on a store generation that
resetPushDevices bumps. Today every trigger is a devtools-console action, so
this is robustness for the store's contract rather than a user-reachable bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delivery the push service refused left no trace anywhere: the sender
swallowed the error with no logging, the send route folded the outcome away
and answered 200, and the Host discarded the response body — so an
all-failed fan-out (a rotated VAPID key, a wedged push service) was
wire-indistinguishable from success, the worst failure mode for a feature
whose whole job is being heard. The sender now logs the refusal (endpoint
origin only — the full endpoint is a bearer capability), PushSendResponse
carries a failed count, and the Host reads the counts and warns when
failed > 0 or delivered is 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four gaps a spec-sync pass caught: pocket-app.md introduced Session/Pane
vocabulary without the required glossary callout and its exhaustively-listed
client/ tree omitted the new push-subscribe.ts; alert.md's Files table had
no row for activation.ts, which this branch made the push-arming point; and
its Text And Security section cross-referenced toPushText "under Push
notifications" where the section only ever named boundedPushText — the push
bullet now names both. (The toPushText wording landed with the sanitizer
commit; this adds the structural entries.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-Host "Alerts on." marker was session-only, so a reload re-offered
Enable alerts for every Host — including ones the Server already held a row
for — and a row pruned after a 410 kept claiming alerts were on until the tab
was closed. Pocket now reads the state back instead of remembering it.

GET /api/push/subscriptions is session-gated and returns the *account's*
registrations as identities; PocketClient filters them to its own device key.
Deliberately not parameterized by devicePublicKey: an endpoint answering "which
Hosts is device X registered with" would be an enumeration primitive over an
input the caller need not own, whereas the account's own rows are already its
to read — the same scoping GET /api/hosts uses, and correct per-tenant if the
staged SaaS mode lands. That generalizes to an invariant now stated in
server.md: push reads are scoped by credential, never by a supplied identity,
which also covers the host-token /api/push/devices. Neither returns the
endpoint or its keys, which are a bearer capability to notify the phone.

No 503 when push is unconfigured — rows outlive a key being removed, so the
truthful answer is the list rather than an error.

The hosts-view read replaces the set rather than merging, since self-correction
after a pruned row is the point; a pushEnablesRef counter keeps a registration
that completed while the read was in flight, because that is the newer fact. A
failed read leaves the set empty, re-offering an idempotent action — the
harmless direction to be wrong in.

Verification: pnpm test green (server 101 -> 105, lib 1312 -> 1314, spec-lint
OK). The Bash sandbox's classifier was down for this session, so `tsc -b` and
`storybook build` could NOT be run — server typechecking is covered because its
test script builds first, but lib typechecking is unverified. No component
props changed in this commit, so the story surface is untouched. Run both
before pushing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signing in on a new browser profile now lets it ask to pair. Reaching a machine
still requires that machine's approval. The security model is unchanged; what
changes is that the UX no longer leaks an artifact of the wire as if it were a
trust boundary.

SigninFinishResponse returns the asserted passkey's public key. A Client needs
it to build pair and connect requests — as a hash for pairing, in full for a
connection — and previously it could only obtain it by having performed the
registration itself, because sign-in did not return it. That forced a redundant
second passkey on every new browser profile, most visibly an iOS Home Screen
install, whose storage is partitioned from the Safari tab that set the account
up. Handing the key back costs nothing: it is public, the Host is given it in
every ConnectionRequest regardless, and holding it authorizes nothing — a
connection still requires a fresh assertion, a device-key signature, and both
halves on one active ACL record.

That deletes rather than adds UI: LocalPasskeyNotice, the needsLocalPasskey
prop, hasPasskeyMaterial, and HostsView's now-unused onSetup are all gone,
along with the "run first-time setup again inside the installed app" step.

Copy pass on the parts where account and device meet:

- The pairing modal says what approving means in account terms — this device
  signed in to your account and is asking to reach this machine — and states
  that approving adds it to this machine only. Signing in is not enough to
  reach a machine, and the prompt must not read as a formality.
- Pocket names its mode in the label it suggests: "Dormouse Pocket (Home
  Screen)" vs "(browser)". One phone can hold two Client identities with
  separate device keys, and they are genuinely separate delivery targets that
  cannot be merged — so the approver, and the alarm dialog afterwards, need to
  tell two entries for one phone apart.
- The alarm dialog's empty state says "No device paired with this machine",
  because the ACL lives on the Host and there is no account-wide device list to
  imply.

Specs updated in all three places the old limitation was documented, including
remote-security-model.md, which now states why returning a public key is not a
weakening.

Verified: pnpm test green (server 105 -> 106, lib 1314 -> 1315, spec-lint OK),
plus tsc -b, storybook build, build:vscode, and build:pocket. tsc caught the
orphaned onSetup prop that the test suite could not see — the same gate gap as
before. Pairing modal copy checked rendered in Storybook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

Careful, well-documented work — the security reasoning is laid out at every boundary, and the coverage is thorough (the sw.js sanitizer mirror enforced against the shared boundedPushText via a VM is a nice touch). Reads clean; the shared ring-machine extraction is a real simplification. Approving.

One forward-looking, non-blocking note left inline on the subscribe endpoint check.

Comment thread server/src/app.ts Outdated
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 59ea72c
Status: ✅  Deploy successful!
Preview URL: https://69d7ed20.mouseterm.pages.dev
Branch Preview URL: https://push-notifications.mouseterm.pages.dev

View logs

@nedtwigg
nedtwigg merged commit d7c91d5 into main Jul 29, 2026
9 checks passed
@nedtwigg
nedtwigg deleted the push-notifications branch July 29, 2026 22: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