- See
docs/specs/glossary.mdfor Burrow, Client, Relay, Pane, and Baseboard vocabulary.- Owns one-time links, Hosted rendezvous, and direct-only sessions. Trust rules:
docs/specs/remote-security-model.md-> "One-time connection"; audited checks:docs/specs/security-remote.md-> "One-time connection" anddocs/specs/security-hosted.md-> "Rendezvous boundary".
Until the switch the room forwards every phone↔laptop message verbatim (Hosted rendezvous); the laptop's state names in the notes are OneTimeStates.
sequenceDiagram
participant L as Laptop (OneTimeRuntime)
participant H as OneTimeRoom
participant P as Phone (OneTimeClient)
Note over L: oneTimeOpen: opening, mints the one-use keypair
L->>H: Burrow route
H->>L: OneTimeRoomFrame
Note over L: waiting, shows the link
Note over P: takes and erases the fragment, waits for the Connect tap
Note over P: connectOnce: mints a static, writes message 1
P->>H: client route
P->>L: init (message 1)
Note over L: link reserved, key erased
L->>P: response (message 2)
Note over P: shows the two digits
P->>L: OneTimeRequestV1
Note over L: confirming, approval modal, one attempt
L->>P: OneTimeOutcomeV1
Note over L,P: connecting, DIRECT_ONLY_DEADLINE_MS to switch both directions
L->>H: close at the switch (connected)
P->>H: close at the switch
Note over H: deletes itself, the channel carries the session
One link per connection, one phone per link. Exactly <origin>/connect/#<v>.<roomId>.<expiry>.<ephPub>: the fragment is positional, dot-delimited, carries no field names, is exactly 79 characters, and never reaches a server.
The link names the page's own origin, HOSTED_ONE_TIME_ORIGIN, never Pocket's, and the Burrow never connects to it. A dev Hosted build names its relay origin, which redirects ("Links from older builds"), since no second origin is baked.
| Field | Encoding, exact length | Purpose |
|---|---|---|
v |
literal 1, one character |
E2E wire version; any other value is rejected, never negotiated |
roomId |
16 bytes as 22-character unpadded base64url | the rendezvous room Hosted minted |
expiry |
unsigned 32-bit epoch seconds as exactly 10 decimal digits | the phone's fail-fast; the Burrow checks its own copy at the attempt |
ephPub |
32-byte X25519 public key as 43-character unpadded base64url | the one-use Burrow Noise responder key for this link |
ONE_TIME_LINK_MAX_LENGTH = 256, enforced at mint, which bounds the origin (docs/specs/relay.md-> "Relay origin").- One parser boundary.
parseOneTimeLinkUrlanswers the complete link ornull, never a partial parse: the URL around the fragment underdocs/specs/relay.md-> "Setup tokens and the pairing QR", and the path exactlyONE_TIME_PAGE_PATH(/connect/). - Must keep a link live through
expiry * 1000, expiring the following millisecond (oneTimeLinkExpired), and the parser refuses on that same rule. - The prologue contract:
docs/specs/remote-security-model.md-> "One-time connection".
Which desktop release may carry a link version: docs/specs/deploy.md -> "Release checklist".
Source of truth: remote-lib-common/src/security/one-time-link.ts; oneTimeLinkOrigin in lib/src/host/relay-origin.ts.
A separate frame family, never an E2eKind, so neither the Relay nor BurrowRuntime has a reader for it. Two WebSocket routes: ONE_TIME_WS_ROUTES.burrow (/api/one-time/burrow) on the relay origin mints a room, and ONE_TIME_WS_ROUTES.client (/api/one-time/client?room=<roomId>) on the page's origin joins one. Must send each message as one JSON frame with exact keys, forwarded verbatim by the room:
| Frame | Direction | Shape |
|---|---|---|
OneTimeRoomFrame |
room → Burrow, once, first | {t: 'one-time-room', roomId, expiresAt}, expiresAt epoch ms whose whole seconds fit a uint32 |
OneTimeClientFrame |
phone → Burrow | {t: 'one-time', step: 'init' | 'transport', ct} |
OneTimeBurrowFrame |
Burrow → phone | {t: 'one-time', step: 'response' | 'transport', ct} |
ct is one base64url Noise message, bounded as on the relay envelope. Each end keeps its open socket alive with the relay socket's RELAY_PING heartbeat (docs/specs/relay.md -> "Routing"), whole strings never JSON, holding the room to no deadline.
- Must measure a frame's raw text against
MAX_ONE_TIME_FRAME_LENGTHbefore parsing it; both ends read the room throughparseOneTimeFrame. A room forwards at mostMAX_ONE_TIME_FORWARDEDframes, both directions together. - Timings. An unused link lives
ONE_TIME_LINK_TTL_MS. The join and the confirmation finish by its expiry, and the direct path'sDIRECT_ONLY_DEADLINE_MSafter the outcome ends inside the room's hard deadline,expiresAt + ONE_TIME_EXPIRY_GRACE_MS. Values:docs/specs/remote-security-model.md-> "Burrow bounds". - The ceremony's messages are padded
controlmessages on the Noise session (docs/specs/relay.md-> "E2E framing"):OneTimeRequestV1 {code, label}phone → Burrow, the page sending alabelfromONE_TIME_DEVICE_LABELS, then oneOneTimeOutcomeV1—{ok: true, burrowLabel}, or{ok: false, code}withcodeone ofONE_TIME_DENIAL_CODES(user-denied,confirmation-mismatch,link-expired,burrow-error).
The room closes a socket with one of six codes:
| Code | Constant | Meaning |
|---|---|---|
| 4010 | WS_CLOSE_ONE_TIME_EXPIRED |
the link expired with no phone joined |
| 4011 | WS_CLOSE_ONE_TIME_TAKEN |
a phone already joined |
| 4012 | WS_CLOSE_ONE_TIME_UNAVAILABLE |
no such room, or it can no longer be joined |
| 4013 | WS_CLOSE_ONE_TIME_PEER_GONE |
the other end's socket closed |
| 4014 | WS_CLOSE_ONE_TIME_DEADLINE |
a phone joined, and the hard deadline passed |
| 4015 | WS_CLOSE_ONE_TIME_VIOLATION |
a binary frame, one over the length bound, or one past the message cap |
A laptop ends on a close code it does not know as rendezvous-lost, and the phone reads one as generic copy for its phase; an outcome whose guard refuses it, a denial code the phone does not know included, reads as burrow-error. So either deploy may add a code first.
Source of truth: remote-lib-common/src/remote/one-time-wire.ts; parseOneTimeFrame in lib/src/remote/one-time-rendezvous.ts; OneTimeRequestV1 / OneTimeOutcomeV1 in remote-lib-common/src/security/e2e-ceremony.ts.
OneTimeRuntime is single-use: one rendezvous socket, one one-use keypair, one phone, one guess, and at most one session. It opens once and ends once, and nothing resumes. Its states are OneTimeState, its endings OneTimeEndReason.
- Must open the socket through the host's factory with no
Originheader, on the relay origin's Burrow route. - The first message must be one
OneTimeRoomFrame, the keypair minted and the frame read withinONE_TIME_OPEN_TIMEOUT_MS, elseunreachable. The link's expiry is the earlier of the runtime's ownnow + ONE_TIME_LINK_TTL_MSand the room'sexpiresAt, floored to whole seconds. - Must discard
RELAY_PONGkeepalive replies and guard and count every other later message after the length check: pastMAX_ONE_TIME_FORWARDEDthe runtime stops reading the room. - Reserving the link:
docs/specs/remote-security-model.md-> "One-time connection". - Must spend an
E2E_INIT_BURSTTokenBuckettoken before aninit's WebCrypto. The first non-keepalive transport message must beOneTimeRequestV1, elseburrow-error. Its label passesknownOneTimeDeviceLabelbefore the approval request or any state carries it, so anything not exactly a member ofONE_TIME_DEVICE_LABELSisPhone browser(the rule:docs/specs/remote-security-model.md-> "One-time connection"). - Promotion reuses the same Noise session in an
EstablishedE2eSessionwhosehello.burrowIdis the room id, and arms the direct deadline. A decline the Burrow sends reaches the phone before the rendezvous closes. - At the switch it closes the rendezvous normally (the lifecycle carve-out:
docs/specs/remote-security-model.md-> "One-time connection"); before the switch a room close ends the connection by its code. - Never let a deadline run later than the room's: each is on the runtime's own clock. A link not yet promoted ends the first millisecond past its expiry — a claimed one telling its phone
link-expired, which dismisses the modal — and the request and the approval each check the expiry again. end()releases everything and writes nothing. Auser-ended,idle, ornetwork-not-allowedending sends the goodbye first, before the room closes (docs/specs/remote-api.md→ Transport), so a phone still connecting hears it over the rendezvous.
Source of truth: OneTimeRuntime in lib/src/remote/burrow/one-time-runtime.ts; directOnly in lib/src/remote/burrow/established-session.ts.
OneTimeClient is single-use: one connectOnce, one rendezvous socket, and at most one session, on ClientSessionCore, direct or not at all. connectOnce is the phone's half of Flow: at the switch it closes the rendezvous normally and resolves {ok: true, burrowLabel}; a decline, an abandoned attempt, or no switch by DIRECT_ONLY_DEADLINE_MS fails it.
- Never open a socket outside
connectOnce, which runs once per client and opens none for an expired link. - Must end the handshake and the confirmation at the room's hard deadline, WebCrypto and the socket's open included; the direct path runs on the direct deadline instead.
- Never import store, passkey, push, or worker code (the static's rule:
docs/specs/remote-security-model.md-> "One-time connection"). - Every protocol-v1 method refuses until both directions are direct (Direct required:
docs/specs/remote-security-model.md-> "One-time connection"). - Must drop what
isOneTimeBurrowFramerefuses, after the length check. - An outcome already read outranks the room's close behind it.
- After the switch (the same carve-out), channel loss, the laptop's goodbye, or the idle deadline reaches
setOnEndedonce;close()reports nothing. - Every failure resolves
{ok: false, message}with fixed copy, never text from the room or the Burrow but the goodbye's address, whichnetworkNotAllowedMessagefills in (docs/specs/remote-network.md-> "Local networks").
Source of truth: OneTimeClient in lib/src/remote/client/one-time-client.ts.
The relay Worker serves the Burrow route and the try Worker the client route (docs/specs/hosted.md -> "Application boundary"), and one OneTimeRoom Durable Object per room, which the try Worker implements and the relay binds, carries the frames. Never parse, store, or log a forwarded frame: the room bounds a frame by its raw length and its count alone, and forwards the string verbatim (rationale).
| Route | Admits | Refuses |
|---|---|---|
ONE_TIME_WS_ROUTES.burrow |
an upgrade with no Origin header |
426 without an upgrade, 403 on any Origin, 429 past ONE_TIME_MINT_LIMIT |
ONE_TIME_WS_ROUTES.client |
an upgrade whose Origin is exactly the try Worker's APP_ORIGIN, naming exactly one E2E id as room |
426 without an upgrade, 403 on any other or no Origin, 400 on a missing, repeated, or malformed room, 429 past ONE_TIME_JOIN_LIMIT |
- Must mount after the bindings mapper and the 421 gate, and never read a cookie, reach Hyperdrive, or call auth. The Worker mints each room id from 16 random bytes and hands the room a fresh request carrying only the upgrade and the room id, never the caller's headers.
- The
Originrules are abuse control, never authorization (rationale). - Both limits key on
cf-connecting-ip: an IPv6 address by its /64, an IPv4-mapped one (::ffff:0:0/96, however spelled) by its IPv4, and a missing one aslocal(rationale); the Worker's own Wrangler config sets each. - The room's state is the Burrow socket's hibernation attachment —
expiresAt,joined, and a count of every frame received — never memory, so a hibernated room keeps its join and its count.RELAY_PINGis answered by the runtime's auto-response and never wakes, forwards, or counts.
A room's life, each step one event on the object:
- Open. The Burrow's upgrade arms one alarm at
expiresAt + ONE_TIME_EXPIRY_GRACE_MS, withexpiresAtONE_TIME_LINK_TTL_MSfrom now, accepts the socket taggedburrow, and sendsOneTimeRoomFrame. - Join, decided synchronously, check and set with no await between: no open Burrow socket answers 4012, a joined room 4011, a join past
expiresAt4010; otherwise the socket is accepted taggedclient. A refusal accepts outside hibernation and closes at once, so its close never reaches the room's handlers (rationale). - Forward. Every text frame counts (rationale), and goes verbatim to the other end's open socket, or nowhere before a phone joins. A binary frame, one longer than
MAX_ONE_TIME_FRAME_LENGTH, or one pastMAX_ONE_TIME_FORWARDEDcloses both ends with 4015. - Leave. Either end closing or erroring closes the room's sockets with 4013, the leaving end's included as its reply.
- Deadline. The alarm closes what remains with 4010 if no phone joined, 4014 if one did.
Every ending deletes the alarm and the room's storage, so a finished room holds nothing.
Source of truth: oneTimeBurrowRoute and oneTimeClientRoute in hosted/server/one-time.ts; OneTimeRoom in hosted/server/one-time-room.ts.
The try Worker serves the phone's half at ONE_TIME_PAGE_PATH (/connect/): OneTimeApp, refusing a browser without X25519 or RTCPeerConnection. Never serve it on an origin holding a paired phone's keys: it renders what a stranger's link sends (rationale).
- Must take the fragment and erase it with
history.replaceStatebefore the first render, parsed or not, and reload onhashchange(rationale). - Never open a socket before the Connect tap: the tap builds the client and runs
connectOnce, so a link-preview crawler spends nothing. - Its ICE servers:
docs/specs/remote-network.md-> "Anywhere". - Must show the Surfaces Pocket shows, with the same wall, Tools and pages included (
docs/specs/pocket-app.md-> "Frame origin"). - Must be ephemeral to the person using it: never persist anything of the page's own: no storage, IndexedDB, worker, push, or cookie. A framed page's own origin may leave a worker and what the page stored, under a salt held only in memory, so nothing can reach them again until the browser evicts them.
scripts/e2e-lint.mjsholds the page to the client's store rule. - Must mount the wall only on
ok; every ending closes the client and releases the adapter. - The label the page sends is
oneTimeDeviceLabel, never Pocket'sdeviceLabel, and always a member ofONE_TIME_DEVICE_LABELS(rationale).
Build. build:one-time in lib/package.json builds lib/one-time/ into lib/dist-one-time under base ONE_TIME_PAGE_PATH, then runs assertPocketShell --one-time: scripts under /connect/assets/, links under /connect/, nothing inline. Hosted's build runs it first, and stageTry empties the try Worker's assets directory, copies the page to hosted/dist/try/connect/, and checks the copy.
Serving. The try Worker answers /connect, /connect/, and /connect/assets/* from its assets, with no SPA fallback; an HTML answer or an unhashed name under /connect/assets/, and any other path under /connect/, is a 404. Everything under /connect carries oneTimePagePolicy (hosted/server/headers.ts), built from its APP_ORIGIN, and every other response RUNS_NOTHING_POLICY.
- An
APP_ORIGINthat is not exactlyscheme://host[:port]of plain host characters getsRUNS_NOTHING_POLICYinstead: the URL parser admits;,,, and'in a host. - The sandbox keeps
allow-same-origin, and Chrome's warning about the pair stands (rationale).
Source of truth: OneTimeApp in lib/src/remote/one-time-app/OneTimeApp.tsx; stageTry in hosted/scripts/stage-try.mjs; oneTimePageRoutes in hosted/server/one-time.ts; tryRules in hosted/server/headers.ts.
Every release before the page's move shows https://relay.dormouse.sh/connect/#…, and the phone of that link joins a room minted on the relay's Burrow route, as every release's is.
- The relay must answer
/connectand/connect/with a 308 toTRY_ORIGIN+ONE_TIME_PAGE_PATH, and nothing else under/connect/: no page, no shell, and no client route, so nothing of a stranger's renders beside Pocket from the release that serves the try Worker on (rationale). - A browser carries the fragment across the redirect, and the prologue binds no origin, so the link completes on the page's origin unchanged.
- Must keep the redirect while a shipped Burrow may show such a link (
docs/specs/deploy.md-> "Release checklist"); it renders nothing, so no grace period ends it. - A phone that loaded the old page before the deploy finds its client route gone at the Connect tap, and reads generic copy, like any lost room.
Source of truth: oneTimePageMoved in hosted/server/one-time.ts.
dor tool one-time runs the rendezvous and page on loopback, without Postgres: the relay Worker at PORT or a fixed default, since a Burrow build bakes that origin in, and the try Worker on the next port, sharing its room through Wrangler's local registry. Their configs carry no route or production secret. It prints the dev Burrow build variables (DORMOUSE_RELAY_ORIGIN, DORMOUSE_RELAY_IS_HOSTED=1; docs/specs/relay.md -> "Relay origin"). Started by dor tool hosted, it reads that loop's development database and announces no page, since that Tool frames the account origin (docs/specs/hosted.md -> "Development and release").
Source of truth: hosted/scripts/dev-one-time.mjs.
BurrowService holds at most one OneTimeRuntime, independent of the enrollment: it works un-enrolled, survives clearEnrollment and reconnect, and dispose() ends it. Its label is the enrollment's, else suggestedBurrowLabel(kind); its provider and direct-peer factory are the enrolled runtime's, under the level's path rules (docs/specs/remote-network.md -> "Local networks" and "Anywhere").
| Command | Answers |
|---|---|
oneTimeOpen |
the state the open settled at, waiting or ended |
oneTimeEnd |
{}: a live connection ends user-ended; an ended one returns to idle |
oneTimeStatus |
the current OneTimeState |
oneTimeOpentakes no parameters: the origin is never webview input. It joins an open in flight, replaces a waiting, confirming, or ended connection (ending it unannounced), and is refused whileconnectingorconnected, and whileunavailable.- Must cancel a pending
oneTimeOpenononeTimeEnd, including while the policy is being read, without cancelling later opens. - Every state change is a
{ name: 'one-time', state }event, and so is a service's start (rationale). statusand its event carryserving: a running Burrow (none under Nothing), or a one-time status ofopening,waiting,confirming,connecting, orconnected; a flip emitsstatus. What arms on each:docs/specs/vscode.md-> "Burrow: a service in the extension host".- Approval carries a
kind(pairing|one-time) onPendingPairing,PairingQueueItem,ApproveParams, andDenyParams. The one-time request has a slot of its own,clientId: ''with a fresh randompairingIdticket. The service reads anykindbutone-timeas apairing, so a malformed answer finds nothing to approve.
The origin is the build's Hosted origin, which only a Hosted build has (docs/specs/relay.md -> "Relay origin", whose "Accepted origins" bounds it). Availability is decided before any socket exists: without a Hosted origin the state is unavailable with reason self-host, and the service builds no runtime; the network policy's nothing makes it unavailable with reason network-off, and a policy change ends a live connection (docs/specs/remote-network.md -> "Policy").
Standalone's Rust broadcasts every burrow:event unchanged to every window. VS Code's bootstrap, idle answers, and serving marker are docs/specs/vscode.md -> "Burrow: a service in the extension host".
Source of truth: BurrowService in lib/src/host/remote/service.ts; lib/src/host/remote/service-protocol.ts.
OneTimeConnection sits in the Remote control choices of Settings → Network's Phones section, enrolled or not (docs/specs/remote-network.md -> "Settings → Network"); OneTimeIndicator sits in the Baseboard (docs/specs/layout.md -> "Baseboard").
- Must render service state and accept busy/error updates only from the latest panel action. Closing Settings changes nothing: the link keeps waiting.
- Never open a link on a timer: only New link replaces one (rationale).
- Cancel and End send
oneTimeEnd; Done sends the one that returnsendedtoidle.ended {user-ended}renders asidle(rationale). - End copy is fixed per reason and level, with a fallback for a reason a newer broker knows.
- The indicator shows only while
connectingorconnected, with End (rationale).
Source of truth: OneTimeConnection in lib/src/components/OneTimeConnection.tsx; OneTimeIndicator in lib/src/components/OneTimeIndicator.tsx; lib/src/remote/burrow/one-time-store.ts.
Scope: one-time-limits — a per-IP cap on concurrent rooms, beside the mint limit.