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.