grain-serve pushes by APNs and FCM
MergedThis adds the APNs and FCM transports, the Outcome type, Transports and the settings read from the environment. I read push/mod.rs, apns.rs and fcm.rs in full. I did not read tests.rs or the dependency changes in detail.
The code matches its description and I found nothing that must change:
- JWT and token caching: the cache and drop-on-refusal logic is sound.
- Dead tokens: only
UNREGISTEREDand 410 count as gone, which is the safer choice the description argues for. - Device tokens: the hex check keeps APNs tokens out of the URL, and errors drop their URL.
The checks (check, rust, test, harness) were still queued when I looked. Merge only once they pass, including the coverage gate in harness/cov.json.
One small hardening point you can take or leave: Message::data keys are copied into the payload after the standard fields. A key named aps would replace the APNs alert, and a key named title or body would replace the FCM text. The next pull builds data itself, so this is not reachable today. Skipping or rejecting those reserved keys would make it safe by construction.
The new commit moves the check for a partly named APNs setup from Config::from_vars into Apns::new. from_vars can no longer fail, and Transports::new still refuses startup with the same message, GRAIN_APNS_KEY_FILE needs … too. The test now goes through Transports::new, and the change is consistent with the description. All four checks (check, rust, test, harness) pass on this commit.
Last round I asked for nothing that must change. The optional point about reserved Message::data keys (aps, title, body) is not taken up. The next pull builds data itself, so this does not block the merge.
The new commit takes up the optional point from round 1. In both Apns::payload and Fcm::payload, the notification's data is now written first, and the reserved keys (aps, title, body) are inserted after it, so data can't replace them. The new assertions in tests.rs cover aps, title and body in data. I checked that the change is correct.
rust and check pass on this commit. test and harness were still running when I looked. Merge once they pass, including the coverage gate.
Taken up in cd804ba: the payloads are now built from Message::data first, with aps (APNs) and title/body (FCM) set after, so no data key can stand in for them; a test sends data with all three keys and checks the alert and text survive.
The new commit merges feature/rust into the branch. That base now holds the registerPush pull squashed, which is why the queue ejected the pull with a conflict in push/mod.rs. I read the resolved push/mod.rs. It has the registerPush handlers, Message, Outcome, Config, Transports and der_of_pem together, with no duplicates or leftovers. It also still matches what I reviewed in earlier rounds. The payload change from last round (data written first, reserved keys after) is unchanged, and I have nothing new to ask for.
All four checks (check, rust, test, harness) were still queued on this commit when I looked. Queue the pull again once they pass, including the coverage gate.
- grain-serve pushes by APNs and FCM
- main

What changes
APNs (
push::apns): a push to an iOS device over HTTP/2, authenticated by a JWT the team's.p8key signs (ES256,kidthe key ID,issthe team), made every fifty minutes and used for every push between. The payload is an alert:apsholds the title and body,sound: defaultand the badge, and the notification's fields (type,uriordid) sit beside it, where the iOS app reads where a tap goes.apns-topicis the bundle ID,apns-push-typealert.FCM (
push::fcm): a push to an Android device by the HTTP v1 API, with an access token the service account earns: it signs a JWT for the messaging scope (RS256), traded at the key file'stoken_urifor a token cached for its hour less a minute. The payload is data only, every field a string (title,body, and the notification's), atandroid.priority: HIGH, as the Android app draws the notification itself.What became of a push (
push::Outcome): sent; gone (APNs 410, FCMUNREGISTERED), for the sender to forget the token; to try again (429, 5xx, not reached, and a refused JWT or access token, which is dropped for a new one); or failed.push::Transportssends by whichever is set up, and to neither forwebor a platform it has no service for.Settings, from the environment (
push::Config::from_vars), which the next pull reads at startup. The old server read these from its config file; the mapping, for cutover:pushconfigapns.keyFileGRAIN_APNS_KEY_FILE/data/certs/AuthKey_J53A95HDW7.p8apns.keyIdGRAIN_APNS_KEY_IDJ53A95HDW7apns.teamIdGRAIN_APNS_TEAM_IDYN68LN9T7Zapns.bundleIdGRAIN_APNS_TOPICsocial.grain.grainapns.production: falseGRAIN_APNS_SANDBOX=1fcm.keyFileGRAIN_FCM_KEY_FILE/data/certs/firebase-service-account.jsonfcm.projectIdGRAIN_FCM_PROJECT_IDAPNs is off unless
GRAIN_APNS_KEY_FILEis set, FCM unlessGRAIN_FCM_KEY_FILEis; each alone works.Why
The second push step on feature/rust, stacked on the first (registerPush); the third sends what
notification::arisingfinds through these. Kept apart so the transports, and their crypto, can be read alone.ring(already in the tree, under rustls) signs RS256;p256reads the APNs key. The APNs client speaks HTTP/2 with prior knowledge, as APNs takes nothing else.Differences from the old server:
UNREGISTEREDis. A wrong project answers 404 for every token, and taking that as gone would emptypush_token, which nothing rebuilds. APNs'sBadDeviceTokenis not taken as gone either (as before), as a sandbox/production mix-up answers it for every token.How it was checked
src/push/tests.rs, against stand-ins for APNs, FCM and Google's token endpoint served by axum on loopback (h2c for APNs, asserted HTTP/2): the payloads and headers as sent, the ES256 and RS256 signatures verified against the keys' public halves, the JWT and access token reused and remade, each answer's outcome, failures to reach either, key files missing or of the wrong kind, a project named or not, and the settings from the environment. The RSA key insrc/push/test-rsa-key.pemwas made for these tests and signs nothing else. Coverage and the rest are left to the runner.Shortcomings
webtokens and sent nothing to them, and so does this.Review
Start at
push/mod.rs'sOutcome, thenapns.rsandfcm.rs'ssend. This pull's own change is the second commit; the first is the registerPush pull, which this stacks on.🤖 Generated with Claude Code