Skip to content

E2E remote control, stages 4–5: Noise pairing/connection cutover, delivery-keyed push, Host bounds - #517

Merged
nedtwigg merged 38 commits into
mainfrom
e2e-client-host-2
Sep 2, 2026
Merged

E2E remote control, stages 4–5: Noise pairing/connection cutover, delivery-keyed push, Host bounds#517
nedtwigg merged 38 commits into
mainfrom
e2e-client-host-2

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 2, 2026

Copy link
Copy Markdown
Member

Second of three stacked PRs for the e2e-client-host scope (docs/specs/remote-security-model.md). Stacked on #515 — review the diff against e2e-client-host, not main. This PR is the atomic cutover: after it, the Host, the Server relay, and Pocket speak only the Noise e2e protocol. Existing Hosts must re-enroll and every phone must pair again from a fresh Host QR (pre-launch; no migration reader, no fallback).

What lands

  • Stage 4a — shared protocol, Host, Server. ACL v2 (clientStaticPublicKey + deliveryId; legacy records drop on read). The positional QR grammar and one parsePairingInvitationUrl boundary. PresenceProofV1 verified by one shared verifier on the Host against a handshake-hash-bound reauth binding (/api/reauth/begin takes a required PresenceBinding; finish extends nothing). The Host runs both ceremonies itself: invitations with one-use responder keys, pairing IK → proof → reverse two-digit confirmation (constant-time, one attempt, the code never crosses to the webview) → one ACL record; connection IK → challenge → proof ∧ four-field conjunction → promotion with protocol-v1 inside. Mint-on-start backfill and a halves-correspond gate for the Host static. POST /api/setup/retire; push re-keyed on possession-only deliveryIds (query, idempotent delete, endpoint-rotation pruning). adopt, setup proofs, fingerprints, and setup-token-redeemed deleted.
  • Stage 4b — Pocket. PocketClient rewritten on the e2e envelope; native-camera bootstrap (fragment erased, nothing retained, token unspent); in-app scanner (@zxing/browser, lazy chunk) plus paste, both through the shared parser (loopback HTTP exempted for the dev loop); scan-first auth screen; two-digit waiting screen; record-driven Hosts view; probeNoiseSupport gate; IndexedDB v3 drops the device key; durable delivery-id tombstones with retry. Tests drive the real RemoteHost against the real PocketClient through an in-memory relay stub with no ceremony step stubbed.
  • Stage 4c — deletion. Server handshake.ts, all nine legacy relay arms, the transitional reauth arms, the setup-password arm of /api/setup/* (the password now only enrolls Hosts), Host labels on the Server; shared deviceKey.ts/setup-proof.ts and thirteen exports; hostId shape pinned. Plus carried fixes: Noise handshake re-entrancy guard, client-gone and teardown serialized against in-flight handshakes (epoch guard), invitation eviction state, push-row pruning.
  • Stage 5 — Host bounds. MAX_ESTABLISHED_E2E_SESSIONS = 16 with same-static replacement and host-busy; a Host-global crypto token bucket (8 burst, 1/s); Pocket keepalives every 30 s while visible and a 120 s idle reaper refreshed only by decrypted Client→Host traffic; one next-expiry reaper over every pending and established entry; StreamReassembler compaction bound; instrumented flood tests proving rejected frames perform zero crypto and allocate nothing; a malicious-relay harness whose guard-less variant shows the Host bounds itself.

Each sub-stage had a /simplify pass and a high-effort /code-review pass committed on top; specs, SECURITY.md, SELF_HOST.md, and docs/stories/pairing.mdx were rewritten as each claim changed.

Verification

Root pnpm test and pnpm build pass on the head commit (lib 2005, server-lib-common 223, server 221, vscode-ext 113, standalone 62, website 65, all six lints). Pocket builds with the zxing decoder in its own chunk.

Open items (device-only)

Two facts cannot be verified in Node or jsdom and need a real iPhone before this ships: an X25519 CryptoKey survives IndexedDB structured clone on iOS Safari, and getUserMedia works inside a Home Screen web app (paste remains as the fallback). Remaining carried findings from the 4c review that were judged product calls rather than bugs are listed in the stage-5 commit message.

Next

PR 3 (stacked) lands sealed push (dormouse/push/v1), the built classic service worker, and the documentation/lint enforcement.

🤖 Generated with Claude Code

https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj

nedtwigg and others added 30 commits September 1, 2026 16:42
The protocol both ends of the end-to-end ceremonies speak, with nothing
consuming it yet on this commit:

- **ACL v2.** `HostAclRecord` replaces `devicePublicKey` with
  `clientStaticPublicKey` (raw X25519) and gains `deliveryId`, both checked for
  exact length on read — which is the whole of the Host-ACL version, since a
  record written before the cutover fails it and is dropped. `HostAcl` keys on
  (passkey credential, Client static); the miss is `client-not-paired`.
- **The invitation grammar** — `PairingInvitation`, the 146-character positional
  fragment, its formatter, and one parser boundary that answers the complete
  invitation or `null`.
- **The ceremony messages** — `PresenceProofV1` and the one `verifyPresenceProof`
  both ceremonies run, `PairingRequestV1` / `PairingOutcomeV1`,
  `ConnectionRequestV1` / `ConnectionOutcomeV1`, and `samplePairingCode`.
- **Wire.** Push re-keyed on `deliveryId`, the reauth binding variants,
  `/api/setup/retire`, and the deletion of `mintId` and the
  `setup-token-redeemed` frame.

`PairingCeremony` and `authorizeConnection` are deleted; the legacy types and
guards the Server and Pocket still import stay until stage 4c.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`RemoteHost` now speaks exactly two frames — the `e2e` envelope and
`client-gone` — and every legacy frame is ignored:

- **Invitations are Host memory.** `mintInvitation` generates a one-use X25519
  responder keypair that never leaves the process; `setupQr` composes the
  `#pair?` URL around it, and the panel flips on the Host's own `invitation`
  event rather than on a Server redemption.
- **Pairing.** IK against the invitation key, then a `PairingRequestV1` whose
  presence proof is verified against a binding built from the Host's own
  `hostId` and handshake hash. The service mirrors `{ clientId, pairingId,
  label, requestedAt }` and never the expected code; the webview echoes typed
  digits, the Host compares them constant-time exactly once, and every terminal
  outcome consumes the invitation.
- **Connection.** IK against the Host static, message 2 carrying a fresh
  challenge, then proof ∧ the one-record ACL conjunction. Every ACL miss answers
  `pairing-required`; protocol-v1 rides the same session.
- **Enrollment gate.** A Host without a usable Noise static does not enroll and
  does not start: absent is backfilled and persisted at start, and halves that
  do not correspond keep it down rather than presenting a changed identity.
- Deletes `adopt`, the webview `localStorage` Host, and `ACL_KEY_PREFIX`.

`lib/src/remote/test-e2e-client.ts` is the shared Client driver both Host suites
run against, so no ceremony step is stubbed in either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…stage 4a)

- **`/api/reauth/*` take the binding.** `begin` requires a kind-tagged
  `PresenceBinding` naming a registered credential, mints a single-use 2-minute
  nonce, and answers `presenceChallenge(binding, nonce)`; `finish` consumes it,
  verifies against the stored key for exactly that credential, and extends
  nothing. The bodyless legacy arm stays marked `STAGE-4 TRANSITIONAL`.
- **`POST /api/setup/retire`** spends a live token without registering, so a
  photographed code cannot register a passkey after the phone moves on.
- **Push is keyed on `deliveryId`.** Possession is the proof: no challenge, no
  signature, and the Server never lists an id to a session. Adds the
  possession-only query and the always-204 delete; drops `/api/push/challenge`
  and `GET /api/push/subscriptions`. Rows from before the cutover are dropped on
  read with one warning naming the file.
- `mintId` and `RelayHub.notifyHost` are gone with the `setup-token-redeemed`
  frame.

The harness now mirrors the real Host: `FakeHost` runs the same invitations,
presence verification, and ACL conjunction, and `FakeClient` drives both
ceremonies through the real relay with proofs obtained from the real reauth
routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`remote-security-model.md` is rewritten around what now ships: one end-to-end
channel per ceremony, presence proofs, the invitation-based pairing with its
two-digit confirmation, the connection conjunction, the Host bounds stage 4
carries, and Host identity. The Device Keys, Pairing Ceremony, and Connection
Establishment sections described code that no longer exists and are gone, along
with the end-to-end-confidentiality non-goal, the setup proof, the fingerprint
compare, and the WebAuthn PRF item. A new `remote-security-model.rationale.md`
holds the evidence the spec no longer carries inline.

`server.md` promotes the QR grammar and its parser, the reauth and retire
routes, the delivery-keyed push surface, the new relay diagrams, and the Host
side; what is left under the fold is the 4c deletions and stage 6's sealed push.

`SECURITY.md`'s `FAIL IF` lines are rewritten in the same commit rather than
deferred: none may name `authorizeConnection`, `PairingCeremony.approve`,
`adopt`, or the setup nonce, and the Server-compromise bullet no longer claims
read and write on an established session — it cannot read one at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over the stage-4a range; no wire bytes, QR grammar,
presence encoding, ACL conjunction, one-attempt rule, or bound changes.

Reuse: `isExactBase64Url` and `randomBase64Url` land in `server-lib-common`
and replace four hand-written copies of each; the QR fragment is now composed
through `pairingInvitationFields`, so the emitter and the prologue cannot
disagree about order; the two denial-code lists are the source of their own
union types.

Simplification: `RemoteHost` grows `#sendE2e`, `#evictOldestIfFull`, and
`#aclMiss`, collapsing four frame literals, two identical eviction scans, and
a four-level nested ternary (with an unreachable log arm); `#disposeClient`
now composes the three teardown paths instead of re-implementing them;
`#promoteConnection`'s send closure retains only the established id and
session; `flushUntil` is written over `pollFor`.

Dead code from the legacy Host paths: `verifyPushSubscribeSignature` (its only
caller went with the challenge/signature subscribe gate), `MAX_PENDING_TICKETS`
(its ceremony is gone), and `boundedPairingAccount` (the modal no longer
renders an account), plus a dangling `sameRequest` citation and the stale
"nothing calls this yet" on `presence.ts`.

Comment proportionality: the rewritten specs are normative, so the code stops
restating them — eight blocks in `remote-host.ts`, `verifyPresenceProof`'s and
`isHostAclRecord`'s doc, and two of the three copies of the delivery-id rule.
Two `Reserved:` pointers that stage 4a promoted are rewritten from "will" to
"is", and `server.md`'s parser paragraph keeps only the cross-boundary rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
**A dropped invitation is not a scan.** Losing the relay socket retired every
held invitation as `consumed`, which the QR panel renders as "Scanned. Finish
on the phone" — so a wifi roam sent the user to a phone that never asked. The
Host now reports `dropped` for a code it discarded un-scanned (socket loss, or
eviction at the mint cap) and `consumed` only where a phone really completed
message 1, and the panel offers a new code instead.

**A rejected connection `init` no longer allocates a challenge.** It was issued
before the responder ran, so a frame that never authenticated left an entry
nothing but its 2-minute TTL reclaims. Issued after message 1 decrypts, on the
Host and in the harness that mirrors it.

**Enrollment mints the Noise static before the exchange.** The mint became
fatal in this stage but still ran after the POST, so a runtime without X25519
orphaned a `hosts.json` row and spent the installer's one-time token on every
attempt, with no way back.

**Pairing refuses when this Host has no static to present** rather than writing
a record and handing the Client `hostStaticPublicKey: ''` — an authorized
Client whose next connection could never complete IK.

**Every delivery-id route bounds its ids** the way subscribe already did:
`query`, `send`, and the `DELETE` path parameter. The delete stays 204 for
anything, so it is still no oracle. Also states the single-account assumption
`removeDelivery` rests on.

Plus: `client-gone`'s clientId is bounded as its comment claimed; a promotion
with no session factory clears the disposed handler and prunes the entry;
`SECURITY.md` keeps the pre-authorization relay clause its still-live legacy
`msg` gate needs; and `PushSubscribeResponse`, `remote-security-model.md` and
`server.md` say what the code does about endpoint rotation, the mint order, and
the transitional `/api/reauth/*` arms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The documented dev loop serves Pocket on `http://localhost:3000`, so a QR
minted there could not parse at all. The parser now accepts plain HTTP on
`localhost`, `127.0.0.1`, and `[::1]` — exactly the set a browser already
treats as a secure context, and the same exemption WebAuthn and service
workers get. The origin compare is unchanged, so this widens nothing a
remote code could reach.

Also adds `isE2eServerToClientFrame`, the Client's mirror of the guard the
Host already runs on what the relay hands it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PocketClient` now speaks only the `e2e` envelope: Noise IK for both
ceremonies, presence proofs through the real `/api/reauth/*` bindings, and
protocol-v1 as application messages on the connection's own session. The
device key, the setup password, `pair-status`, the `:paired:` markers, and
the legacy push shim are gone.

- `pair(invitation, label, onCode)` mints a per-Host static, runs IK against
  the invitation key, samples the two digits and shows them before the
  WebAuthn prompt, then verifies the outcome's passkey fields against its own
  and the Host static against any existing pin before writing `KnownHostV1`.
  A changed pin is a terminal `HostIdentityMismatchError` that keeps the old
  record.
- `connect(hostId)` runs IK against the pin, binds its proof to the Host
  challenge from message 2, and on `pairing-required` tombstones the delivery
  id *before* the record forgets it.
- Push is by capability: subscribe and query present this browser's own
  delivery ids, and `retirePendingDeletions` drains the tombstones.
- Every denial maps to fixed copy; a timer expiring reports unavailability.
- IndexedDB goes to v3, deleting the `device-key` store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The phone's first user-visible change. A `#pair?` the native camera opened is
erased before first render and nothing is kept from it — not even a parse —
so the auth screen leads with "Install or open Pocket, then scan this Host QR
in Pocket" and every real pairing starts at the in-app scanner.

- `ScanInvitation` reads a code as data through a lazily imported
  `@zxing/browser` rear-camera scan, with a paste field beside it feeding the
  same parser; both stop the tracks on success, cancel, error, and unmount.
- The auth screen loses the setup password: a first run leads with the
  scanner, a browser holding a passkey leads with sign-in.
- The two-digit waiting screen shows the code while the outcome is pending.
- The Hosts view is the pinned records — labeled locally, online state
  stamped on from `GET /api/hosts`, one action each (Connect, or Pair again),
  plus Remove and Scan a Host QR.
- `probeNoiseSupport` gates the whole app: a runtime without X25519 gets a
  fixed upgrade requirement and no remote operation.

`PK`/`pkButton`/`ErrorRow` move to `pocket-chrome.tsx` so the scanner draws
from the same vocabulary without importing the app shell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ge 4b)

`pocket-client.test.ts` now runs the **real** `RemoteHost` and the **real**
`PocketClient` through a new in-memory relay (`lib/src/remote/test-relay.ts`,
which stamps `clientId` and `hostId` the way `server/src/relay.ts` does). No
ceremony step is stubbed: real Noise IK, real ES256 assertions over the shared
challenge builder, outcomes decrypted on the session that produced them. It
covers pairing, connection, protocol-v1 inside the session, `pairing-required`
with its tombstone, a mistyped code, a Host-static pin mismatch, tampering, and
both deadlines.

App coverage splits: `App.scan.test.tsx` drives the capability gate, the parse,
setup-versus-retire, the code screen, and the connect a pairing continues into;
`ScanInvitation.test.tsx` covers the reader and every way the camera stops;
`pair-link.test.ts` covers the fragment being erased with nothing retained.
`pocket-db.test.ts` pins the v1→v3 and v2→v3 upgrades deleting `device-key`.

`FakeSocket` gains an `onSend` hook, which is the seam the relay stub bridges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`pocket-app.md` loses the setup-password auth screen, the `pair-status` sweep,
the device-fingerprint line, and the `:paired:` markers; it gains the
native-camera bootstrap, the scanner and its camera-teardown rule, the
after-the-parse flow with the two-digit screen, the record-driven Hosts view,
the capability gate, IndexedDB v3, and the durable deletion queue — which lands
here rather than in stage 6 because 4b needed it, so `pair` now also tombstones
a delivery id a re-pair replaces.

`remote-security-model.md`: stage 4b is done, stage 6 no longer owns the
deletion queue, the runtime gate moves above the fold into Host identity, the
session-is-authentication-plane-only rule moves into Presence proofs, and the
first run's three authenticator prompts are stated once. What is left under
Future is the pair of properties only a real iOS device can settle.

`server.md`: the legacy relay frames now have no speaker at all, `#setup?` has
no reader, and "Running it" walks the scan. `docs/stories/pairing.mdx` follows
the phone through its new screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
It is the Host's own label as it arrived inside the encrypted pairing outcome,
not a nickname chosen on the phone — there is no rename UI, and the point of the
field is keeping the Server's copy of the name out of Pocket's rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over the Pocket cutover.

Reuse: `E2E_ID_BYTE_LENGTH` moves to `server-lib-common/src/remote/wire.ts`
beside the `isE2eId` it already derives, so a minter and the shared guard
cannot drift; the relay stub and the pocket-app suites mint routing ids
through `testRoutingId()`, and `invitationUrl(origin)` lives once in
`app-test-utils.ts`.

Simplification: `PocketClient` grows one `#sendE2e` funnel — every
Client→Host byte now leaves through it — plus `#exchangeControl` for the
send-then-await both ceremonies share; `pair()` hoists its route as
`connect()` already did; `#connectionUnavailable` is `#unavailable` plus a
flag; the caller-less `knownHost()` is gone; the two localStorage prefix
scans share `keysWithPrefix`. `App`'s `Phase` becomes a discriminated union
carrying the pairing digits and the connected Host, which retires
`activeHost`, `pairingCode`, the impossible `(wall, null)` guard and the
duplicated placeholder; `loadPushConfig` is the one copy of the config read
the effect and Retry both wanted; `PushConfigStatus` derives from
`PushConfigState`. `ScanInvitation`'s three camera teardowns become one
`release(controls, video)`.

Efficiency: the Hosts list paints before the tombstone drain rather than
behind N serial DELETEs.

Altitude: the relay stub binds each Client socket on its own `init` and
reports `client-gone` on a replaced registration, as `registerClient` /
`unregisterClient` do; its sync claim now names where the rules are pinned.
The parser's loopback set is stated as the policy list it is rather than as
a re-derivation of the platform's secure-context rule, which is wider.

Say it once: the run token, "a timer is unavailability", the install
notices' shared line, and the `KnownHostV1` shape rationale each drop to one
statement plus pointers.

No wire bytes, prologues, presence rules or bounds move, and no fallback or
plaintext path appears.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Nine confirmed findings, each pinned by a test proven load-bearing by
reverting the fix.

**Nothing may throw out of the two-digit screen.** `PairingCodeView` shows
two digits and a Cancel button and renders no error, but the whole span it is
up — `pair`, then the connect that follows an approved pairing — throws for a
Host-static mismatch, a dismissed authenticator prompt, a lost passkey cache,
and any connection denial that is not `pairing-required`. Every one of those
messages was invisible until the user tapped Cancel, `HostIdentityMismatchError`
above all. `onScanned` now leaves for the Hosts list before rethrowing, and
re-reads defensively so a failed re-read cannot strand them either.

**A Host static was pinned without checking its shape.** `PairingOutcomeV1`
bounds `hostStaticPublicKey` as a string and nothing else, so a Host
announcing anything that is not base64url of 32 bytes got stored — and every
later `connect` then threw building a handshake from it, long after the
screen that could have explained it was gone. `isNoisePublicKey` joins
`isNoiseStaticMaterial` in `server-lib-common/src/security/noise.ts`.

**The scanner latched forever on the first accepted code.** Everything
between acceptance and the pairing screen can fail — a refused setup token, a
sign-in that did not work — and each leaves the scanner up telling the user
to scan again, with the camera *and* the paste field inert. The latch now
opens when the handover settles, so `onScanned` is awaited.

**And it ran a camera through the whole ceremony.** `accept` stopped the
camera, then `setBusy('pair')` re-rendered `App`, re-created `onScanned`, and
restarted it behind the WebAuthn prompt — indefinitely on any failure that
kept the screen up. `onScanned` is a `useCallback` and the effect is gated on
`busy`, which also reopens the scanner for a second attempt.

**Cancelling the scanner showed an empty Hosts list.** A scan signs in but
only reads the list on a path that reaches pairing, so Cancel after a later
failure claimed nothing was paired until Refresh. It reads the list instead.

**A relay chose the sentence the user read.** An `error` frame's text is
unbounded, unshaped, and reaches the alert row through the protocol-v1
requests `#rejectAll` fails. Fixed copy now, same rule as the denial tables;
the relay's words go to the console.

**A storage failure replaced a ConnectResult with a raw error.**
`#dropAuthorization` ran outside a `try`, so an IndexedDB throw surfaced as
`QuotaExceededError` instead of *Pair again*. The tombstone-before-transition
order still holds: a failed write leaves the record paired, and the next
Connect earns the same authenticated denial and retries.

**The scanner left a camera running when the decoder threw late.** A
`startScan` that rejects *after* `getUserMedia` attached a stream hands back
no controls to stop. The rejection path releases the element's tracks.

**A ceremony's control waiter was registered after its send** — the reverse
of `#exchange` — so an answer delivered with no await in between would find
no waiter, be dropped, and hang to the deadline. Latent behind the Host's own
crypto awaits, and left untested for that reason: it is an ordering
invariant, not an observable behavior.

Also: `pocket-app.md`'s design source-of-truth now names `pocket-chrome.tsx`
for the `pkButton`/`PK` vocabulary that moved there this stage, and the
session-gate bullet names the refused setup token rather than the setup
password and device signature the phone no longer has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
The relay routes the `e2e` envelope and nothing else. `pair`, `pair-status`,
`connect`, `connect2` and `msg` — up and down — are gone, and with them the
`Handshake` gate, its relayed-challenge memory, `Session.lastVerifiedPresence`,
and the two `STAGE-4 TRANSITIONAL` arms of `/api/reauth/*`. No production
speaker had used any of it since stage 4b.

`/api/setup/begin` and `/finish` take a Host-minted setup token only: the setup
password enrolls Hosts and no longer registers a passkey. `/api/host/enroll`
keeps both credentials but takes no `label` — the name a machine presents is its
own, told to a Client inside an encrypted outcome — and `hosts.json` rows and
`GET /api/hosts` lose it with them.

Enrollment now pins the `hostId` shape at base64url of 16 bytes, validated on
read: `e2e` routes on it and the shared guard accepts no other length, so a
hand-edited row of another shape would be a Host the relay admits and no Client
can address.

The `FakeHost` harness loses its legacy half and `LegacyAcl`;
`server/scripts/fake-host.mjs` mints a Noise static and prints a pairing URL, so
it is still a usable headless stand-in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ge 4c)

Deleted outright: `security/deviceKey.ts` and `security/setup-proof.ts` whole,
`SETUP_HASH_*`, `pairingFingerprint`, `PairingRequest`/`isPairingRequest`,
`PairStatusQuery`/`isPairStatusQuery`, `PAIRING_PRESENCE_WINDOW_MS`,
`PAIRING_STALE_PRESENCE_ERROR`, `PUSH_SUBSCRIBE_DOMAIN` with
`pushSubscribePayload`/`signPushSubscribe`, and `ConnectionRequest` /
`ConnectionFailure`. `PAIRING_LABEL_LIMIT` and `isSetupTokenHandle` stop being
exported; each has exactly one caller, in its own file.

The four relay unions lose their legacy arms — `ClientFrame` and `HostFrame`
are now the `e2e` envelope alone, and the other two add only `host-gone` /
`error` and `client-gone` — which is what drops the `HostAclRecord` import from
the wire contract.

`ConnectionPolicy` survives, since it is what a Host records at enrollment and
what both verifiers demand of an assertion, but `connection.ts` would have held
nothing else. It moves to `passkey.ts` as `Omit<PasskeyAssertionExpectations,
'challenge'>` — the standing half of what that module already defines — and
replaces `PresencePolicy`, its structural twin with no external caller.

`SetupCredential` becomes `{ setupToken }`, `HostEnrollRequest` loses `label`,
and `HostsResponse` rows carry `hostId` and `online` only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`performEnrollment` stops sending `label`: the Server takes none, and the name
this machine presents stays beside the enrollment, told to a Client only inside
an encrypted ceremony outcome.

The rest is naming that outlived its subject — `SettingsDialog`'s Storybook
parameters seeded push devices by `devicePublicKey`, which the view has called
`deliveryId` since the cutover, and `QrCode`'s fixture URL still carried a
`#setup?` hash no emitter composes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`NoiseHandshake.#step` had no in-flight guard, so a second `writeMessage` or
`readMessage` entered while one was still awaiting could interleave with it. A
step is a dozen awaited WebCrypto calls that mutate the symmetric state in
order; two of them running together mix the same key twice or read `h` between
two of its writes, producing a transcript neither peer can reproduce. The
pattern-order checks could not catch it — they read `#firstMessageDone`, which
a step sets at its *end*.

A second entry now fails the handshake outright rather than queueing: a caller
reaching here has a bug, and a handshake is cheap to redo and impossible to
resynchronize.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
… 4c)

`client-gone` was dispatched inline while every `e2e` step runs on the
per-socket promise chain, so a teardown could land *between* an init's awaits:
`#disposeClient` found no pairing entry to dispose, and the resumed handshake
then reserved the invitation and allocated a client entry for a peer the relay
had already forgotten — one nothing would ever remove, holding a
`NoiseTransportSession` and a `MAX_PENDING_PAIRINGS` slot until the Host's own
socket dropped. Both transport handlers already re-check after their awaits;
the two init handlers had no equivalent. Queueing the frame is the fix, and it
covers both of them at the source.

Second, `mintInvitation`'s cap eviction announced `dropped` unconditionally.
Eviction takes the oldest by insertion whatever it is doing, so a code a phone
had already scanned was reported as one nobody asked for — the inverse of the
state table, which `#dropTransientState` encodes correctly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PushSubscriptionStore.upsert` read the address it was replacing from the row
for this exact `(hostId, deliveryId)`, so a subscribe with no row of its own
pruned nothing — leaving a sibling Host's row on an address the browser had
already moved off, which the possession query then reported as registered.

The replaced addresses now come from every row carrying this `deliveryId`,
whichever Host holds it: a delivery id names one Client's pairing and so speaks
for one worker scope. The rows dropped are still matched on the endpoint, which
is what reaches siblings whose delivery ids the request never names.

What it still cannot do is documented rather than papered over: a brand-new
delivery id after a re-pair has no row to read a previous address from, and
closing that would need cross-Host device identity the Server deliberately does
not hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`server.md`: the Relay's `STAGE-4 TRANSITIONAL` paragraph is replaced by the
rule it was deferring — the envelope is the whole client surface and the relay
holds no policy — and the `hostId` shape it promised moves above the fold into
State files, beside the `hosts.json` row that no longer carries a label. The
setup routes' rows, Guardrails, the opening loop, Configuration, Testing, and
"Running it" all say what registering a passkey now requires: a code an enrolled
Host displayed, and nothing else. State files also states the push-upsert rule
and, plainly, the one case it cannot cover.

`remote-security-model.md`: the Presence-proofs transitional sentence is gone,
stage 4 is landed whole, and its (c) list is deleted — stages 5–7 remain.

`SECURITY.md`: the setup password now buys `/api/host/enroll` and reaches the
account only through a Host it enrolled, which is what the setup-password bullet
and the accepted-risk section now say; the relay `FAIL IF` names the shipped
rule instead of a legacy clause.

`SELF_HOST.md` checkpoint 4 is reordered — the Host must be enrolled before the
phone has anything to scan — and `pairing.mdx` § 9 drops the legacy-path item.

Budgets: `server.md` and `remote-security-model.md` lowered where they shrank,
`SELF_HOST.md` raised by the sentences the new ordering costs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`#dropTransientState` is not a frame: `stop()` and the socket's own `close` run
it synchronously, so the chain cannot order it against a ceremony step. A
pairing or connection `init` whose handshake was still awaiting WebCrypto when
one landed went on to reserve an invitation the teardown had just retired —
announcing `reserved` for an id the panel was told was `dropped` — and to
allocate a client entry, holding a `NoiseTransportSession` and a
`MAX_PENDING_PAIRINGS` slot that nothing would ever clear. After `stop()` there
is no later close to clean it up. Reachable in normal use: a server restart or a
network blip mid-scan, or toggling remote control off while a phone is scanning.

Teardown stays synchronous — the service clears its mirrored pairing queue the
instant `stop()` returns, and deferring it would write to that queue afterwards,
possibly past a replacement Host. Instead it bumps an `#epoch` that `#enqueue`
stamps each step with. The capture is at *enqueue* time, not at step start: a
step is queued on the microtask queue, so a `stop()` on the very next line tears
down before it has begun. A step already stale never runs; one that goes stale
mid-flight is handed its epoch and refuses to mutate afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Leftovers of the deletion, and the prose that outgrew it.

Dead after the legacy paths went: `pickCredential`'s `tokenError` (one caller,
one value), `#resolveHost`'s missing-`hostId` arm and `parseFrame`'s matching
annotation (only `isE2eClientFrame` reaches there, and it proves the shape),
`enrollHost`'s options bag and the four `label` arguments still threaded into
it, and `reauth.test.mjs`'s assertion on a `Session` field that no longer
exists.

Said once instead of twice: `setupRetire` re-ran `readSetupGated`'s four steps
inline, which only stopped being possible while that helper still took a
password; the eviction-vs-teardown state derivation lived at both
`#retireInvitation` callers, so it moves into `#retireInvitation` as the default
when no cause is named; and the three `e2e` envelope builders this stage grew
across two relay suites move to `harness/e2e.mjs`, which already owns the
envelope's other facts.

`reauthFinish` checked the nonce's shape *after* consuming it, purely to
re-narrow; checking first keeps "consumed FIRST" true for every value that could
be a nonce. Comments on the `hostId` pin and on `upsert` restated their own spec
paragraphs at length; they keep the rule and hand the argument back to
`server.md`, and `SELF_HOST.md` does the same with `pocket-app.md`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
**The Host did not pin its own `hostId`.** The Server now mints and reads it as
`isE2eId`, but `isEnrollment` still took any string — and that value is the
routing id of every `e2e` envelope and the second field of every QR fragment,
which the parser accepts at exactly one width. A Server answering another shape
(or a hand-edited store) left this Host minting codes no phone could parse, with
nothing anywhere to explain it. `missingEnrollmentFields` mirrors the guard's
shape check too, so the rejection cannot name nothing.

**The harness stopped mirroring the Host.** `RemoteHost` now reports an evicted
invitation by what it was doing — `consumed` if a phone had scanned it,
`dropped` if nobody had — while `FakeHost` still said `consumed` for both. The
harness exists to fail where a real Host would, and this is also the event
`scripts/fake-host.mjs` re-mints on, so the drift would have re-offered a code
mid-ceremony. It now derives the same way.

Pinning tests for two rules the relay already followed and nothing asserted: a
`transport` from a Client with no binding, or naming a Host it has bound away
from, reaches no Host and is dropped rather than answered; and the eviction
distinction above. Both fail against the code without the fix.

The rest is text that outlived what it described: three installers and the
server's own startup error still told the operator the setup password creates a
passkey, which `/api/setup/*` stopped accepting in this stage; and `server.md`'s
Relay section still had an `established` flag, "legacy frames", and sessions
where the relay now keeps only a binding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`mintInvitation` is the panel's action, not the relay's: it runs off the frame
chain and reaps and evicts synchronously. A suspended `#onPairingInit` holds the
`HeldInvitation` it looked up before its dozen awaited WebCrypto calls, and the
`#epoch` guard added with the teardown fix covers only teardown — so a mint
landing mid-handshake could retire that entry underneath it. The handler then
wrote `reserved` onto a detached object, announcing a state change for a code
the panel had already been told was gone, and allocated a pairing naming an
invitation no later dispose could retire.

Re-checking that the map still holds this exact entry covers both causes at
once, next to the epoch check it joins.

Found by the stage-4c review. The other in-scope finding — an enrollment
rejection that could name no field at all — went in with the previous commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`client-gone during a handshake disposes what the handshake then creates`
failed in roughly a third of full-suite runs, before this review and after it.
Not a race in the Host: `settle()` gave one timer's worth of event-loop turns,
and a ceremony step is a dozen awaited WebCrypto calls resolving off the
threadpool, so under the suite's parallel load the chain had not drained when
the assertions ran. `settleUntil`'s own docstring says a fixed settle cannot
cover this; the tests added alongside it called bare `settle()` anyway.

`settle()` now spends several turns rather than one, which is what the tests
asserting *absence* need — a step that had not resumed yet made those pass
vacuously, which is the worse half of the bug. The two that assert a positive
outcome name it through `settleUntil` instead of counting turns.

Four consecutive full-suite runs clean, against three failures in the seven
before it, and no measurable change in wall time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`StreamReassembler` queued one array entry per stream body. The byte cap
bounds what a peer can declare, but not how many entries carry it: one
1 MiB message delivered as single-byte bodies grew the array to ~234 MB
of live `Uint8Array` headers.

Bodies now compact into one geometrically-grown buffer whose live window
slides forward, so memory is proportional to the bytes actually queued
(1.11 MB backing store for a 1 MiB incomplete message, 41 ms to feed it a
byte at a time) and the work stays linear.

Pinned by a memory bound, a drain-releases-the-buffer case, and a seeded
400-pattern fuzz over random message sizes and split points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`MAX_ESTABLISHED_E2E_SESSIONS`, `E2E_KEEPALIVE_INTERVAL_MS`,
`ESTABLISHED_E2E_IDLE_TIMEOUT_MS`, and the crypto token bucket's burst and
refill, in one shared module: the Host reaps on the idle timeout and the
Client keepalives against it, so two copies would be two opinions about
when a live session looks dead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Three Host-enforced bounds, all independent of the relay:

- `MAX_ESTABLISHED_E2E_SESSIONS = 16`, checked at promotion and only
  there — after the presence proof and the ACL conjunction have both
  succeeded, so nothing unauthenticated can decide who gets in. A Client
  static that already holds a session replaces its own atomically,
  whatever relay-chosen `clientId` it arrived under; any other identity
  at the cap gets the fixed-size `host-busy` and evicts nobody.
- A Host-global crypto token bucket on accepted `init` frames: an
  eight-operation burst decaying to one per second, on the injected
  clock. A refused frame performs no WebCrypto, allocates nothing, and
  is dropped — an accepted-but-failing pairing `init` costs 1 deriveBits
  and 3 digest, a refused one costs zero.
- One reaper over absolute timestamps for invitations, pending pairings,
  pending connections, and established sessions, replacing the lazy
  per-frame invitation sweep. It runs on every init, every local
  decision, every relay lifecycle event, and a next-expiry timer that is
  re-armed on change and cleared on `stop()`. `lastClientActivityAt` is
  refreshed only by a successfully decrypted Client->Host transport
  message; an expiry answers only where a transport cipher exists and
  someone is still owed one.

Instrumented by `remote-host-bounds.test.ts`: a counting wrapper over
`globalThis.crypto.subtle` plus a spy on `NoiseTransportSession.receive`,
an injected clock and timer, and the private-map sizes read through the
existing counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`PocketClient` sends one fixed-size keepalive every
`E2E_KEEPALIVE_INTERVAL_MS` on an established session, and only while
`document.visibilityState === 'visible'`: a backgrounded tab has its
timers throttled or suspended, so it must not promise a liveness it
cannot keep. Returning to the foreground sends one immediately and
resumes the interval; the session ending stops both.

The timer and the visibility state are injected, so the tests fire an
interval by hand rather than waiting thirty seconds, and `host-gone` now
runs the same `#disposeCeremony` every other teardown does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
nedtwigg and others added 6 commits September 1, 2026 20:48
A relay that records, drops, reorders, duplicates, modifies, and invents
frames between the real fake Client and fake Host. It wraps the shipped
`RelayHub` rather than reimplementing it, so what it routes is the
routing under test, and both harness halves now accept an injected
socket so the relay can sit between them in memory (HTTP stays a real
server: the presence proofs go through the real `/api/reauth/*`).

What it proves: no pairing decision, Host label, delivery id, static,
handshake hash, remote-api message, or terminal byte crosses it; a
forged or corrupted outcome is not believed and pins nothing; every
tampered, duplicated, or reordered frame poisons the receiving session
and buys nothing else; dropping everything is denial of service and not
authorization.

Its last case removes the relay's own `ct`/`id`/shape guards entirely
and runs the flood cases through the guard-less pipe: every one is
refused by the Host's own `isE2eServerToHostFrame`, no entry is
allocated under a relay-chosen key, no scanner is spent, and the honest
ceremony still completes on the same wire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`remote-security-model.md` -> Host bounds gains the session cap, the
crypto token bucket, the one reaper, and the idle deadline's refresh
rule, with `Source of truth` naming the two suites that pin them; stage
5 leaves the `## Future` ledger. `pocket-app.md` states what keepalives
cost a backgrounded phone — a new handshake and one WebAuthn prompt.
`server.md` records the malicious-relay harness under Testing, drops the
promoted item from its own ledger, and corrects the reassembler's
framing note. `SECURITY.md` -> Remote Control gains a `FAIL IF` for the
Host-enforced bounds naming the constants and the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
None of these are stage-5 work; they are the small, clearly-correct half
of the thirteen findings the stage-4c review left behind. Each lands with
a pinning test.

- A `setupFinish` whose answer never arrived left a browser that reads as
  returning while holding a credential the account never got: every later
  scan signed in, failed, and clearing site data was the only way out.
  A Server *refusal* of the sign-in now outranks this browser's own record
  of prior use and falls through to registration; a dismissed prompt or a
  dead radio still propagates and spends nothing.
- An `expired` invitation rendered as "Scanned. Finish on the phone",
  sending the user to a phone that never asked — the exact confusion the
  `dropped` branch exists to prevent. It has its own copy now, and the
  state-to-phase map is exhaustive over `InvitationState`, so a state
  added to the Host cannot fall through to "Scanned" again.
- `POST /api/setup/finish` stored `credentialId` unchecked while
  type-checking `label` on the next line. It is handed back to every
  later `setup/begin` and base64url-decoded by the Client, so one
  malformed id wedged passkey registration for the account.
- The Host's own label went on the wire unbounded, while the Client's
  outcome guards refuse any field over `CEREMONY_FIELD_LIMIT` — a machine
  name past that paired on the laptop and was discarded by the phone.
- `#sendApp` disposed the session on any throw, including the over-size
  refusal the transport raises *before* encrypting anything — turning a
  caller's size error into a re-handshake, and doing it re-entrantly from
  inside the receive loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`isEnrollment` now checks the *shape* of `hostId`, not just its type: it is
the routing id of every `e2e` envelope, so it must be `isE2eId` — base64url
of 16 bytes. The glue tests still stored `hostId: 'host-1'`, so the service
read every one of those enrollments as malformed and never started a Host,
timing out five tests. The ACL fixtures are untouched: `isHostAclRecord`
type-checks `hostId` and length-checks only the two E2E fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality only: no bound's value or semantics moved, no wire change, the
reaper is still one mechanism, and the flood cases still prove zero crypto.

- One deadline enumeration. `#reap` and the arming pass both walked the
  invitation map and both client maps, and spelled the idle deadline's
  arithmetic twice; `#deadlines()` is now the one place a fifth deadline
  would be added. The reaper runs on `init` frames, which is what the spec
  and `SECURITY.md` already said it did — an established transport frame
  was paying two O(sessions) walks on the terminal path — and re-arms only
  when the soonest instant moves *earlier*, which is what its own comment
  already promised.
- One walk answers both questions a promotion asks, and the reconnect
  backoff goes through the same injected timer as the reaper, so
  "`stop()` leaves nothing armed" is a thing the bounds test can see.
- One shared `RemoteTimer` + `hostTimer` in `remote/ws.ts`, replacing the
  identical `setTimeout` wrapper and four spellings of its type. Pocket's
  visibility handler decides "is a timer armed?" in one place, and reads
  `globalThis.document` instead of a hand-rolled structural type.
- The E2E fixture, `establish`, `watch`, and `flip` move into
  `server/test/harness/e2e.mjs`; the malicious-relay suite was a
  60-line fork of `e2e-relay.test.mjs`'s copy. Its drop case now lowers
  the harness's frame deadline — that one case was 2.0s of a 2.6s suite,
  which is now 0.35s.
- `remote-host-bounds.test.ts` reuses `pairThroughSocket` and its own
  `makeHost`/`establish` instead of re-inlining them three times, and the
  case that asserts *zero* crypto no longer claims to cost a decrypt.
- Comment proportionality: the promoted bounds table owns the session cap,
  the bucket, and the idle rule; the four restatements in `e2e-bounds.ts`,
  `remote-host.ts`, and the tests are one-liners plus pointers. Three
  rationale clauses move to `remote-security-model.rationale.md`, the
  reaper's outcome mapping becomes a table, and both budgets are retuned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Six confirmed findings, each with a test that fails without its fix.

- **A reaped session left Pocket holding a corpse.** The Host disposes an
  idle session silently, and the Client's relay socket is to the *Server*,
  so nothing closed and no `host-gone` arrived: a phone backgrounded past
  the idle timeout came back to a wall whose every `request()` hung
  forever with no error and no way out but a reload. `PocketClient` now
  runs the Host's own deadline against its own last send — the message the
  Host's deadline is measured from — before a keepalive and before every
  request, and reports host loss when it passes. This is what
  `pocket-app.md` already promised.
- **`ServerRefusalError` is every non-ok answer**, so the stage-4c
  registration fallthrough also fired on a signin challenge that expired
  at the Face ID prompt, a rejected assertion, and a restarting server's
  502 — each of which spent the single-use setup token and minted a
  redundant second passkey. The error carries its status now, and only the
  404 that proves the Server holds nothing falls through.
- **`#sendApp`'s poisoned branch disposes re-entrantly**, and the receive
  loop went on handing the rest of the receipt to an api it had just
  disposed. It stops at the dispose.
- **`StreamReassembler` is exported on its own**, so it cannot lean on the
  session's poison: a caller pushing after a terminal error grew past the
  bound and eventually threw a bare `RangeError`. The first failure latches
  and releases the buffer.
- **`#disposeConnection` consumes the challenge it drops**, so every path
  that abandons a pending connection reclaims it, rather than leaving the
  issuer's own lazy sweep as a second reclaim policy beside the reaper.
- **Two invariants were stated wrong.** An entry holds one established
  session, so a promotion under a relay-reused `clientId` replaces it
  whatever identity it belonged to — the cap displaces nobody, which is
  the rule that actually matters; and `malicious-relay.test.mjs` drives
  the fake Host, so it shows the wire refusals surviving a guard-less
  relay rather than auditing the Host bounds.

Also pinned, without code changes: the bucket's burst after a long idle
and across a clock rewind, a deadline falling exactly on `now`, one clock
jump reaping every deadline kind, the inputs that must *not* refresh the
idle deadline, a keepalive whose socket throws, and alternating body sizes
against the reassembler's bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 495243e
Status: ✅  Deploy successful!
Preview URL: https://ff787c96.mouseterm.pages.dev
Branch Preview URL: https://e2e-client-host-2.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Standalone Smoketest is red on this head, and it is this diff: the new 'jwk' overload in SubtleCryptoLike makes the globalThis cast in getWebCrypto uncheckable once DOM types are in the program. standalone/tsconfig.json is the only project that compiles server-lib-common's source with "lib": ["ES2020", "DOM", "DOM.Iterable"] (via its paths mapping to ../server-lib-common/src/index.ts), which is why root pnpm test and pnpm build are green and this job is not. Inline suggestion below; { readonly x?: string } is the field deriveNoiseStaticPublicKey actually reads, and JsonWebKey declares x?: string, so the cast becomes comparable again. A named JsonWebKeyLike interface beside CryptoKeyLike would read better if you expect more JWK fields later — either is fine, but a bare Record<string, unknown> cannot work here.

Everything else I traced held up. Worth recording what I checked, since a diff this size gets reviewed once: the pairing/connection state machine (epoch guard across teardown, one-use invitations, the single confirm attempt spent before the compare, #disposePairing always retiring consumed), the StreamReassembler growth bound (needed provably stays under MAX_BUFFER_CAPACITY, so the Math.min clamp never truncates), the reaper's one-enumeration deadline sweep and its re-arm rule, the eviction loops against their caps, the four-field ACL conjunction at connect, and the push store's deliveryId rekey including the endpoint-rotation prune. The keepalive/idle-reap pair is also sound in the direction that matters: the Client's lastSentAt is always at or before the Host's lastClientActivityAt, so #reapedByHost can only fire early, never late.

One minor thing, and one note

RemotePairingModal gives the code input autoFocus while also passing initialFocusRef={denyButtonRef}. useModalFocusTrap focuses in a useEffect, which runs after the browser applies autoFocus, so Cancel wins and the autoFocus never takes effect. Focusing Cancel looks like the intent here (same pattern as KillConfirm), so the attribute is the part to drop — suggestion inline.

Not a finding, just noting I read it deliberately: isStoredPushSubscription checks typeof s.deliveryId === 'string' where every route uses isDeliveryId (exact base64url length). Harmless — a wrong-length id on disk matches no query and no send target — so I'd leave it unless you want the read boundary to state the same shape the routes do.

Happy to push the webcrypto.ts fix if you'd rather not round-trip it.

Comment thread server-lib-common/src/security/webcrypto.ts Outdated
Comment thread lib/src/remote/host/RemotePairingModal.tsx
nedtwigg and others added 2 commits September 1, 2026 22:01
The `Record<string, unknown>` return on the new `'jwk'` overload made
`SubtleCryptoLike` incomparable to the DOM's `SubtleCrypto` — `JsonWebKey`
has no index signature — so the `globalThis` cast in `getWebCrypto` failed
TS2352 in every project that compiles this package's source with DOM libs.
`standalone/tsconfig.json` is the only such project, which is why root
`pnpm test` and `pnpm build` were green while Standalone Smoketest was red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
`ModalFrame` passes `initialFocusRef={denyButtonRef}` to
`useModalFocusTrap`, which focuses in a `useEffect` — after the browser has
applied `autoFocus` — so Cancel always won and the attribute never took
effect. Cancel-focused is the intent (same as `KillConfirm`), so the
attribute is the half to remove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
@nedtwigg

nedtwigg commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

On the isStoredPushSubscription note: leaving it as typeof === 'string'. That guard is a shape check for the JSON file, not a validity check — hostIdis loose there too, and tightening onlydeliveryId` would make the boundary say two different things about two ids that both have pinned shapes at the routes. As you note, a wrong-length id on disk matches no query and no send target, so the strict check buys nothing but a silent row drop on read.

Base automatically changed from e2e-client-host to main September 2, 2026 15:31
@nedtwigg
nedtwigg merged commit d7f0a07 into main Sep 2, 2026
19 checks passed
@nedtwigg
nedtwigg deleted the e2e-client-host-2 branch September 2, 2026 15:32
nedtwigg added a commit that referenced this pull request Sep 2, 2026
main's Noise rewrite (#515, #517, #518, #522, #523, #524) already landed
this PR's change: `test-remote-host-link.ts` now imports
`DEFAULT_PAIRING_TTL_MS` from `server-lib-common` and builds the fixture's
`expiresAt` from it, exactly as this branch intended. The surrounding QR
grammar it was written against is gone — the fixture composes a positional
`#pair?` invitation via `formatPairingInvitationUrl`, and `SetupTokenResponse`
no longer carries `mintId`.

Both conflict hunks resolved wholly on main's side, so the branch is now an
exact no-op against main: nothing pre-Noise is reintroduced, and the intent
this PR chased is already enforced by main's import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
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