~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve pushes by APNs and FCM

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:push/transports

What changes

APNs (push::apns): a push to an iOS device over HTTP/2, authenticated by a JWT the team's .p8 key signs (ES256, kid the key ID, iss the team), made every fifty minutes and used for every push between. The payload is an alert: aps holds the title and body, sound: default and the badge, and the notification's fields (type, uri or did) sit beside it, where the iOS app reads where a tap goes. apns-topic is the bundle ID, apns-push-type alert.

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's token_uri for a token cached for its hour less a minute. The payload is data only, every field a string (title, body, and the notification's), at android.priority: HIGH, as the Android app draws the notification itself.

What became of a push (push::Outcome): sent; gone (APNs 410, FCM UNREGISTERED), 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::Transports sends by whichever is set up, and to neither for web or 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:

old push configgraintoday's value
apns.keyFileGRAIN_APNS_KEY_FILE/data/certs/AuthKey_J53A95HDW7.p8
apns.keyIdGRAIN_APNS_KEY_IDJ53A95HDW7
apns.teamIdGRAIN_APNS_TEAM_IDYN68LN9T7Z
apns.bundleIdGRAIN_APNS_TOPICsocial.grain.grain
apns.production: falseGRAIN_APNS_SANDBOX=1unset in prod
fcm.keyFileGRAIN_FCM_KEY_FILE/data/certs/firebase-service-account.json
fcm.projectIdGRAIN_FCM_PROJECT_IDunset (read from the key file)

APNs is off unless GRAIN_APNS_KEY_FILE is set, FCM unless GRAIN_FCM_KEY_FILE is; each alone works.

Why

The second push step on feature/rust, stacked on the first (registerPush); the third sends what notification::arising finds through these. Kept apart so the transports, and their crypto, can be read alone.

ring (already in the tree, under rustls) signs RS256; p256 reads the APNs key. The APNs client speaks HTTP/2 with prior knowledge, as APNs takes nothing else.

Differences from the old server:

  • A key file named but unreadable, or APNs named without its key ID, team or topic, fails startup rather than logging and pushing by the other service only. Leaving a variable unset is how a service is left off.
  • A bare FCM 404 is not taken as a dead token: only UNREGISTERED is. A wrong project answers 404 for every token, and taking that as gone would empty push_token, which nothing rebuilds. APNs's BadDeviceToken is not taken as gone either (as before), as a sandbox/production mix-up answers it for every token.
  • Refused credentials, 429 and 5xx are said to be worth retrying; the old server tried once and logged.
  • An APNs token that is not hex is refused before it is sent, since it is part of the URL.
  • No collapse ID: the old server's notifications never set one.
  • Errors never carry the device token: request errors drop their URL.

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 in src/push/test-rsa-key.pem was made for these tests and signs nothing else. Coverage and the rest are left to the runner.

Shortcomings

  • Not wired up yet: nothing calls these until the next pull.
  • Not tried against Apple's or Google's real services; that needs the production keys on the box.
  • No web push: the old server kept web tokens and sent nothing to them, and so does this.

Review

Start at push/mod.rs's Outcome, then apns.rs and fcm.rs's send. This pull's own change is the second commit; the first is the registerPush pull, which this stacks on.

🤖 Generated with Claude Code

Approved

This 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 UNREGISTERED and 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.

chadtmiller.com added 1 commit
Approved

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.

chadtmiller.com added 1 commit
Approved

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.

Approved

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.

Merged as 97fdfc4
Part of a stack of 2
  1. grain-serve pushes by APNs and FCM Landed as 97fdfc4
  2. main