Skip to content

feat(relay): the watch exchange - #85

Merged
frahlg merged 3 commits into
mainfrom
feat/relay-exchange
Oct 14, 2026
Merged

frahlg merged 3 commits into
mainfrom
feat/relay-exchange

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Step 4 of srcfl/ftw ADR 0009, accepted 14 October 2026. The wire is in docs/watch-exchange.md (#83).

watchOS gives an app HTTPS and nothing lower, so a watch cannot hold a stream. This is the relay's half of the one-shot exchange.

  • The box's second socket, /r/<epoch>/<handle>/exchange. One per room; the first holds it, as with the uplink. A box that never opens it sees no change.
  • The watch's request, POST /x/<epoch>/<handle> with content-type: application/octet-stream. The relay draws a random 16-byte id, sends id ‖ body down the exchange socket, and returns the answer that comes back behind the same id. It reads that id and nothing else, and drops an answer whose id it is not waiting for.
  • Apart from the streams. An exchange never takes one of the four stream places, no stream sees one, and the exchange socket gets no stream frame and no control word.
  • The limits from the doc, in relay/src/limits.ts, none keeping a list: 30 requests a minute per address, 2 open and 30 a minute per room, 1 024 open in the process, a body of exactly 608 or 536 bytes in and at most 4 KiB back, 10 s per exchange.
  • The epoch. 409 carries the right one. During a rotation's spread /x/ also takes the previous epoch.
  • Statuses only: 200, 400, 404, 405, 409, 429, 501 and 504. No CORS header, ever.
  • No record. Exchanges stay out of the log line and out of framesRouted and bytesRouted, because in a small fleet a counter printed every 15 s would time each command. inspect() gains the open count, and hasExchange and the open count per room.
  • Caddy writes no access log line for /x/ (log_skip).
  • relay/README.md gains the claim that the relay reads only its own id on the exchange socket, and lists what it still knows while a request is open.

Not here: the deploy, which the main session runs after merge, and Core's side (steps 1, 3 and 5 in srcfl/ftw).

Tests

Tests 1–5 from the paired doc. They run against a stand-in box and watch in tests/support/exchange-harness.ts, which use this repository's Noise, the ftw.exchange.v1: prologue, hello_ok in a 1 KiB bucket and lane 0 frames everywhere else, as the doc says Core will.

  1. Blindness. tests/relay-blindness.test.ts runs every registry op through applied, rejected, unconfirmed and tick, then a read of Now. It searches every routed byte, every log line and inspect() for 25 words (types, ops, outcomes, the charger, the pairing code) and the house's readings, and finds none. The control first shows that the same exchanges, unsealed, trip all 25.
  2. Routing. A request reaches the box byte for byte behind a fresh id, and the answer comes back byte for byte. Two requests answered in reverse order each get their own answer. Answers with an unknown id, another room's id or an id already settled go nowhere. Four streams, an uplink and the exchange socket in one room: the exchange took no stream place, a fifth stream is still refused, no stream saw the exchange, and the exchange socket saw no stream frame and no word. A second exchange socket is refused. 404 with no box, 501 with an old box, 504 at once when the exchange socket goes.
  3. Shape. X1 is 608 up and 1 072 down. Every op, with each of the four outcomes, and now are all 536 up and 1 072 down. Bodies of 0, 1, 16, 535, 537, 607, 609, 1 072, 4 096 and 65 536 bytes get 400 and never reach the box.
  4. Limits. Each reached and shown to refuse: 30 per address, 2 open per room, 30 a minute per room, the process cap and the timeout. The last two run through options, with the defaults asserted. A watch that hangs up is forgotten at once.
  5. Epoch. 409 carries the right epoch. With a fake clock: the previous epoch works during the spread, the exchange socket closes with its room (4410), and after the spread the previous epoch gets 409.

Each of these breaks fails its test: dropping the previous-epoch rule, allowing three open per room, sending uplink frames to the exchange socket, answering 404 for an old box, and logging the handle on an exchange.

Caddy. caddy adapt in caddy:2-alpine (v2.11.7, the image compose.yml pulls) turns /x/* into log_skip: true. End to end, the relay behind that Caddy: /healthz and /fleet each wrote an access line; the /x/ request wrote none.

Node 24, the image's base: the relay starts from relay/src as it stands.

npx vitest run relay/ tests/relay-*.test.ts   12 files, 89 passed
npm run verify                                 108 files, 1098 passed, 1 skipped; types and build clean
exchange and blindness test files, 20 runs     20 passed

Two test fixes after the first CI run. CI found now twice in the exchange blindness dump. The dump held each ciphertext twice, once on the watch's side and once on the box's, so one chance match of a three-byte word counted as the two a short word needs. It now holds each message once, and the test checks the box saw no bytes the watch did not. And because the relay and its peers share the test process, a stall longer than one heartbeat got a socket reaped; the exchange tests now beat every 500 ms or 1 s, as the routing tests already do.

Known gap. A body past 608 bytes sent chunked, with no content-length, ends the connection instead of getting 400, as on /fleet and /deadman today. URLSession sends content-length for a body it holds, and that case gets 400.

Open PR on the same file. #50, a draft last updated on 16 August, also edits relay/src/server.ts, in other places. Whichever lands second rebases.

🤖 Generated with Claude Code

ADR 0009 step 4. watchOS gives an app HTTPS and nothing lower, so a
watch cannot hold a stream. The box now may open a second socket,
/r/<epoch>/<handle>/exchange, and a watch posts one sealed message to
POST /x/<epoch>/<handle>. The relay draws a random 16-byte id, sends
id ‖ body down the exchange socket and returns the answer that comes
back behind the same id. It reads that id and nothing else.

- One exchange socket per room; the first holds it. It never takes one
  of the four stream places, no stream sees an exchange, and the
  exchange socket gets no stream frame and no control word.
- The limits in docs/watch-exchange.md, none keeping a list: 30
  requests a minute per address, 2 open and 30 a minute per room,
  1 024 open in the process, bodies of exactly 608 or 536 bytes in and
  at most 4 KiB back, 10 s per exchange.
- 409 carries the right epoch. During a rotation's spread /x/ also
  takes the previous epoch.
- Exchanges stay out of the log line and the routed totals, so a small
  fleet's counters cannot time a command.
- Caddy writes no access log line for /x/.
- relay/README.md: the new claim and what the relay still knows.

Tests 1-5 from the paired doc: the blindness test gains exchanges, and
tests/relay-exchange.test.ts covers routing, shape, limits and the
epoch against a stand-in box and watch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T08:39:30.786531Z aa3d6c9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
ftw-webapp 01d43fb Commit Preview URL

Branch Preview URL
Oct 14 2026, 09:15 AM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa3d6c9319

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread relay/src/server.ts
res.end()
return true
}
if (!this.#exchangeAttempts.allow(clientAddress(req, this.#opts.trustProxy), this.#opts.now())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate request shape before spending the exchange quota

A cross-origin page can issue browser-simple POST requests with text/plain (or no content type) to any /x/... path without a CORS preflight. Although those requests are later rejected, this call consumes the victim address's quota first, so a page can continuously send 30 invalid requests per minute and make the legitimate watch behind the same NAT receive 429 responses indefinitely. Check the path and require application/octet-stream before charging the per-address exchange counter.

Useful? React with 👍 / 👎.

CI found "now" twice in the exchange blindness dump. The dump held each
ciphertext twice, once as the watch sent or got it and once as the box
got or sent it behind the relay's id, so one chance match of a
three-byte word in random bytes counted as the two a short word needs.
The dump now holds each message once and the ids, and the test checks
that the box saw no bytes the watch did not.

The relay and its peers share the test process, so a stall longer than
one heartbeat leaves a pong unread and the relay reaps the socket, and
the exchange on it. The exchange blindness test beats once a second and
the rotation test every 500 ms, which a busy machine rides out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 90067dd into main Oct 14, 2026
3 checks passed
frahlg added a commit to srcfl/ftw that referenced this pull request Oct 14, 2026
ADR 0009 milestone 5, decisions 1, 2 and 6. Paired with
srcfl/ftw-webapp#85, the relay's half.

The uplink keeps a second socket, /r/<epoch>/<handle>/exchange, beside
each connection, under the same handle and with its own backoff. The
relay hands it id(16) ‖ Noise message per watch request; the box writes
id ‖ answer back and reads nothing into the id.

Watch sessions live in a table of eight, apart from the phones' four,
and end after four exchanges, a minute idle or three minutes. At most 60
handshakes a minute. The handshake is Noise IK with prologue
ftw.exchange.v1: and the box's key; message 1 carries the watch's hello
with its pairing code in hello.code, and message 2 an ExchangeHelloOK in
the 1 KiB bulk bucket with rev and, while it fits, cloud.

appproto.Config.Exchange makes a handler that takes hello, cmd and now,
answers everything else E_UNKNOWN_OP, never streams and pushes no plan.
Every answer is two lane 0 frames padded with ticks, so a read, a
command and a refusal have one length; an unsettled result leaves a tick
and asking again returns it. now carries the frozen fields, source ages
keyed by fields 7 to 9, and each charger's plug state, level and power.

The box now says cmd.exchange. Golden vectors for every step are in
appuplink/testdata/exchange-vectors.json. An end-to-end test runs
against a real relay when FTW_RELAY_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit to srcfl/ftw that referenced this pull request Oct 14, 2026
ADR 0009 milestone 5, decisions 1, 2 and 6. Paired with
srcfl/ftw-webapp#85, the relay's half.

The uplink keeps a second socket, /r/<epoch>/<handle>/exchange, beside
each connection, under the same handle and with its own backoff. The
relay hands it id(16) ‖ Noise message per watch request; the box writes
id ‖ answer back and reads nothing into the id.

Watch sessions live in a table of eight, apart from the phones' four,
and end after four exchanges, a minute idle or three minutes. At most 60
handshakes a minute. The handshake is Noise IK with prologue
ftw.exchange.v1: and the box's key; message 1 carries the watch's hello
with its pairing code in hello.code, and message 2 an ExchangeHelloOK in
the 1 KiB bulk bucket with rev and, while it fits, cloud.

appproto.Config.Exchange makes a handler that takes hello, cmd and now,
answers everything else E_UNKNOWN_OP, never streams and pushes no plan.
Every answer is two lane 0 frames padded with ticks, so a read, a
command and a refusal have one length; an unsettled result leaves a tick
and asking again returns it. now carries the frozen fields, source ages
keyed by fields 7 to 9, and each charger's plug state, level and power.

The box now says cmd.exchange. Golden vectors for every step are in
appuplink/testdata/exchange-vectors.json. An end-to-end test runs
against a real relay when FTW_RELAY_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit to srcfl/ftw that referenced this pull request Oct 14, 2026
…ox (#1626)

* feat(appuplink): the watch exchange, a watch's one-shot door to the box

ADR 0009 milestone 5, decisions 1, 2 and 6. Paired with
srcfl/ftw-webapp#85, the relay's half.

The uplink keeps a second socket, /r/<epoch>/<handle>/exchange, beside
each connection, under the same handle and with its own backoff. The
relay hands it id(16) ‖ Noise message per watch request; the box writes
id ‖ answer back and reads nothing into the id.

Watch sessions live in a table of eight, apart from the phones' four,
and end after four exchanges, a minute idle or three minutes. At most 60
handshakes a minute. The handshake is Noise IK with prologue
ftw.exchange.v1: and the box's key; message 1 carries the watch's hello
with its pairing code in hello.code, and message 2 an ExchangeHelloOK in
the 1 KiB bulk bucket with rev and, while it fits, cloud.

appproto.Config.Exchange makes a handler that takes hello, cmd and now,
answers everything else E_UNKNOWN_OP, never streams and pushes no plan.
Every answer is two lane 0 frames padded with ticks, so a read, a
command and a refusal have one length; an unsettled result leaves a tick
and asking again returns it. now carries the frozen fields, source ages
keyed by fields 7 to 9, and each charger's plug state, level and power.

The box now says cmd.exchange. Golden vectors for every step are in
appuplink/testdata/exchange-vectors.json. An end-to-end test runs
against a real relay when FTW_RELAY_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* feat(appuplink): hold an exchange command's answer for 400 ms

ADR 0009 decision F, settled by Fredrik on 14 October 2026. The timing
test showed the gap: a refusal answered at once and an applied charge
after its EV save, so the time to answer told the relay which.

The box now answers a watch's cmd no sooner than 400 ms after it
arrived, applied or refused. A command that takes longer answers when it
is done and waits no more; reads and the handshake are not held. New
wires the floor; a table built by hand in a test has none.

Measured again with the floor, through the relay on ftw-webapp main and
at the box: applied and refused both answer in about 402 ms for any EV
save under 400 ms, and a 1 s save answers in 1 s.

ADR 0009 records the decision and its date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* docs(adr): ADR 0009 names milestone 6 as built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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