docs/specs/2026-09-20-ios-push-notifications-design.md

v1.37.0
gitbay/docs/specs/2026-09-20-ios-push-notifications-design.md rendered · source · history · blame · raw

375 lines · 16231 bytes

  1# iOS push notifications
  2
  3Closes #89. gitbayd delivers activity to registered Apple devices over
  4APNs, as a third route beside the inbox row and the activity mail that
  5`notify()` already sends.
  6
  7## Problem
  8
  9`krz/gitbay-ios` is a reading and reviewing surface for the times its
 10user is not at a keyboard, and it has no way to say anything happened.
 11`DESIGN.org` records the gap as "No push notifications — poll on
 12foreground, use background refresh; not planned, propose if the app
 13makes the case". This is that proposal.
 14
 15Background refresh does not close the gap. `BGAppRefreshTask` is
 16opportunistic: the system runs it when it feels like it, routinely
 17fifteen minutes to hours after the event, and it cannot be relied on to
 18badge. A failed build is exactly the notice that is worthless late.
 19
 20## Decision
 21
 22gitbayd speaks APNs directly, over HTTP/2, authenticated by a JWT it
 23signs with an operator-supplied `.p8` key. Notices become queue rows and
 24a drainer sends them with the same bounded-retry discipline the mail
 25queue and the webhook deliverer already use.
 26
 27Decisions taken on the way, with the alternatives rejected:
 28
 29- **gitbay.org only.** An APNs key belongs to a bundle ID, and only the
 30  author of `org.gitbay.gitbay` holds one. A self-hoster gets push by
 31  shipping their own build under their own bundle ID and pointing
 32  `[push]` at their own key; the App Store build talks to gitbay.org.
 33  The config is written so that already works — nothing in the server
 34  hardcodes an instance.
 35- **No relay.** A service this instance operates, holding the key and
 36  accepting pushes from other instances, was the only way to give
 37  strangers push with the App Store build. At one-instance scope it is
 38  a second deployment to run and back up, and it would put other
 39  people's notification text through this server for no benefit anyone
 40  asked for. Declined. If self-hosters ever ask, the queue row is
 41  already the right unit to hand to one.
 42- **No new Go dependency.** APNs requires HTTP/2, and stdlib
 43  `net/http` negotiates h2 over ALPN. Token auth is an ES256 JWT over a
 44  fixed two-field header and three-field claim set — `crypto/ecdsa`
 45  and `encoding/json`, no JWT library. A provider token is valid an
 46  hour and must not be reminted faster than once per twenty minutes,
 47  so it is cached and refreshed at fifty.
 48- **Full text in the payload, private repositories included.** The
 49  alternative sends "new activity on krz/gitbay" and has the app fetch
 50  the detail, which needs a Notification Service Extension holding a
 51  bearer token in a shared keychain group. That is real complexity to
 52  keep a repository name off a lock screen on a single-user instance.
 53  The trade is recorded here rather than made configurable: a private
 54  repository's name, item number and summary reach Apple and appear on
 55  the lock screen. Revisit if the instance stops having one human user.
 56- **Device rows, not user rows.** A token identifies an install, and
 57  one account signs in from a phone and an iPad. Registration is
 58  per-device, keyed on the token.
 59- **Reaped by APNs, not by a job.** A `410 Unregistered` response, and
 60  a `400` whose reason is `BadDeviceToken`, delete the device row. No
 61  expiry sweep; Apple is authoritative about which tokens are live.
 62- **`issue assign` starts filing a notice.** #89 names assignments and
 63  assignment is not among the sixteen `notify()` call sites today — the
 64  dashboard surfaces assigned work and nothing announces it. Fixed
 65  here, because a push feature that is silent on the thing the issue
 66  asked for is not the feature. It lands as its own commit and is
 67  worth having with or without push.
 68
 69## Data
 70
 71Migration 0059. Two tables and one column.
 72
 73```sql
 74CREATE TABLE push_devices (
 75    id           INTEGER PRIMARY KEY,
 76    user_id      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 77    token        TEXT NOT NULL UNIQUE,
 78    label        TEXT NOT NULL DEFAULT '',
 79    created_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
 80    last_seen_at TEXT
 81);
 82CREATE INDEX push_devices_user ON push_devices(user_id);
 83
 84CREATE TABLE push_queue (
 85    id              INTEGER PRIMARY KEY,
 86    device_id       INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
 87    title           TEXT NOT NULL,
 88    body            TEXT NOT NULL,
 89    path            TEXT NOT NULL,
 90    attempts        INTEGER NOT NULL DEFAULT 0,
 91    next_attempt_at TEXT,
 92    sent_at         TEXT,
 93    failed_at       TEXT,
 94    last_error      TEXT,
 95    created_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
 96);
 97CREATE INDEX push_queue_due ON push_queue(next_attempt_at)
 98    WHERE sent_at IS NULL AND failed_at IS NULL;
 99
100ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1;
101```
102
103The mail queue's table is named `notifications`, so the push queue
104cannot be. `push_queue` mirrors its columns exactly, which is what lets
105the drainer be a copy of the mailer's loop rather than a new design.
106
107`notify_push` defaults to 1 and costs nothing when the account has no
108devices: an account that never registers one is unaffected by the
109column. It exists so a user with two devices can silence both without
110deregistering each.
111
112Rows are stored per device rather than per notice, so a retry to one
113device does not resend to the other. A notice reaching a user with two
114devices writes two rows.
115
116Retention: `push_queue` joins the `[retention]` sweep beside the mail
117queue, as a new `push` key on `config.Retention` and a corresponding
118sweep in `internal/store/retention.go`.
119
120## Config
121
122```toml
123[push]
124enabled     = true
125key_file    = "/etc/gitbay/apns.p8"
126key_id      = "ABC123DEFG"
127team_id     = "ZCNAX3VL9D"
128topic       = "org.gitbay.gitbay"
129environment = "production"   # or "sandbox"
130```
131
132`environment` picks the host: `api.push.apple.com` or
133`api.sandbox.push.apple.com`. It is a named mode rather than a raw URL
134so a typo cannot aim the key at a host that is not Apple's.
135
136Validation at load, in the manner of `max_snippets_per_user`'s negative
137check (#214): with `enabled = true`, the four string fields must be
138non-empty, `environment` must be one of the two names, and `key_file`
139must exist and parse as an EC private key. A misconfigured `[push]`
140refuses to start rather than failing silently at the first notice —
141the failure mode otherwise is a queue that fills and dead-letters with
142nobody watching.
143
144The `.p8` is read at startup and referenced by path, as `host_keys` and
145the TLS `key_file` are. It is never in the repository, never in argv,
146never logged. File mode 0600, owned by the account gitbayd runs as.
147
148## Commands
149
150The capability lands in the registry; the surfaces render it.
151
152| Command | Notes |
153|---|---|
154| `notifications device add` | `--label <name>`, token on stdin. `ReadsStdin: true`. Returns the row id, for deregistration. |
155| `notifications device list` | `ReadOnly`. Token shown truncated, never in full. |
156| `notifications device remove <id>` | Own devices only. |
157| `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. |
158
159A device token is an address, not a credential, but it is
160device-identifying and long enough to be awkward in argv. Taking it on
161stdin costs nothing and keeps it out of `/proc`; `ReadsStdin: true` is
162mandatory or `control.go` swaps in an empty reader and the command
163stores an empty string without erroring.
164
165`notifications settings show` and `emitNotificationSettings` grow a
166third key, `push`, beside `mail` and `watch`. The map is the JSON
167contract, so this is additive.
168
169`device add` on a token that already exists updates the label and the
170owner rather than erroring: a reinstall hands the same token to a
171different account, and Apple reuses tokens.
172
173Every command runs on every surface, per #234. The app registers over
174the JSON API with its bearer token, which is the whole point.
175
176## Delivery
177
178`notify()` in `internal/control/notifications.go` gains a third branch
179in the loop it already runs per recipient:
180
181```go
182c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
183// mail, as today
184c.Store.EnqueuePush(id, pushTitle(n), pushBody(n), n.path)
185```
186
187`EnqueuePush` writes one row per registered device, and writes nothing
188when the account has `notify_push` off or no devices — the same shape as
189`ActivityMailAddress` returning "" when `notify_mail` is off. Mute,
190watch and actor-exclusion are already settled by `NotifyRecipients`
191before this point, so push inherits them for free and cannot drift from
192what the inbox shows.
193
194`internal/push` is a `Deliverer` in the mould of `internal/notify`'s
195`Mailer`: a two-second ticker, `DuePush(20)`, send, `MarkPushSent` or
196`MarkPushFailed` with `RetryBase << (attempt-1)` and dead-lettering at
197`DefaultMaxAttempts`. gitbayd starts it beside the mailer at
198`cmd/gitbayd/main.go:177`, under the same context, when
199`cfg.Push.Enabled`.
200
201The payload:
202
203```json
204{
205  "aps": {
206    "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"},
207    "sound": "default",
208    "badge": 3,
209    "thread-id": "krz/gitbay"
210  },
211  "path": "krz/gitbay/issues/12",
212  "instance": "https://gitbay.org",
213  "user": "cmc"
214}
215```
216
217`title` is the repository path, `body` the inbox summary — the same
218string the inbox row carries, so the two surfaces cannot disagree.
219`thread-id` groups a repository's notices in Notification Center.
220`path` is the inbox row's `path` field, which the app already knows how
221to turn into a link. `badge` is the recipient's unread inbox count,
222counted by `DuePush` at send time rather than at enqueue, so a cleared
223inbox is reflected.
224
225`instance` is this instance's `site_url` and `user` the recipient's
226username. A device token is one install, and an install registers
227against every account signed in on it, so `path` alone cannot say which
228account a notice belongs to — two instances can hold the same
229`owner/name`. The pair is the account's identity, and the client
230resolves it before routing.
231
232`apns-push-type: alert`, `apns-topic` from config, and
233`apns-collapse-id` unset — collapsing is wrong here, two comments are
234two notices.
235
236Response handling: `200` marks sent; `410`, and `400` with reason
237`BadDeviceToken`, delete the device row and its queued rows; `429` and
238`5xx` retry with backoff, honouring `Retry-After` when present; other
239`4xx` dead-letter with the reason recorded, since retrying a rejected
240payload will not fix it.
241
242`internal/notify`'s `redactAddresses` has no analogue to write — a
243device token is not a mail address — but the token is never logged
244either. Log lines name the device id.
245
246## Assignment notices
247
248`runIssueAssign` (`internal/control/issue.go:423`) files a notice for
249each account newly added. The add loop already resolves each name to a
250`store.User`; it collects their ids into `added`, and after both loops
251succeed:
252
253```go
254notify(c, added, notice{repo: repo, kind: "issue", direct: true,
255    subject: fmt.Sprintf("[%s] #%d: %s", repo.Path(), issue.Number, issue.Title),
256    action:  fmt.Sprintf("assigned you to #%d", issue.Number),
257    path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
258```
259
260`direct: true`, as mentions are: an assignment is addressed to someone,
261and widening it to watchers would report "assigned you" to people it did
262not assign. Removals file nothing. `notify()` already drops the actor,
263so assigning yourself is silent.
264
265There is no `mr assign` command; `issue assign` is the only site.
266
267## Web
268
269`/settings/notifications` grows a push row beside mail and watch, and a
270device list with a remove button per row, dispatching the same commands
271through `runControlStdin`.
272
273There is no web form to add a device — a browser cannot produce an APNs
274token. `notifications device add` is therefore reachable on the web in
275the sense that every command is, but no page offers it, which is the
276Parity page's "CLI only, for now" made literal rather than a refusal.
277
278A new template means a row in `TestMainWidthClass`
279(`internal/web/web_test.go`) or CI fails on it.
280
281## The app
282
283Separate merge request on `krz/gitbay-ios`, after the server ships.
284`gitbay-ios` has no push scaffolding today: no app delegate adaptor, no
285`UNUserNotificationCenter` use, no background modes.
286
287- `UIApplicationDelegateAdaptor` for
288  `didRegisterForRemoteNotificationsWithDeviceToken`, which is the only
289  way to get the token.
290- Permission requested on first visit to the notifications screen, not
291  at launch. A prompt before the user has seen what the app does is the
292  prompt they deny.
293- On the token arriving, and on each sign-in, `notifications device
294  add` with a label from `UIDevice.current.name`. On sign-out,
295  `notifications device remove`.
296- `userNotificationCenter(_:didReceive:)` reads `path` and routes
297  through the navigation the inbox rows already use.
298- Badge from the unread count the dashboard already returns.
299
300Because an account is an instance plus a user, a device registers once
301per signed-in account and holds one row per account it is signed in to.
302A token registered against two instances gets two pushes, which is
303correct — they are two accounts.
304
305Ships with the Push Notifications capability on `org.gitbay.gitbay`
306(team ZCNAX3VL9D), a privacy nutrition label declaring the device token
307under Identifiers, and a resubmission.
308
309`DESIGN.org`'s "No push notifications" gap row is rewritten to point at
310the implemented feature.
311
312## Sequencing
313
314Server first, app second, in separate merge requests on separate
315repositories. The server half is self-contained and testable against a
316fake APNs endpoint, which keeps an App Store review off the critical
317path. Between the two, `[push]` is configured and inert — nothing has
318registered a device, so nothing queues.
319
320`[push]` stays `enabled = false` on bay1 until the app is submitted.
321
322The implementation plan that follows this spec covers the server half
323only. The app half is scoped here to fix the contract it has to meet —
324the payload keys, the registration calls, the capability and the
325nutrition label — and gets its own plan on `krz/gitbay-ios` once the
326server has shipped.
327
328## Testing
329
330Unit, `internal/push`:
331
332- The JWT signs, carries `alg: ES256` and the key id in its header,
333  `iss` (team id) and `iat` in its claims, and verifies against the
334  public half of a generated test key.
335- The cached token is reused inside fifty minutes and reminted after.
336- Response mapping: 200 sent, 410 and BadDeviceToken reap, 429 and 503
337  retry, 403 dead-letters.
338
339Unit, `internal/store`: `EnqueuePush` writes one row per device, none
340when `notify_push` is off, none when the account has no devices.
341
342Unit, `internal/config`: each malformed `[push]` is refused at load.
343
344`TestStdinCommandsReadStdin` covers `device add` once it is registered
345with `ReadsStdin`; `TestReadOnlyCommandsWriteNothing` covers
346`device list`. Both are existing registry tests that pick up the new
347commands without being edited — the `cmd/gitbay/main.go` `pass()` table
348does need the new commands or its coverage test fails.
349
350E2E, `e2e/push_test.go`: an httptest server standing in for APNs, its
351host injected through `GITBAY_APNS_HOST`, following the
352`GITBAY_SWEEP_TICK` precedent. An env var rather than a config key, so
353`environment` stays a two-name mode that an operator cannot point at a
354host that is not Apple's. Register a device, act as another user on
355a watched repository, assert the queue drains and the fake received a
356payload whose body matches the inbox row's summary. Then a 410 and
357assert the device row is gone. The fake speaks HTTP/1.1 — the real
358transport is h2 by ALPN, which is stdlib behaviour and not this
359repository's to test.
360
361E2E, `e2e/assign_test.go` or the existing issue test: assigning files an
362inbox row for the assignee and none for the actor.
363
364Verified already: outbound HTTP/2 from bay1 to `api.push.apple.com:443`
365reaches Apple — a GET to `/3/device/test` answers `405` over h2.
366
367## Docs
368
369- Wiki `Parity`: rows for the four commands, in the merge request that
370  adds them.
371- Wiki `Admin`: the `[push]` section, obtaining a `.p8`, and the
372  one-instance-one-bundle-ID constraint for self-hosters.
373- Wiki `Users`: `notifications settings push`, and what a device row is.
374- `CHANGELOG.org`.
375- `krz/gitbay-ios` `DESIGN.org`: the gap row.