Repository navigation
feat: phones register for alerts over their app session (C3 + C4) - #7
Conversation
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>
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. |
There was a problem hiding this comment.
💡 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".
| if noiseOn { | ||
| srv.App = appsession.New(st, reg, up, noise, log) | ||
| srv.App.Push = pushSvc |
There was a problem hiding this comment.
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 👍 / 👎.
| 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()}) |
There was a problem hiding this comment.
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 👍 / 👎.
| if !c.purged && c.u.OnConnect != nil { | ||
| go c.u.OnConnect(c.home) | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
| 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{}, |
There was a problem hiding this comment.
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 👍 / 👎.
| func (c *phoneConn) stopLooking() { | ||
| c.setLooking(false) | ||
| c.lookMu.Lock() | ||
| c.ended = true | ||
| c.lookMu.Unlock() |
There was a problem hiding this comment.
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 👍 / 👎.
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
internal/appsession.Servergets aPushfield (Pusher:RegisterandUnregister; nil ignores push).phoneConn.applyhandlespush.registerandpush.unregisterand passes onc.dev.Device()andc.dev.Scopes(). These come fromstore.For, never from the message.push.ErrNotForThisDeviceleaves the session open, so a viewer registers nothing and keeps reading. A body the service cannot read closes the session with 4400.internal/push. A device that an owner has suspended keeps its tokens but gets no alerts until the suspension ends. That includesbox.unreachable.cmd/ftwcloudd.app.Push = pushSvc, andOnConnectcalls both the app sessions and the dead man's switch. In uplink'sroster(), C3'sPruneSuspensionsand C4'sOnRosterboth stay.go/protocolmoves to a9e6f66f on srcfl/ftwclaude/cloud-c3c4-core(feat(cloudwire): join FTW Cloud C3 and C4; alerts dated by rule U ftw#1607). That commit holdscloudwire/app.gofrom feat(cloud): FTW Cloud C3: app session wire, K11 session info, statistics consent ftw#1603,cloudwire/push.gofrom feat(cloud): FTW Cloud C4: the box's notifications as signed events ftw#1602, and rule U forReadAlert. Nothing here callsReadAlert.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
maindeploy/keepsmain's version:bootstrap.shanddeploy/README.mdaremain's. C4's APNs pieces stay:deploy/apns.sh,deploy/apns.mdand the compose volume that mounts/run/ftw-cloud/apns. They install their own unit and write beside the service key, and do not changemain's units. The-apns-dirline 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/appstays off in production until the Noise key is installed beside the service key, as #5 says;deploy/does not install it yet.server_test.gokeepsmain's/sourcetest and C3's socket tests. The socket test covers/v1/boxtoo, somain'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 onmaster, the pin can move tomaster's commit; the code does not change.Checks
go vet ./... && go test -race ./...pass.go test -tags ftwclouddev ./...passes.bash scripts/check-imports.shpasses (onlygo/protocol, 3 packages).push.registerleaves its session open and registers nothing, an owner's registers,push.unregisterremoves it, and an unreadable body closes the session. WithoutPush, the session goes on. A suspended device gets no event and nobox.unreachable, and gets both again after the suspension lifts. The suspension test fails without the filter.End to end (13 Oct, 10:15–10:21, this laptop)
Core from srcfl/ftw#1607.
ftwclouddfrom 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.cloud-<home>to the App Group (box key, names, rule U offset) and asked to allow notifications.KyrRmCeC(the phone that granted), epoch 1,sandbox,alert, 80 bytes.charging.connectedand 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).FTWNotifyloggedpush: event charging.connectedat 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