Skip to content

feat: phones register for alerts over their app session (C3 + C4) - #7

Merged
frahlg merged 13 commits into
mainfrom
claude/cloud-c3c4-join
Oct 13, 2026
Merged

frahlg merged 13 commits into
mainfrom
claude/cloud-c3c4-join

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Joins C3 (#5, the app session) and C4 (#4, alerts through APNs): a phone now registers its APNs token over its own Noise session, and nobody needs the development route.

What changes

Commits: the merge of #4 into #5, which only resolves conflicts; the join (a1812f6); and the merge of main (2e88475, after #3 and #6).

Merging main

deploy/ keeps main's version: bootstrap.sh and deploy/README.md are main's. C4's APNs pieces stay: deploy/apns.sh, deploy/apns.md and the compose volume that mounts /run/ftw-cloud/apns. They install their own unit and write beside the service key, and do not change main's units. The -apns-dir line goes, since the flag's default is the same path. On a host without the APNs key, the service runs and sends no alerts. /v1/app stays off in production until the Noise key is installed beside the service key, as #5 says; deploy/ does not install it yet.

server_test.go keeps main's /source test and C3's socket tests. The socket test covers /v1/box too, so main's uplink-only test goes.

Stack

It contains #5 and #4; merging it lands both. Pairs: srcfl/ftw#1607 and srcfl/ftw-app#20. The pin is a9e6f66f on srcfl/ftw claude/cloud-c3c4-core. Once #1607 is on master, the pin can move to master's commit; the code does not change.

Checks

  • go vet ./... && go test -race ./... pass.
  • go test -tags ftwclouddev ./... passes.
  • bash scripts/check-imports.sh passes (only go/protocol, 3 packages).
  • New tests: a viewer's push.register leaves its session open and registers nothing, an owner's registers, push.unregister removes it, and an unreadable body closes the session. Without Push, the session goes on. A suspended device gets no event and no box.unreachable, and gets both again after the suspension lifts. The suspension test fails without the filter.
  • Size: non-test Go is now 3,229 lines, over the 3,000 budget. C3 and C4 bring nearly all of it; this PR adds about 45.

End to end (13 Oct, 10:15–10:21, this laptop)

Core from srcfl/ftw#1607. ftwcloudd from this branch, built without the development tag, so it has no development routes. The app from srcfl/ftw-app#20 in its own iOS 26.5 simulator. Apple's sandbox, with the key read from Parameter Store and deleted afterwards.

  1. I paired the simulator with a pairing link and turned FTW Cloud on with the consent screen.
  2. At the next hello the app showed "Live via FTW Cloud". It wrote cloud-<home> to the App Group (box key, names, rule U offset) and asked to allow notifications.
  3. I allowed at 10:18:13.373. Apple gave a token. In the same second the registry held one row: device KyrRmCeC (the phone that granted), epoch 1, sandbox, alert, 80 bytes.
  4. I turned on charging.connected and plugged in the simulated charger. Core sent the event at 10:20:57.899. The service sent it to Apple at 10:20:58.389 (1 phone, 0 failed). FTWNotify logged push: event charging.connected at 10:20:58.602, 703 ms after Core. The lock screen showed the extension's own words: "Car plugged in. Open to see charging status, check the battery level and set a ready time."

Screenshots are in the session notes, not in the repo.

🤖 Generated with Claude Code

frahlg and others added 12 commits October 13, 2026 07:50
A phone runs Noise IK to the service's Noise key with the home id in the
prologue. The service admits it only through store.For with the key the
handshake proved, so only a phone in the box's signed roster that no
owner has suspended gets in. Message 2 carries the snapshot from memory;
after it each new box envelope the device may read follows on the same
socket, unchanged.

A roster change, a suspension, an expired roster or a purge closes the
sessions it touches at once. While a phone looks, the service asks the
box for one-second updates and renews the ask every 50 s. Limits per
home: 16 sessions, one per device, a queue of 256 per phone and a small
message rate. Owners' suspensions live in the registry and end when the
roster drops the device or its epoch moves.

go/protocol moves to srcfl/ftw claude/cloud-c3-core (7490c64c) for the
app session wire, the Noise initiator and RemoteStatic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box's signed event now reaches its phones through Apple, wrapped
unchanged; the phone checks the signature, writes its own words and picks
the buttons from the verified kind. The service chooses none of that.

- internal/apns: HTTP/2 to Apple's provider API with an ES256 token from
  the team's .p8, renewed every 40 minutes and once on
  ExpiredProviderToken. 410, BadDeviceToken and DeviceTokenNotForTopic
  mark a token gone. Every alert has the same push type and priority and
  no collapse id.
- internal/registry: a push table bound to roster entries (home, device
  id, epoch), cascading with the home on purge and expiry, and expiring
  30 days after the box's last roster.
- internal/push: registration (only devices whose effective scope reads
  events), roster pruning, fan-out with an hourly cap per home, and the
  dead man's switch: box.unreachable after ten minutes away, held back for
  an hour after bye{why:"update"}, never for a revoke.
- uplink: OnConnect (as C3 has it), OnEvent, OnRoster and OnGone hooks.
- ftwcloudd: -apns-dir; without a key nothing is sent. Development builds
  get -dev-push-listen to register a token by hand before the app session
  can.
- deploy: apns.sh reads the key from Parameter Store into tmpfs at boot;
  the host role can already read it. apns.md is the owner's steps.

Pins go/protocol at srcfl/ftw 8cca4203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-dev-apns-capture <dir> writes each alert as JSON instead of sending it to
Apple, so an end-to-end test can hand it to a simulator with xcrun simctl
push before an APNs key exists. Release builds refuse the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/v1/app answers 501 until a Noise key is installed, so deploying this
before the deploy files learn the second secret cannot stop the service.
The deploy files stay as they are: #3 owns them, and the
Noise key goes in beside the service key once it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither line names the home.

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

Like the service key's fetch in #3: at boot the network and the instance's
credentials may lag, so a read is tried for a minute. A parameter that does
not exist ends it at once, so a host without a key does not hold Docker back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /dev/push/raw on the loopback dev listener sends a payload unchanged
to a home's registered phones, so the end-to-end test can put a forged
alert through Apple's sandbox and watch the phone's extension refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joins #4 and #5. Both stay: C3's PruneSuspensions and
C4's OnRoster in uplink's roster(), and OnConnect now reaches the app
sessions and the dead man's switch. go/protocol moves to a9e6f66f on
srcfl/ftw claude/cloud-c3c4-core, which holds cloudwire/app.go (#1603)
and cloudwire/push.go (#1602).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
push.register and push.unregister arrive on a phone's Noise session and
go to the push service with the roster entry and scope store.For gave
that session, never anything from the message. A registration the device
may not make, such as a viewer's, registers nothing and the session goes
on; one the service cannot read closes it as a protocol error. Without a
push service both are ignored.

A device an owner has suspended keeps its tokens but gets no alerts,
box.unreachable included, until the suspension ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deploy/ keeps main's version. C4's APNs pieces stay: deploy/apns.sh and
apns.md, and the compose volume that mounts /run/ftw-cloud/apns. The
-apns-dir line goes, since the flag's default is the same path.
server_test.go keeps main's /source test and C3's socket tests; the
sockets test covers /v1/box, so main's uplink-only test goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 08:33
@frahlg
frahlg merged commit c946842 into main Oct 13, 2026
2 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 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-13T08:38:31.741600Z 2e88475 Draft marked ready
ℹ️ 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.

@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: 2e88475357

ℹ️ 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 cmd/ftwcloudd/main.go
Comment on lines +125 to +127
if noiseOn {
srv.App = appsession.New(st, reg, up, noise, log)
srv.App.Push = pushSvc

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 Restore persisted rosters before serving app sessions

After any service restart, store.New contains no homes or rosters, yet the app server is immediately wired to that empty store. When a box reconnects, uplink.rosterSeq() advertises the roster sequence persisted in the registry, so the box need not resend it, while store.For continues denying every phone because no code restores Home.Roster into memory. Consequently all app sessions can remain unavailable until the box's next periodic roster renewal; hydrate the store from the persisted signed roster before advertising/serving it.

Useful? React with 👍 / 👎.

Comment thread internal/push/push.go
Comment on lines +147 to +148
return s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch,
Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.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 Revalidate access when persisting a push token

A roster update can close a removed device's session and finish PushPrune, after which that session's concurrent read goroutine can still call Register with the stale dev and scopes captured when it connected. This insert then survives until another roster arrives, and send filters only suspensions, so the removed device can continue receiving events. Registration needs to be checked against the current roster/epoch atomically with pruning rather than trusting the session's cached handle.

Useful? React with 👍 / 👎.

Comment thread internal/uplink/uplink.go
Comment on lines +224 to +226
if !c.purged && c.u.OnConnect != nil {
go c.u.OnConnect(c.home)
}

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 Order the connect callback before a connection can be declared gone

If the socket closes immediately after hello_ok, the deferred OnGone can run before this newly spawned goroutine. The delayed OnConnect then marks the already-disconnected home online and cancels the outage timer, so no box.unreachable alert is emitted until another connection cycle occurs. Invoke the state transition synchronously or associate it with a connection generation so stale connect work cannot override a later disconnect.

Useful? React with 👍 / 👎.

Comment thread internal/push/push.go
Comment on lines +96 to +100
return &Service{
reg: reg, sender: sender, log: log, now: time.Now,
after: func(d time.Duration, f func()) func() bool { return time.AfterFunc(d, f).Stop },
work: make(chan func(context.Context), queueSize),
homes: map[string]*homeState{},

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 Seed outage tracking from persisted registrations

On process startup, the dead-man state is always empty and Run only handles queued jobs and registration expiry. If a registered box was already offline and never reconnects after a deployment, this process receives neither BoxConnected nor BoxGone, so it never starts the ten-minute timer and the promised box.unreachable alert is never sent. Persisted registrations need to seed an offline state/timer at startup, with a reconnect canceling it.

Useful? React with 👍 / 👎.

Comment on lines +318 to +322
func (c *phoneConn) stopLooking() {
c.setLooking(false)
c.lookMu.Lock()
c.ended = true
c.lookMu.Unlock()

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 Mark a session ended while clearing its looking state

When a store-driven close races with an incoming watch message, the read goroutine can call setLooking(true) after stopLooking has cleared the flag but before it sets ended. That increments the home's viewer count and starts the renewal loop, after which ended prevents any later cleanup, leaving the box permanently requested for one-second updates. Set ended and remove the current looking contribution in one critical section.

Useful? React with 👍 / 👎.

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