Push notifications for unattended alarms - #332
Merged
Merged
Conversation
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
approved these changes
Jul 29, 2026
dormouse-bot
left a comment
Collaborator
There was a problem hiding this comment.
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.
Deploying mouseterm with
|
| Latest commit: |
59ea72c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://69d7ed20.mouseterm.pages.dev |
| Branch Preview URL: | https://push-notifications.mouseterm.pages.dev |
dormouse-bot
approved these changes
Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers scope alarm-push from
docs/specs/alert.md: when a Session ringsand stays unattended for
pushDelayMs, the derived Pane label goes to thepaired phones. All four staged items are built and promoted above the fold, and
that spec no longer has a
## Futuresection.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 offlinewhen 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:
is a push transport and nothing else (no
fetchhandler, no cache: Pocket isuseless without a live relay, and a cache would fight the server's
per-request
index.htmlre-read).push-subscriptions.jsonstore, VAPID key custody, delivery throughweb-push.alert-speech.ts's state machine extracted toalert-ring-watch.ts, with speech and push as two sinks over it, sofresh-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 duringconnectand so sees them in transit, anddeviceKey.ts's stated contract isthat 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
devicePublicKeyas input, sono 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.
boundedPushTextinserver-lib-commonis called by both the Host and the Server;sw.jsmirrorsit 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 becauseWebKit'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 —
SigninFinishResponsereturns the asserted passkey's public key, which theClient 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
ConnectionRequestanyway.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 washand-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 testgreen: 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, andbuild:pocket— worth running separately, sincepnpm testdoes 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
createWebPushSenderproduces, verifiedagainst an independently written RFC 8291 receiver over a TLS fake push service,
with
aes128gcm/TTL/VAPID headers asserted. Auto-generated VAPID keys wereconfirmed to be a 65-byte point and 32-byte scalar at
0o600, stable acrossrestart.
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_ORIGINset to it, a paired phone, and Pocketadded 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