Repository navigation
feat: a watch reads its home at /v1/app/once in one HTTPS request - #13
Conversation
A device asks for its extension token over its app session with token.get. GET /v1/now with that token returns the newest live, prices and hb as the box signed them, and nothing else. The registry keeps the token's SHA-256, never the token, bound to one roster entry. A read reaches the home only through store.For with a new Token principal, so the token stops working when the roster drops the device, moves its epoch or runs out, an owner suspends the device, or the home is purged. Moves the go/protocol pin to srcfl/ftw master 7c72a5b3, which holds cloudwire's token.get and token types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A widget registers its push token with push.register {kind: widgets},
which needs ftw.live.read. When a box's priced hours reach further than
before, as when tomorrow's prices arrive, or a car is plugged in, the
service sends Apple's widgets push: no data, only content-changed, to
the app's .push-type.widgets topic. The widgets then read /v1/now with
their token. At most 4 an hour per home, since Apple budgets reloads.
The uplink's OnEvent hook becomes OnHeld, which hands push every held
envelope with its class. A roster prunes each kind by the scope it
needs. The APNs client takes the push type and the app's bundle id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
watchOS gives an app HTTPS and nothing lower, so a watch cannot open
/v1/app. POST /v1/app/once?home=<id> carries Noise IK message 1 with the
prologue ftw.cloud.once.v1: and the home id, and one unsigned envelope:
read {have, hz}, token.get, push.register or push.unregister. The
service admits the device as /v1/app admits a phone, through store.For
with the key the handshake proved, and answers with message 2 and, for
a read, the envelopes that did not fit, each after its u16 length. It
keeps no session and no Noise state after the answer.
- read: the app session's snapshot less what have covers; hz 1 asks the
box for one-second updates.
- token.get: the watch's own token, as on the app session.
- push.register takes app iphone or watch; a watch's pushes go to the
watch app's topic, with the same alert payload as its phone's.
- Limits: one read at a time per device and 30 a minute, 16 at once per
home, 64 at once in the process, message 1 at most 4 KiB, the answer
within 10 s. 400, 403, 429 and 501 have empty bodies.
The service answers srcfl/ftw's cloud-once-vectors.json byte for byte,
read from the pinned go/protocol. An end-to-end test runs a box on the
uplink and a Go watch client over TLS, and a fuzz test covers message 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. |
…within the minute is asked again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 423f5b37c6
ℹ️ 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".
| if err != nil || len(env.Sig) != 0 || !slices.Contains(cloudwire.OnceMessages, env.T) { | ||
| return nil, http.StatusBadRequest | ||
| } | ||
| dev, err := s.store.For(store.Principal{Kind: store.Device, Home: home, DeviceKey: hs.RemoteStatic()}) |
There was a problem hiding this comment.
Revalidate device access before executing one-shot requests
When a roster removal or suspension lands after this store.For call but before onceReply, the retained Handle still exposes the old device and scopes: Snapshot, Device, and Scopes do not repeat the access check as Handle.Open does. A concurrent read can therefore return data after revocation, and, more seriously, push.register can insert a stale registration after OnRoster has already pruned registrations, allowing the removed device to continue receiving alerts until a later roster refresh. Revalidate the handle under the home's lock immediately before performing the requested action.
Useful? React with 👍 / 👎.
| mux.HandleFunc("/v1/app/once", s.App.ServeOnce) | ||
| } else { | ||
| mux.HandleFunc("/v1/app", notYet) | ||
| mux.HandleFunc("/v1/app/once", notYet) |
There was a problem hiding this comment.
Return an empty body for the one-shot 501 response
When the service starts without a Noise key (s.App == nil), this maps /v1/app/once to notYet, whose http.Error writes not available yet\n. The new one-shot endpoint explicitly requires refusal responses, including the no-key 501 case, to have empty bodies, so a client validating that response shape receives a protocol-invalid refusal. Use a one-shot-specific handler that writes only status 501.
Useful? React with 👍 / 👎.
…evice's access From review: - suspend ended the suspended device's session only after dropping its token, so a registry error left the session open. Recheck now runs first. - A token.get that store.For admitted just before a suspension could store a token after the suspension dropped it. Issue now checks the device's access again once the row is stored, and drops the row if it is gone. - TokenPrune deleted by device id alone, so a token the device got at its new epoch meanwhile went too. It now matches the epoch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly its kind From review: - Widgets need only ftw.live.read, so a viewer may now register, and nothing capped how many tokens a device held. One push then called Apple once per row on the single send goroutine, holding up other homes' alerts. A device now keeps its newest 4 tokens per kind. - PushGone deleted every registration of a token, so Apple refusing the widgets topic also deleted the same token's alert registration. It now deletes only the kind Apple refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssion gets 403 Merges claude/cloud-widget-push. Register now stores the app and trims the device's tokens of that kind. From review: token.get over the one-shot read answered 500 when Issue found the device shut out after admission; it is a refusal, 403. The README no longer says the service checks the watch role: it checks none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #12, which is stacked on #11. Merge those first; this PR then
shows only its own two commits.
What
Milestone 7 of ADR 0009:
FTW Cloud's half of a watch that works on its own over LTE. watchOS gives an
app HTTPS and nothing lower, so a watch cannot open
/v1/app. It reads in onerequest instead, as the protocol's "One-shot read" section defines
(srcfl/ftw#1627).
POST /v1/app/once?home=<id>(internal/appsession/once.go)ftw.cloud.once.v1:and the home id. It carries one unsigned envelope:read {have, hz},token.get,push.registerorpush.unregister.Anything else, an owner's
suspendamong them, and a signed envelope, get400 before message 2.
store.Forwith the key the handshakeproved, so the key must be in the box's roster (where the box lists a
watch with role
watch), the roster in force, the device not suspended.The service holds no role table. A refusal is 403 with an empty body.
readanswers with the app session's snapshot, less the envelopeshavecovers. What does not fit in message 2 follows as transport messages; each
message comes after its u16 length.
hz: 1asks the box for one-secondupdates.
token.getreturns the watch's own token (feat: widgets read /v1/now with a token bound to a roster entry #11), bound to its roster entry.push.registertakesapp,iphone(the default) orwatch, stored perregistration. A watch's pushes go to the watch app's topic
(
-apns-watch-topic, defaultenergy.ftw.app.watchkitapp), and an alertto a watch is the same payload, byte for byte, as to its phone. Its widget
pushes go to
energy.ftw.app.watchkitapp.push-type.widgets.state is the counts behind the limits.
64 at once in the process (counted after the body is in), message 1 at most
4 KiB, the answer within 10 s. Past a limit 429; without a Noise key 501;
all with empty bodies.
debug level.
the box's key and its own clock.
lastSeenMsandonlinestay theservice's unsigned word, and an hour without the box leaves no readings.
/v1/app/onceis inserver.Routes, soTestNoRouteReadsAHomecovers it.The registry gains an
appcolumn onpush; an existing registry isupgraded in place and its tokens stay the iPhone app's.
go/protocolstayson srcfl/ftw master 7c72a5b3 (moved in #11), which holds
once.go.Tests (the ADR's list for FTW Cloud)
TestOnceAnswersTheGoldenVectorsposts the request fromcloud-once-vectors.jsonand gets the vectors' answer body back byte forbyte. It reads the file from the pinned
go/protocol, so the test followsthe pin.
/v1/app/onceis inserver.Routesand passesTestNoRouteReadsAHome./v1/appand/v1/app/once: owner, viewer and watch get in; a key the roster lacks,another service key, another home in the prologue, a home the service
lacks, a suspended watch, a watch the roster dropped, a roster that ran out
and a purged home are refused by both.
second of that roster arriving).
live,pricesandhband nothing else,and the owner's next token leaves it be.
its own topic.
looked at, and all 16 sessions of the home can still open.
internal/server/e2e_test.go). The service wired asftwclouddwires it, over TLS 1.3, with a Go box on the real uplink and aGo watch client: the first read verifies every envelope with the box key
and grades
livebycloudwire.View;hz: 1reaches the box aswatch;the second read carries only what is new; the watch's token reads
/v1/now; an event reaches the watch and its phone with one payload; thebox drops the watch and its reads, token and alerts end. A second test
closes the box and shows the watch's view of the same bytes going
down70 s later: the service cannot make data look fresher.
content type, empty and oversized bodies, garbage handshakes), the rate and
in-flight limits, hz, the hour without the box, push registrations over the
read, and no logs of bodies, homes, keys or tokens.
FuzzOnceMessage), now also in CI for 30 s.Locally: 161,000 runs, no failure.
Local:
go vet(with and withoutftwclouddev),go test -race ./...,go test -tags ftwclouddev ./...,scripts/check-imports.sh, the arm64build. The built binary, run locally with a Noise key, answers a POST of
random bytes with 403, and with 501 without one, as
deploy/noise.mdnowtells the deployer to check.
Not done here
deploy/noise.mdhas the new check. Atopic-specific APNs key needs the watch's topics (
deploy/apns.md).3,000-line budget in the guide.
After review (a209d60)
An independent review found no admission bypass, limit leak or logged
secret here. It found these:
token.getanswered 500 when the device was shut out between admissionand the stored token (the race fixed in feat: widgets read /v1/now with a token bound to a roster entry #11). It now answers 403.
role, as the protocol says, and the README now says so.
One known property: anyone holding the plaintext body of a message 1 can
replay it. A replayed
token.getrotates the token, and a replayedpush.unregisterremoves one. Only TLS 1.3 protects against this, as thestateless design implies.
🤖 Generated with Claude Code