Repository navigation
feat(relay): the watch exchange - #85
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying with
|
| 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 |
There was a problem hiding this comment.
💡 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".
| res.end() | ||
| return true | ||
| } | ||
| if (!this.#exchangeAttempts.allow(clientAddress(req, this.#opts.trustProxy), this.#opts.now())) { |
There was a problem hiding this comment.
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>
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>
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>
…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>
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.
/r/<epoch>/<handle>/exchange. One per room; the first holds it, as with the uplink. A box that never opens it sees no change.POST /x/<epoch>/<handle>withcontent-type: application/octet-stream. The relay draws a random 16-byte id, sendsid ‖ bodydown 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.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./x/also takes the previous epoch.framesRoutedandbytesRouted, because in a small fleet a counter printed every 15 s would time each command.inspect()gains the open count, andhasExchangeand the open count per room./x/(log_skip).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, theftw.exchange.v1:prologue,hello_okin a 1 KiB bucket and lane 0 frames everywhere else, as the doc says Core will.tests/relay-blindness.test.tsruns every registry op through applied, rejected, unconfirmed and tick, then a read of Now. It searches every routed byte, every log line andinspect()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.noware 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.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 adaptincaddy:2-alpine(v2.11.7, the imagecompose.ymlpulls) turns/x/*intolog_skip: true. End to end, the relay behind that Caddy:/healthzand/fleeteach wrote an access line; the/x/request wrote none.Node 24, the image's base: the relay starts from
relay/srcas it stands.Two test fixes after the first CI run. CI found
nowtwice 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/fleetand/deadmantoday. URLSession sendscontent-lengthfor 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