Skip to content

feat: a watch reads its home at /v1/app/once in one HTTPS request - #13

Merged
frahlg merged 9 commits into
mainfrom
claude/cloud-once
Oct 14, 2026
Merged

frahlg merged 9 commits into
mainfrom
claude/cloud-once

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

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 one
request instead, as the protocol's "One-shot read" section defines
(srcfl/ftw#1627).

POST /v1/app/once?home=<id> (internal/appsession/once.go)

  • The body is Noise IK message 1 to the service's Noise key, prologue
    ftw.cloud.once.v1: and the home id. It carries one unsigned envelope:
    read {have, hz}, token.get, push.register or push.unregister.
    Anything else, an owner's suspend among them, and a signed envelope, get
    400 before message 2.
  • Admission is the app session's: store.For with the key the handshake
    proved, 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.
  • read answers with the app session's snapshot, less the envelopes have
    covers. What does not fit in message 2 follows as transport messages; each
    message comes after its u16 length. hz: 1 asks the box for one-second
    updates.
  • token.get returns 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.register takes app, iphone (the default) or watch, stored per
    registration. A watch's pushes go to the watch app's topic
    (-apns-watch-topic, default energy.ftw.app.watchkitapp), and an alert
    to 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.
  • The service keeps no session and no Noise state after the answer. The only
    state is the counts behind the limits.
  • Limits: one read at a time per device and 30 a minute, 16 at once per home,
    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.
  • Nothing logs a body, a home, a key or a token. A refusal logs its status at
    debug level.
  • Freshness: the box's envelopes go unchanged, so the watch grades them with
    the box's key and its own clock. lastSeenMs and online stay the
    service's unsigned word, and an hour without the box leaves no readings.

/v1/app/once is in server.Routes, so TestNoRouteReadsAHome covers it.
The registry gains an app column on push; an existing registry is
upgraded in place and its tokens stay the iPhone app's. go/protocol stays
on srcfl/ftw master 7c72a5b3 (moved in #11), which holds once.go.

Tests (the ADR's list for FTW Cloud)

  • Vectors. TestOnceAnswersTheGoldenVectors posts the request from
    cloud-once-vectors.json and gets the vectors' answer body back byte for
    byte. It reads the file from the pinned go/protocol, so the test follows
    the pin.
  • Routes. /v1/app/once is in server.Routes and passes
    TestNoRouteReadsAHome.
  • Admission. One table of 11 cases runs through /v1/app and
    /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.
  • Removal. A watch the roster drops is refused at once (well within a
    second of that roster arriving).
  • Token. A watch's token reads live, prices and hb and nothing else,
    and the owner's next token leaves it be.
  • Alerts. A watch and its phone get byte-identical alert payloads, each to
    its own topic.
  • Memory. After the answers, no read is counted, no home is held as
    looked at, and all 16 sessions of the home can still open.
  • End to end (internal/server/e2e_test.go). The service wired as
    ftwcloudd wires it, over TLS 1.3, with a Go box on the real uplink and a
    Go watch client: the first read verifies every envelope with the box key
    and grades live by cloudwire.View; hz: 1 reaches the box as watch;
    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; the
    box 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 down
    70 s later: the service cannot make data look fresher.
  • Have, spill into transport messages, the HTTP rules (method, Origin,
    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.
  • A fuzz test for message 1 (FuzzOnceMessage), now also in CI for 30 s.
    Locally: 161,000 runs, no failure.

Local: go vet (with and without ftwclouddev), go test -race ./...,
go test -tags ftwclouddev ./..., scripts/check-imports.sh, the arm64
build. 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.md now
tells the deployer to check.

Not done here

  • Nothing is deployed. On the host, deploy/noise.md has the new check. A
    topic-specific APNs key needs the watch's topics (deploy/apns.md).
  • The watch app's direct paths (ADR 0009 steps 8–10) are in srcfl/ftw-app.
  • The service is now about 4,100 lines of Go without tests, past the
    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:

One known property: anyone holding the plaintext body of a message 1 can
replay it. A replayed token.get rotates the token, and a replayed
push.unregister removes one. Only TLS 1.3 protects against this, as the
stateless design implies.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 14, 2026 14:25
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>
@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-14T12:47:37.338020Z 423f5b3 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.

…within the minute is asked again

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

@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: 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()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread internal/server/server.go
mux.HandleFunc("/v1/app/once", s.App.ServeOnce)
} else {
mux.HandleFunc("/v1/app", notYet)
mux.HandleFunc("/v1/app/once", notYet)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

frahlg and others added 4 commits October 14, 2026 15:02
…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>
@frahlg
frahlg changed the base branch from claude/cloud-widget-push to main October 14, 2026 13:10
@frahlg
frahlg merged commit 341bd65 into main Oct 14, 2026
2 checks passed
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