Repository navigation
Conversation
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>
Neither line names the home. 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>
This was referenced Oct 13, 2026
Member
Author
|
Included in #7 (C3 and C4 joined), squash-merged to main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
FTW Cloud step C4 in the service: a box's signed events reach its phones through APNs, and a box that has been away for ten minutes is reported out of reach. The service reads and never controls, and it never chooses what a phone shows: the payload carries the box's envelope unchanged, and the phone checks the signature, writes its own words and picks the buttons from the verified kind.
Paired pull requests
bye{why:"update"}, new kinds, andgo/protocol/cloudwire/push.gowith vectors). This PR pinsgo/protocolat its commit8cca4203.charging.goal_at_risk).internal/appsessionandinternal/server; this PR touches neither. Both add the sameOnConnectto the uplink; when both land,main.gocallsapp.BoxConnectedandpushSvc.BoxConnectedfrom it, and C3'sappsession.applygains two cases forpush.registerandpush.unregister.What changes
internal/apns(stdlib only):.p8) is reused for 40 minutes and renewed once onExpiredProviderToken/InvalidProviderToken.apns-push-type: alert,apns-priority: 10and no collapse id, so the request says nothing a kind would.410,BadDeviceTokenandDeviceTokenNotForTopicmark a token gone. A 410 carries Apple's timestamp, so a token the phone registered again later is kept.internal/registry/push.go:pushtable bound to roster entries: home, device id, epoch, token, environment, kind.REFERENCES homes(home) ON DELETE CASCADEwithforeign_keyson, so a purge and the 90-day expiry take the registrations with the home without touchingPurgeorExpire.internal/push:ftw.events.read. A viewer, or an owner under a grant without events, getsErrNotForThisDevice.cloudwire.AlertPayload) and sends it to every registered phone. The cap is 20 alerts per home per hour, and Apple keeps an event at most 10 minutes; after that a phone would show only the generic line.box.unreachable10 minutes after the box's socket ends, once per outage. Afterbye{why:"update"}it is held back for an hour. A box that comes back stops it, and a revoke ends it. It is the one alert the service writes, with no arguments and no buttons.internal/uplink:OnConnect(the same declaration C3 has), plusOnEvent,OnRosterandOnGone.OnGonefires only for the home's current connection, with the reason the box gave in itsbye.ftwcloudd:-apns-dirholdskey.p8,key-idandteam-id. Without them the service runs and sends nothing.-tags ftwclouddev) get two flags, so an end-to-end test can run before the app session and the key exist:-dev-push-listen: register a token for a roster device by hand, on loopback;-dev-apns-capture: write each alert to a file instead of sending it to Apple.deploy/:apns.shinstalls a unit that reads the key from SSM Parameter Store into tmpfs before Docker starts.compose.ymlmounts that directory read-only.apns.mdis the owner's steps.AmazonSSMManagedInstanceCoreallowsssm:GetParameteron*(iam simulate-principal-policysays allowed), and the account'saws/ssmkey policy allows decrypt through SSM for every principal in the account. Secrets Manager would have needed a policy change, so the key goes in Parameter Store.deploy/host-policy.jsonandget-secret-valueinapns.sh(deploy/apns.mdsays how). Both PRs add a line after-acme-emailincompose.yml, so whichever lands second resolves one small conflict.Go without tests: 2,282 lines (budget about 3,000, before C3's app session).
Tests
End-to-end, on this laptop
Before the APNs key existed
Setup
claude/cloud-c4-core(19ea338c,fe359327), with the endpoint, a local service key and a dev root set by-ldflags -X.ftwclouddfrom srcfl/ftw-cloudclaude/cloud-c4-eb03d7, built with-tags ftwclouddevand-dev-apns-capture. That flag writes each alert to a file instead of sending it to Apple.sim-ferroamp,sim-sungrow, andsim-ocppas an Easee charger.claude/ios-m3-notify(Debug), on the simulator "FTW M3" (iPhone 17, iOS 26.5, Swedish).-FTWPairLink).-FTWCloudHome …, standing in for C3's K11.xcrun simctl push.POST /api/cloud/grant {scopes: [ftw.live.read, ftw.events.read]}on loopback. The uplink connected; homefid4fo4j….charging.connectedat 08:37:09: level assumed, no goal, after the 90 s settle. The service sent the alert to 1 phone 11 ms later.cloudwire.ReadAlertwith the box's key:verdict=event kind=charging.connected lp=7edYjyHw-MQ seq=108. Theapsis the generic one, and the event is the box's envelope unchanged.7edYjyHw-MQ→garageand sentloadpoint.soc.set. The box's result came in 1,002 ms:setLevel(pct: 80.0) answered in 1002 ms,push: ftw.level.80: done: FTW planerar nu från 80 %.The box then readsoc_source: inferred, current_soc: 0.80.charging.connected→charging.bonnected).ReadAlertgaveverdict=generic. In the app, a tap on 20 % loggedpush: ftw.level.20 not sent: generic, and the box stayed at 80 %.charging.interruptedReadAlertgaveverdict=event kind=charging.interrupted.POST /api/restartat 08:54:00. Core sentbye{why:"update"}and exited; it has no supervisor here. 11.5 minutes later no alert had left the service.kill -9at 09:06:18. The service loggeda box went away bye="" out_of_reach_after=10m0s, and at 09:16:18a box is out of reach; telling its phones, with the alert to 1 phone. Its payload is{"ftw":{"v":1,"home":"fid4fo4j…","kind":"box.unreachable"}}, andReadAlertgivesverdict=unreachable.Through Apple's sandbox, with the real key
The same house, with
ftwclouddsending toapi.sandbox.push.apple.com. KeyK9299SY46H, teamL66M368PZ6, read from SSM Parameter Store into a temporary folder and deleted afterwards; nothing printed it. The extension (FTWNotify) now ran for every push.box.unreachable(sent with/dev/push/raw)200. The extension loggedpush: unreachable. The lock screen showed TIDSKÄNSLIG · Din box går inte att nå – "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."notifications: sent event=charging.connectedat 09:32:59.870. The service sent to Apple at 09:33:00.032, and the extension loggedpush: event charging.connectedat 09:33:00.138, 268 ms after Core. On screen: Bilen är inkopplad – "Öppna för att se laddningen, kolla bilens nivå och välja klartid."setLevel(pct: 80.0) answered in 990 ms,push: ftw.level.80: done: FTW planerar nu från 80 %.The box went fromsoc_source: assumed, current_soc: 0.20toinferred, 0.80.charging.bonnected), sent with/dev/push/raw. Apple200; the extension loggedpush: generic. The lock screen showed "Något hände hemma / Öppna FTW för att se vad.", and "Visa" opened it with no buttons.charging.interruptedpush: event charging.interruptedat 09:47:02.076. On screen: TIDSKÄNSLIG · Laddningen avbröts – "Bilen slutade ladda innan den var klar." Buttons: Ladda nu, Öppna.chargeNow answered in 9433 ms,push: ftw.chargeNow: done: FTW har startat laddningen. Väntar på att bilen tar ström.The planned update and the crash ran in the first part. They never reach Apple: no alert left the service for the update, and the crash's
box.unreachableis the payload shown in the first row here. Screenshots are kept with the test, outside the repository.Not done
appsession.Servergets aPushfield.phoneConn.applygets two cases that callRegisterandUnregisterwithc.dev.Device()andc.dev.Scopes().ErrNotForThisDevicekeeps the session open; a decode error closes it withCloseProtocol.main.go,OnConnectcalls bothApp.BoxConnected(App may be nil) andpushSvc.BoxConnected.registry.Suspended).Until then the dev route registers tokens.
ftwclouddthe dead man's switch knows only boxes that connect again. A box that was connected before and never comes back is not reported. Persisting "last seen" would fix it.Nothing here is deployed. The owner runs
deploy/apns.shafter putting the key in Parameter Store (deploy/apns.md).🤖 Generated with Claude Code