Skip to content

Inbox mobile: relay mailboxes and push (R1) - #1788

Merged
backnotprop merged 4 commits into
mainfrom
feat/mobile-r1-relay
Oct 8, 2026
Merged

backnotprop merged 4 commits into
mainfrom
feat/mobile-r1-relay

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

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's fetch cannot reach APNs (the spike's item 6).

What changed

  • apps/relay/ (new, private, version 0.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/mailboxes goes 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 with hello and carriage.
    • src/apns.ts: the ES256 provider token from the APNS_KEY secret (kept 50 minutes) and the push (apns-push-type: alert, priority 10, topic ai.plannotator.app, collapse id per thread, host by the token's environment). A 410 deletes the token; without the key a push answers no_apns_key and logs once.
    • src/apns-h2.ts: one HTTP/2 request over a connect() socket: preface, SETTINGS, one HEADERS frame (:method POST and :scheme from the static table, :path and :authority literals with a static name, every other header a literal without indexing, no dynamic table), one DATA frame; reads :status and the DATA body; one connection per push. No imports beyond cloudflare:sockets, so Workspaces can copy it.
    • wrangler.toml (the DO, the SQLite migration, the rate limit block, the relay.plannotator.ai custom 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 in inbox/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, default https://relay.plannotator.ai.
  • packages/server/inbox-devices.ts: the redemption answers relay: { 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) and setCarriage(id, on, at).
  • packages/core/crypto.ts: encryptWithKey, decryptWithKey (the envelope crypto.ts already writes) and deriveRelayKeys (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.ts checks them.
  • adr/implementation/inbox-mobile.md section 4: written in first (see Deviations).
  • .github/workflows/relay.yml (new): the proof job. bunfig.toml: apps/relay/** in pathIgnorePatterns. package.json: tsc --noEmit -p apps/relay/tsconfig.json in typecheck. .gitleaks.toml: the three derived vector values allowlisted, exact and path-anchored, with a disposition.
  • Tests never reach the hosted relay: tests/setup/feedback-archive-off.ts (the root preload), tests/helpers/inbox-world.ts and tests/e2e/inbox-phones.spec.ts set PLANNOTATOR_RELAY_URL to a closed loopback port.
  • CLAUDE.md: Project Structure entry for apps/relay; the Inbox section (the relay under "Local only", "On a phone", "Phones", the tests); the PLANNOTATOR_RELAY_URL row.

Record sections made real

  • PLAN step R1: mailbox at first pairing, devices by the relay secret's hash, the phone's APNs token, the encrypted summary with a collapse id per thread, the JWT from the Worker secret, 410 drops the token, the push where the browser notification fires, one per message, encrypt and decrypt in core.
  • Contract section 4: Keys, The envelope, What the relay stores and deletes (R1's part), Routes 7.26 to 7.30 and 7.35, the socket's hello and carriage (7.31), Push. Section 2: the redemption's relay. Rulings 3 (the switch), 4 (questions and guided reviews only), 5 (no time sweep), 6 (the reused brake).
  • Decisions: the relay on by default (carriage true at pairing), keys made at pairing and never at the relay, ciphertext only at the relay.

Proofs and how to run them

  • cd apps/relay && PLANNOTATOR_INBOX_TEST_BINARY=<compiled plannotator> bun test (15 tests with RELAY_PROOF_APPLE=1; the relay workflow runs exactly this after compiling the binary). Under wrangler dev on free ports with --persist-to apps/relay/.wrangler/proof-*, the compiled Inbox pointed at it, agents through plannotator 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:
    • a pairing makes the mailbox (relay.json 0600, its hash in the DO) and registers the phone by SHA-256(R);
    • a replayed registration changes nothing (cursor, switch and APNs token as they were); a wrong bearer is 401; an unknown mailbox 404; R2's items route 404 not_found;
    • the phone's relay switch reaches the device record over the socket, off and on;
    • one question is one push: headers, the generic alert, and an envelope the phone's K opens to the contract's summary; two questions one push with 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;
    • the DO's storage files hold no pairing secret, K, R, phone token, mailbox secret, subject or body text (UTF-8 and UTF-16), and do hold SHA-256(R);
    • Apple's 410 deletes the token and the next push answers no_apns_token (read from the Inbox's log);
    • hello after an Inbox restart registers a phone the relay lost and takes a switch flipped while the Inbox was stopped;
    • a revoked phone is gone from the DO, its relay secret answers 404 device_not_found, its secret file is gone;
    • a second relay without the key: no_apns_key twice, logged once; the creation brake answers 429 with Retry-After: 60 within 21 creates;
    • a third relay with a throwaway key and no override: Apple's real sandbox and production answer 403 InvalidProviderToken over TLS and HTTP/2 (the transport works).
    • Which Apple server: a local HTTP/2 server (cleartext, prior knowledge) for the behaviour, Apple itself for the TLS transport. The real sandbox send to the test iPhone waits on the owner's APNs key (OWNER-ITEMS item 3) and the device (item 5).
  • bun test packages/core/crypto.test.ts: TypeScript derives K, R and SHA-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.
  • Run records: 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 new apps/relay entry): green.
  • bun test packages apps/hook tests scripts under the preload's temp data dir: 5862 pass, 1459 skip, 0 fail (628 files), tests/package-bunfig.test.ts included (the new apps/relay/bunfig.toml mirrors the root preloads).
  • cd apps/relay && bun test on 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/relay is 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 on the range (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 /create from fetch, and the Worker forwarded every /v1/mailboxes/:mbx/<rest>, so POST /v1/mailboxes/<mbx>/create with 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)); fetch has no create path, and create refuses when a hash is already stored. The proof adds the two probes: a client /create on 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.ts had 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 answer 502 apns_failed in 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.yml has a concurrency group; the Apple test (sandbox and production) runs only with RELAY_PROOF_APPLE=1, which the workflow sets, so a local bun test works offline; the collapse id is HMAC-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

  • R2: the down items, the phone's fetch and ack, commands up, inbox_online, the socket's item, command and applied frames. Those routes answer 404 not_found until then (contract section 4).
  • The deploy (owner item 20) and the APNs key (owner item 3): wrangler secret put APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID, then wrangler deploy from apps/relay. Until the relay is deployed, a pairing answers relay: null.
  • A phone that paired while the relay could not be reached has no mailbox to use until it pairs again (the contract's shape: the relay arrives in the redemption's answer only). Named, not built.
  • Pairing through the relay, deleting a mailbox, any retention sweep, and any brake beyond the reused one (contract section 8).
  • The review's eight items, for a follow-up PR:
    • I1: a graceful GOAWAY (NO_ERROR, last stream id >= 1) is read as a failure; keep reading when it covers stream 1.
    • I2: a frame longer than 16384 bytes waits for the 10 s timeout; refuse it at once.
    • I3: the provider token cache is not dropped on 403 ExpiredProviderToken or InvalidProviderToken; clear it on those.
    • I4: the Inbox's relay call and the relay's Apple exchange both wait 10 s; make the Inbox wait longer (15 s) so it logs the relay's 502.
    • I5: apnsBody exists in the Inbox and the relay with no test pinning them equal; add one.
    • I6 (optional): share guides.show's limiter helper rather than the 10-line copy.
    • I7: on a socket answer of 401 or 404 (mailbox lost or replaced) the Inbox retries every 60 s forever and never makes a new mailbox; handle with R2 or name beside the "paired while unreachable" gap.
    • I8: the relay workflow reaches Apple's real hosts each run, so an Apple network blip turns it red (accepted risk).
    • I1 (graceful GOAWAY) and I2 (frames over 16 KiB) are now fixed here, from plannotator-ops's review.
  • For the R2 lane, from plannotator-ops's review:
    • Split K per direction (separate HKDF info strings for down and up), or bind direction, mailbox and device as AES-GCM associated data: as built, a hostile relay could reflect a down envelope up as a command.
    • Every command through the idempotency log, since the relay can replay one.
  • Before real traffic (boarded): one connection per push to APNs; a kept connection per isolate comes before volume.

Deviations, each with its reason

  1. The contract was written in first, in section 4: the R1 and R2 split, with R2's routes answering 404 not_found (the brief allowed stubs; a missing route is the plainest "not yet"); 404 not_found and 502 apns_failed (with apns_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_key as 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; "drops question.context" written as setting it to null, so Swift decodes one shape; the APNs sending details and the vectors' path.
  2. The brief says the phone's APNs token is "registered by the phone through the device door and forwarded". The contract (7.29) has the phone register it at the relay itself with its relay secret, and that is what is built: one way to do it, it works when the phone reaches only the relay, and the door needs no new route. Say the word and a door route can forward it instead.
  3. The rate limit block copies guides.show's rule (20 a minute per IP, period 60, keyed on CF-Connecting-IP, open when it cannot resolve) under its own namespace_id (1002), so mailbox and guide creation do not share one count.
  4. 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.
  5. The DO's storage is read from the SQLite files wrangler dev --persist-to writes, so the Worker has no test route or hook at all.
  6. "Where notify.ts decides 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.
  7. hello also removes a listed device the Inbox has no record of (the contract names revoked ones): the Inbox is the mailbox's only authority.
  8. Tests point PLANNOTATOR_RELAY_URL at 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

  • Mailbox creation: guides.show's rule, 20 a minute per IP (reused, no number of its own).
  • A call from the Inbox to the relay waits 10 s at most (a pairing waits on it); an HTTP/2 exchange with Apple 10 s.
  • The socket reconnects 1 s after it drops, doubling to 60 s.
  • Apple's own: 4096 bytes per push body, four actions; the provider token reused 50 minutes (Apple refuses renewal more often than every 20 minutes and expiry after 60).

For plannotator-ops

One job, .github/workflows/relay.yml, ubuntu-latest, no matrix, 15-minute bound: paths-gated on apps/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.lock and itself; on PRs to main and pushes to main. Steps: install, build review and hook and compile the binary (as inbox-e2e does), bun test in apps/relay with PLANNOTATOR_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.

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
@backnotprop
backnotprop merged commit 9883745 into main Oct 8, 2026
33 checks passed
@backnotprop
backnotprop deleted the feat/mobile-r1-relay branch October 8, 2026 15:17
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.

1 participant