Repository navigation
Inbox mobile: relay mailboxes and push (R1) - #1788
Merged
Merged
Conversation
The relay Worker (apps/relay): one Durable Object per mailbox on SQLite, holding hashes, each phone's relay switch, its APNs token and nothing it can read; APNs over a small HTTP/2 client on a connect() socket, since a Worker's fetch cannot reach APNs. The Inbox side (packages/server/ inbox-relay.ts): the mailbox at the first pairing in inbox/relay.json, phones registered and removed, one socket with hello and carriage, one encrypted push per question or guided review. Core gains encryptWithKey, decryptWithKey and deriveRelayKeys with vectors from all-zero inputs. Contract section 4 written in first. Proved under wrangler dev with the compiled binary (.github/workflows/relay.yml).
The Durable Object served /create from fetch, and the Worker forwarded every /v1/mailboxes/:mbx/<rest>, so a client could replace any mailbox's hash and make mailboxes under ids it picked, past the creation brake. Creation is now stub.create(hash) on the object the Worker named with a fresh id; fetch has no create path; create refuses over an existing hash. The proof probes both.
… review) Frames over 16384 bytes and answers over 8 KiB fail at once; one read buffer with an offset, no re-copy per read. Also: the abandoned exchange's rejection is caught, a graceful GOAWAY that covers stream 1 still answers, the provider token is cached per team and key id, the collapse id is an HMAC of the thread id under the phone's key (bounded to 64 bytes at the relay), relay.yml has a concurrency group, and the Apple test runs only with RELAY_PROOF_APPLE=1. A hostile peer joins the proof.
# Conflicts: # AGENTS.md # bunfig.toml
1 task
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.
The story
A paired phone now has a mailbox at the relay, and a locked phone can be told when an agent asks it something: the Inbox makes its mailbox at the first pairing, registers each phone by a hash it cannot reverse, and sends one encrypted push per question or guided review. The relay is a Cloudflare Worker with one Durable Object per mailbox that holds only hashes, each phone's relay switch, its APNs token and envelopes it cannot open. It reaches Apple over a small HTTP/2 client on a
connect()socket, because a Worker'sfetchcannot reach APNs (the spike's item 6).What changed
apps/relay/(new, private, version0.0.1, out of the seven release-bumped files; owner-deployed, never from CI):src/index.ts: the Worker. Routes the contract's section 4 to the mailbox's Durable Object;POST /v1/mailboxesgoes through guides.show's rate limiting rule; nothing else is braked.src/mailbox.ts: the Durable Object on SQLite (mailbox: the mailbox secret's SHA-256;devices: the relay secret's SHA-256, the switch, the cursor, the APNs token and environment). Register, remove, the phone's relay switch, the APNs token, push, and the Inbox's socket withhelloandcarriage.src/apns.ts: the ES256 provider token from theAPNS_KEYsecret (kept 50 minutes) and the push (apns-push-type: alert, priority 10, topicai.plannotator.app, collapse id per thread, host by the token's environment). A 410 deletes the token; without the key a push answersno_apns_keyand logs once.src/apns-h2.ts: one HTTP/2 request over aconnect()socket: preface, SETTINGS, one HEADERS frame (:method POSTand:schemefrom the static table,:pathand:authorityliterals with a static name, every other header a literal without indexing, no dynamic table), one DATA frame; reads:statusand the DATA body; one connection per push. No imports beyondcloudflare:sockets, so Workspaces can copy it.wrangler.toml(the DO, the SQLite migration, the rate limit block, therelay.plannotator.aicustom domain, the secrets named in a comment),tsconfig.json,bunfig.toml,package.json.test/relay.test.ts: the proof (below).packages/server/inbox-relay.ts(new): the Inbox's side. The mailbox at the first pairing, kept ininbox/relay.json(0600); each phone registered at pairing and removed on revoke; one outbound WebSocket while a phone is paired (hello: each phone's switch into its record, a revoked one removed, an unlisted one registered;carriage: the switch a phone flipped); one push per phone with its switch on when an agent's message lands with a question or a guided review, sized to Apple's 4096 bytes.PLANNOTATOR_RELAY_URL, defaulthttps://relay.plannotator.ai.packages/server/inbox-devices.ts: the redemption answersrelay: { url, mailbox_id }(was always null); a revoke tells the relay.packages/server/inbox.ts: starts and stops the relay beside the phones.packages/shared/inbox/devices.ts:secret(id)andsetCarriage(id, on, at).packages/core/crypto.ts:encryptWithKey,decryptWithKey(the envelopecrypto.tsalready writes) andderiveRelayKeys(K and R, HKDF-SHA256 as the contract names them).packages/core/fixtures/inbox-relay-vectors.json: the vectors from all-zero inputs;packages/core/crypto.test.tschecks them.adr/implementation/inbox-mobile.mdsection 4: written in first (see Deviations)..github/workflows/relay.yml(new): the proof job.bunfig.toml:apps/relay/**inpathIgnorePatterns.package.json:tsc --noEmit -p apps/relay/tsconfig.jsonintypecheck..gitleaks.toml: the three derived vector values allowlisted, exact and path-anchored, with a disposition.tests/setup/feedback-archive-off.ts(the root preload),tests/helpers/inbox-world.tsandtests/e2e/inbox-phones.spec.tssetPLANNOTATOR_RELAY_URLto a closed loopback port.CLAUDE.md: Project Structure entry forapps/relay; the Inbox section (the relay under "Local only", "On a phone", "Phones", the tests); thePLANNOTATOR_RELAY_URLrow.Record sections made real
helloandcarriage(7.31), Push. Section 2: the redemption'srelay. Rulings 3 (the switch), 4 (questions and guided reviews only), 5 (no time sweep), 6 (the reused brake).Proofs and how to run them
cd apps/relay && PLANNOTATOR_INBOX_TEST_BINARY=<compiled plannotator> bun test(15 tests withRELAY_PROOF_APPLE=1; the relay workflow runs exactly this after compiling the binary). Underwrangler devon free ports with--persist-to apps/relay/.wrangler/proof-*, the compiled Inbox pointed at it, agents throughplannotator inbox mcp, and a local HTTP/2 server as Apple that verifies the provider token's ES256 signature against a key made at run time:SHA-256(R);itemsroute 404not_found;question: null; news none; a guided review one; a 5,600-character context goes without it and the body stays at or under 4096 bytes; no push with the switch off;SHA-256(R);no_apns_token(read from the Inbox's log);helloafter an Inbox restart registers a phone the relay lost and takes a switch flipped while the Inbox was stopped;device_not_found, its secret file is gone;no_apns_keytwice, logged once; the creation brake answers 429 withRetry-After: 60within 21 creates;403 InvalidProviderTokenover TLS and HTTP/2 (the transport works).bun test packages/core/crypto.test.ts: TypeScript derives K, R andSHA-256(R)from the vectors' zero inputs, opens every vector envelope, re-seals each with its own IV to the same bytes, and refuses a wrong key and a flipped byte. The same JSON is for M5's Swift test; HKDF was also checked by hand against Python's HMAC.research/operational/mobile-proofs/R1-2026-10-08/in the Workspaces meta repo (the test run and the three relays' logs).Gates run
bun run typecheck(with the newapps/relayentry): green.bun test packages apps/hook tests scriptsunder the preload's temp data dir: 5862 pass, 1459 skip, 0 fail (628 files),tests/package-bunfig.test.tsincluded (the newapps/relay/bunfig.tomlmirrors the root preloads).cd apps/relay && bun teston the compiled binary: 14 pass after the B1 fix (13 before it); 12 consecutive passes from source after the one flake found and fixed on the way (the Worker now reads a forwarded body whole: a Durable Object that refused before reading a streamed body left workerd an unread request stream, and the next request failed).bun run check:release-version: green (v0.28.8;apps/relayis private with its own line).bun run --cwd packages/ui smoke:package(core changed): green (ui 0.52.1 installs externally with core 0.25.12; no ui import of the new functions, so no bump).gitleaks git . --log-opts="origin/main..HEAD"): no leaks. Semgrep (the CI image and.semgrep.yml) on every changed file: 0 findings.The review's blocker, fixed (REVIEW-PLANNOTATOR-MOBILE-R1-2026-10-08, B1)
The Durable Object served
/createfromfetch, and the Worker forwarded every/v1/mailboxes/:mbx/<rest>, soPOST /v1/mailboxes/<mbx>/createwith no bearer replaced a mailbox's hash, and made mailboxes under ids the caller chose without the brake. Now the Worker checks the body and makes the mailbox through an RPC on the object it named with a fresh random id (stub.create(hash));fetchhas no create path, andcreaterefuses when a hash is already stored. The proof adds the two probes: a client/createon an existing mailbox and on a picked id both answer 404, the stored hash is unchanged, no mailbox appears under the picked id, and the attacker's bearer gets 401 on push and register.plannotator-ops's h2-client blocker, fixed
apps/relay/src/apns-h2.tshad no bound on what the peer could make it buffer: a frame length up to 16 MiB was accepted, the body and header block grew without limit, and every read re-copied the whole buffer. Now a frame over 16384 bytes (the default max frame size, which the client never raises) fails at once, an answer's header block or body over 8 KiB fails at once (Apple's answers are a few dozen bytes), and reads go into one buffer with a read offset. The proof adds a hostile peer in Apple's place: a DATA frame that declares 16 MiB and a flood of 64 KB of body each answer502 apns_failedin under 3 s, not at the 10 s timeout.Folded in from the same review: the abandoned exchange's rejection after a timeout is caught; a graceful GOAWAY (NO_ERROR, last stream id covering stream 1) keeps reading and the push answers (proved); the provider-token cache is keyed on team id and key id;
relay.ymlhas a concurrency group; the Apple test (sandbox and production) runs only withRELAY_PROOF_APPLE=1, which the workflow sets, so a localbun testworks offline; the collapse id isHMAC-SHA256(K, thread id)in hex, so the relay never sees a thread id, and the relay refuses a collapse id over Apple's 64 bytes (contract section 4 written in first, proved).What it leaves out
inbox_online, the socket'sitem,commandandappliedframes. Those routes answer404 not_founduntil then (contract section 4).wrangler secret put APNS_KEY,APNS_KEY_ID,APNS_TEAM_ID, thenwrangler deployfromapps/relay. Until the relay is deployed, a pairing answersrelay: null.403 ExpiredProviderTokenorInvalidProviderToken; clear it on those.apnsBodyexists in the Inbox and the relay with no test pinning them equal; add one.Deviations, each with its reason
404 not_found(the brief allowed stubs; a missing route is the plainest "not yet");404 not_foundand502 apns_failed(withapns_status,apns_reason) added to the error list, because the first draft named no answer for an unknown path or for Apple's other refusals;no_apns_keyas a push reason (the brief's); the hashes are of the bearer string's UTF-8 bytes (the draft did not say which bytes); a registration with another hash starts the device over; a new socket replaces the older one (close code 4000) and the reconnect delays; "dropsquestion.context" written as setting it to null, so Swift decodes one shape; the APNs sending details and the vectors' path.CF-Connecting-IP, open when it cannot resolve) under its ownnamespace_id(1002), so mailbox and guide creation do not share one count.APNS_ORIGIN, a Worker variable never set in a deploy, points the relay at the proof's local HTTP/2 server; the same client code runs, without TLS. The TLS path is proved against Apple itself.wrangler dev --persist-towrites, so the Worker has no test route or hook at all.notify.tsdecides a browser notification" is browser code; the server applies the same rule (an agent's new message whose questions are open at revision 0) at the message's first store line, plus guided reviews (ruling 4). "One push per burst" is one push per message per phone, as the contract writes it.helloalso removes a listed device the Inbox has no record of (the contract names revoked ones): the Inbox is the mailbox's only authority.PLANNOTATOR_RELAY_URLat a closed loopback port (root preload, test world, the phones spec), so no test run ever makes a mailbox on the hosted relay once it is deployed.Limits, named once
For plannotator-ops
One job,
.github/workflows/relay.yml, ubuntu-latest, no matrix, 15-minute bound: paths-gated onapps/relay/**,packages/server/inbox*,packages/shared/inbox/**,packages/core/crypto*,packages/core/fixtures/inbox-relay*,packages/core/inbox*, the binary entry and inbox CLI files,scripts/inbox-sim.ts,tests/helpers/inbox-world.ts,bun.lockand itself; on PRs to main and pushes to main. Steps: install, build review and hook and compile the binary (as inbox-e2e does),bun testinapps/relaywithPLANNOTATOR_INBOX_TEST_BINARY, upload the three relays' logs. It never deploys and needs no secret; it makes one outbound request to Apple's sandbox and production push hosts.