iOS push notifications (server) !447

merged merged by cmc on 2026-09-20 11:33 UTC · krz/gitbay:ios-push into main

Discussion

cmc

Closes #89. gitbayd delivers activity to registered Apple devices over APNs, as a third route beside the inbox row and the activity mail that notify() already sent. The iOS client half is a separate plan on krz/gitbay-ios; this is the server.

Spec docs/specs/2026-09-20-ios-push-notifications-design.md, plan docs/plans/2026-09-20-ios-push-notifications.md.

What landed

Migration 0059: push_devices, push_queue, users.notify_push. The mail queue's table is named notifications, so the push queue is not; the columns mirror it, which is what lets the drainer be the mailer's loop rather than a new design.

internal/push is a third instance of the shape internal/notify and internal/webhook already have: a ticker, a bounded fetch, exponential backoff, dead-lettering. No new module dependency — stdlib net/http negotiates the HTTP/2 APNs requires over ALPN, and the ES256 provider token is crypto/ecdsa plus encoding/json. The JWT signature is the raw r||s pair rather than SignASN1's DER, which is the difference between a token Apple accepts and one it rejects; the token is cached fifty minutes, inside the hour it is valid and outside the twenty minutes that answers TooManyProviderTokenUpdates.

Config [push]: enabled, key_file, key_id, team_id, topic, environment. Validated at load, including parsing the .p8, so a misconfigured section refuses to start rather than filling a queue nobody watches. environment is a two-name mode rather than a URL, so a typo cannot aim the provider key at a host that is not Apple's.

Commands notifications device add|list|remove and notifications settings push on|off. The token arrives on stdin and device list truncates it: enough to tell two devices apart, not enough to push to one. Web forms dispatch the same commands. There is no add-a-device form, because a browser cannot produce an APNs token — that is the Parity page's "CLI only, for now", not a refusal.

Apple is authoritative about which tokens are live: 410 Unregistered and BadDeviceToken delete the device row, and its queued rows cascade.

issue assign files a notice. It never did — the dashboard surfaced assigned work and nothing announced it. Direct, as a mention is, so watchers are not told they were assigned, and only accounts newly added are notified.

Decisions worth knowing

Notification text is sent in full, private repositories included. A private repository's name and item number therefore reach Apple and appear on a lock screen. The alternative needs a Notification Service Extension holding a bearer token in a shared keychain group, which is not worth it on a single-user instance. Recorded in the spec and stated plainly on the Users wiki page rather than left to be discovered.

An APNs key belongs to a bundle ID, so this pushes to the app built under the configured topic and no other. A self-hoster points [push] at their own key and their own build; nothing hardcodes an instance.

Two things a reviewer should look at

internal/push/apns.go was changed outside the task that owned it. The end-to-end test found that NewClient hardcoded https while GITBAY_APNS_HOST redirected only the host, so a real gitbayd could never reach a test endpoint: every send failed "server gave HTTP response to HTTPS client", which Send classifies as retryable, so the queue drained forever without delivering. The scheme now follows the same override, constrained to a loopback host so a provider token cannot cross the wire in cleartext to anywhere else, and says so in a startup warning.

The device-removal confirmation was bypassable while this branch was in progress, and the fix generalises. want came from a tokenprefix field separate from the dispatch key, so a post carrying a valid id and omitting tokenprefix produced an empty want, which an omitted confirm matched — valid target, no confirmation. It now derives from the id it dispatches on. key-remove and pgp-remove were never affected, because they already derive want from the value they dispatch on, and an empty one there means an empty target. Any future confirmed control that takes its expected value from a separate field has the same hole.

Deployment

[push] stays enabled = false on bay1 until the iOS app is submitted. Nothing registers a device until then, and notify() does not queue when push is disabled — a user who tries device add on a disabled instance is told so rather than being handed a registration that cannot deliver.

Schema moves 58 to 59. Outbound HTTP/2 from bay1 to api.push.apple.com:443 was verified before any of this was written.

Tests

Unit tests per package, and e2e/push_test.go drives a real instance against an httptest fake: a device registers over SSH, a push arrives carrying the same words the inbox row carries, a 410 reaps the device, and the inbox is untouched throughout. The fake speaks HTTP/1.1; the real transport is h2 by ALPN, which is stdlib behaviour and not ours to test.

Full suite green before merge.