Commit e4483e3d3c
Verified · cmc
docs/specs/2026-09-20-ios-push-notifications-design.md added +360
| @@ -0,0 +1,360 @@ | ||
| 1 | # iOS push notifications | |
| 2 | ||
| 3 | Closes #89. gitbayd delivers activity to registered Apple devices over | |
| 4 | APNs, 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 | |
| 10 | user 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 | |
| 12 | foreground, use background refresh; not planned, propose if the app | |
| 13 | makes the case". This is that proposal. | |
| 14 | ||
| 15 | Background refresh does not close the gap. `BGAppRefreshTask` is | |
| 16 | opportunistic: the system runs it when it feels like it, routinely | |
| 17 | fifteen minutes to hours after the event, and it cannot be relied on to | |
| 18 | badge. A failed build is exactly the notice that is worthless late. | |
| 19 | ||
| 20 | ## Decision | |
| 21 | ||
| 22 | gitbayd speaks APNs directly, over HTTP/2, authenticated by a JWT it | |
| 23 | signs with an operator-supplied `.p8` key. Notices become queue rows and | |
| 24 | a drainer sends them with the same bounded-retry discipline the mail | |
| 25 | queue and the webhook deliverer already use. | |
| 26 | ||
| 27 | Decisions 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 | ||
| 71 | Migration 0059. Two tables and one column. | |
| 72 | ||
| 73 | ```sql | |
| 74 | CREATE 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 | ); | |
| 82 | CREATE INDEX push_devices_user ON push_devices(user_id); | |
| 83 | ||
| 84 | CREATE 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 | ); | |
| 97 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 98 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 99 | ||
| 100 | ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1; | |
| 101 | ``` | |
| 102 | ||
| 103 | The mail queue's table is named `notifications`, so the push queue | |
| 104 | cannot be. `push_queue` mirrors its columns exactly, which is what lets | |
| 105 | the 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 | |
| 108 | devices: an account that never registers one is unaffected by the | |
| 109 | column. It exists so a user with two devices can silence both without | |
| 110 | deregistering each. | |
| 111 | ||
| 112 | Rows are stored per device rather than per notice, so a retry to one | |
| 113 | device does not resend to the other. A notice reaching a user with two | |
| 114 | devices writes two rows. | |
| 115 | ||
| 116 | Retention: `push_queue` joins the `[retention]` sweep beside the mail | |
| 117 | queue, as a new `push` key on `config.Retention` and a corresponding | |
| 118 | sweep in `internal/store/retention.go`. | |
| 119 | ||
| 120 | ## Config | |
| 121 | ||
| 122 | ```toml | |
| 123 | [push] | |
| 124 | enabled = true | |
| 125 | key_file = "/etc/gitbay/apns.p8" | |
| 126 | key_id = "ABC123DEFG" | |
| 127 | team_id = "ZCNAX3VL9D" | |
| 128 | topic = "org.gitbay.gitbay" | |
| 129 | environment = "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 | |
| 134 | so a typo cannot aim the key at a host that is not Apple's. | |
| 135 | ||
| 136 | Validation at load, in the manner of `max_snippets_per_user`'s negative | |
| 137 | check (#214): with `enabled = true`, the four string fields must be | |
| 138 | non-empty, `environment` must be one of the two names, and `key_file` | |
| 139 | must exist and parse as an EC private key. A misconfigured `[push]` | |
| 140 | refuses to start rather than failing silently at the first notice — | |
| 141 | the failure mode otherwise is a queue that fills and dead-letters with | |
| 142 | nobody watching. | |
| 143 | ||
| 144 | The `.p8` is read at startup and referenced by path, as `host_keys` and | |
| 145 | the TLS `key_file` are. It is never in the repository, never in argv, | |
| 146 | never logged. File mode 0600, owned by the account gitbayd runs as. | |
| 147 | ||
| 148 | ## Commands | |
| 149 | ||
| 150 | The 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`. | | |
| 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 | ||
| 159 | A device token is an address, not a credential, but it is | |
| 160 | device-identifying and long enough to be awkward in argv. Taking it on | |
| 161 | stdin costs nothing and keeps it out of `/proc`; `ReadsStdin: true` is | |
| 162 | mandatory or `control.go` swaps in an empty reader and the command | |
| 163 | stores an empty string without erroring. | |
| 164 | ||
| 165 | `notifications settings show` and `emitNotificationSettings` grow a | |
| 166 | third key, `push`, beside `mail` and `watch`. The map is the JSON | |
| 167 | contract, so this is additive. | |
| 168 | ||
| 169 | `device add` on a token that already exists updates the label and the | |
| 170 | owner rather than erroring: a reinstall hands the same token to a | |
| 171 | different account, and Apple reuses tokens. | |
| 172 | ||
| 173 | Every command runs on every surface, per #234. The app registers over | |
| 174 | the 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 | |
| 179 | in the loop it already runs per recipient: | |
| 180 | ||
| 181 | ```go | |
| 182 | c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path) | |
| 183 | // mail, as today | |
| 184 | c.Store.EnqueuePush(id, pushTitle(n), pushBody(n), n.path) | |
| 185 | ``` | |
| 186 | ||
| 187 | `EnqueuePush` writes one row per registered device, and writes nothing | |
| 188 | when the account has `notify_push` off or no devices — the same shape as | |
| 189 | `ActivityMailAddress` returning "" when `notify_mail` is off. Mute, | |
| 190 | watch and actor-exclusion are already settled by `NotifyRecipients` | |
| 191 | before this point, so push inherits them for free and cannot drift from | |
| 192 | what 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 | ||
| 201 | The payload: | |
| 202 | ||
| 203 | ```json | |
| 204 | { | |
| 205 | "aps": { | |
| 206 | "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"}, | |
| 207 | "sound": "default", | |
| 208 | "thread-id": "krz/gitbay" | |
| 209 | }, | |
| 210 | "path": "krz/gitbay/issues/12" | |
| 211 | } | |
| 212 | ``` | |
| 213 | ||
| 214 | `title` is the repository path, `body` the inbox summary — the same | |
| 215 | string the inbox row carries, so the two surfaces cannot disagree. | |
| 216 | `thread-id` groups a repository's notices in Notification Center. | |
| 217 | `path` is the inbox row's `path` field, which the app already knows how | |
| 218 | to turn into a link. | |
| 219 | ||
| 220 | `apns-push-type: alert`, `apns-topic` from config, and | |
| 221 | `apns-collapse-id` unset — collapsing is wrong here, two comments are | |
| 222 | two notices. | |
| 223 | ||
| 224 | Response handling: `200` marks sent; `410`, and `400` with reason | |
| 225 | `BadDeviceToken`, delete the device row and its queued rows; `429` and | |
| 226 | `5xx` retry with backoff, honouring `Retry-After` when present; other | |
| 227 | `4xx` dead-letter with the reason recorded, since retrying a rejected | |
| 228 | payload will not fix it. | |
| 229 | ||
| 230 | `internal/notify`'s `redactAddresses` has no analogue to write — a | |
| 231 | device token is not a mail address — but the token is never logged | |
| 232 | either. Log lines name the device id. | |
| 233 | ||
| 234 | ## Assignment notices | |
| 235 | ||
| 236 | `runIssueAssign` (`internal/control/issue.go:423`) files a notice for | |
| 237 | each account newly added. The add loop already resolves each name to a | |
| 238 | `store.User`; it collects their ids into `added`, and after both loops | |
| 239 | succeed: | |
| 240 | ||
| 241 | ```go | |
| 242 | notify(c, added, notice{repo: repo, kind: "issue", direct: true, | |
| 243 | subject: fmt.Sprintf("[%s] #%d: %s", repo.Path(), issue.Number, issue.Title), | |
| 244 | action: fmt.Sprintf("assigned you to #%d", issue.Number), | |
| 245 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | |
| 246 | ``` | |
| 247 | ||
| 248 | `direct: true`, as mentions are: an assignment is addressed to someone, | |
| 249 | and widening it to watchers would report "assigned you" to people it did | |
| 250 | not assign. Removals file nothing. `notify()` already drops the actor, | |
| 251 | so assigning yourself is silent. | |
| 252 | ||
| 253 | There is no `mr assign` command; `issue assign` is the only site. | |
| 254 | ||
| 255 | ## Web | |
| 256 | ||
| 257 | `/settings/notifications` grows a push row beside mail and watch, and a | |
| 258 | device list with a remove button per row, dispatching the same commands | |
| 259 | through `runControlStdin`. | |
| 260 | ||
| 261 | There is no web form to add a device — a browser cannot produce an APNs | |
| 262 | token. `notifications device add` is therefore reachable on the web in | |
| 263 | the sense that every command is, but no page offers it, which is the | |
| 264 | Parity page's "CLI only, for now" made literal rather than a refusal. | |
| 265 | ||
| 266 | A new template means a row in `TestMainWidthClass` | |
| 267 | (`internal/web/web_test.go`) or CI fails on it. | |
| 268 | ||
| 269 | ## The app | |
| 270 | ||
| 271 | Separate merge request on `krz/gitbay-ios`, after the server ships. | |
| 272 | `gitbay-ios` has no push scaffolding today: no app delegate adaptor, no | |
| 273 | `UNUserNotificationCenter` use, no background modes. | |
| 274 | ||
| 275 | - `UIApplicationDelegateAdaptor` for | |
| 276 | `didRegisterForRemoteNotificationsWithDeviceToken`, which is the only | |
| 277 | way to get the token. | |
| 278 | - Permission requested on first visit to the notifications screen, not | |
| 279 | at launch. A prompt before the user has seen what the app does is the | |
| 280 | prompt they deny. | |
| 281 | - On the token arriving, and on each sign-in, `notifications device | |
| 282 | add` with a label from `UIDevice.current.name`. On sign-out, | |
| 283 | `notifications device remove`. | |
| 284 | - `userNotificationCenter(_:didReceive:)` reads `path` and routes | |
| 285 | through the navigation the inbox rows already use. | |
| 286 | - Badge from the unread count the dashboard already returns. | |
| 287 | ||
| 288 | Because an account is an instance plus a user, a device registers once | |
| 289 | per signed-in account and holds one row per account it is signed in to. | |
| 290 | A token registered against two instances gets two pushes, which is | |
| 291 | correct — they are two accounts. | |
| 292 | ||
| 293 | Ships with the Push Notifications capability on `org.gitbay.gitbay` | |
| 294 | (team ZCNAX3VL9D), a privacy nutrition label declaring the device token | |
| 295 | under Identifiers, and a resubmission. | |
| 296 | ||
| 297 | `DESIGN.org`'s "No push notifications" gap row is rewritten to point at | |
| 298 | the implemented feature. | |
| 299 | ||
| 300 | ## Sequencing | |
| 301 | ||
| 302 | Server first, app second, in separate merge requests on separate | |
| 303 | repositories. The server half is self-contained and testable against a | |
| 304 | fake APNs endpoint, which keeps an App Store review off the critical | |
| 305 | path. Between the two, `[push]` is configured and inert — nothing has | |
| 306 | registered a device, so nothing queues. | |
| 307 | ||
| 308 | `[push]` stays `enabled = false` on bay1 until the app is submitted. | |
| 309 | ||
| 310 | The implementation plan that follows this spec covers the server half | |
| 311 | only. The app half is scoped here to fix the contract it has to meet — | |
| 312 | the payload keys, the registration calls, the capability and the | |
| 313 | nutrition label — and gets its own plan on `krz/gitbay-ios` once the | |
| 314 | server has shipped. | |
| 315 | ||
| 316 | ## Testing | |
| 317 | ||
| 318 | Unit, `internal/push`: | |
| 319 | ||
| 320 | - The JWT signs, carries `alg: ES256` and the key id in its header, | |
| 321 | `iss`/`iat`/`sub` in its claims, and verifies against the public half | |
| 322 | of a generated test key. | |
| 323 | - The cached token is reused inside fifty minutes and reminted after. | |
| 324 | - Response mapping: 200 sent, 410 and BadDeviceToken reap, 429 and 503 | |
| 325 | retry, 403 dead-letters. | |
| 326 | ||
| 327 | Unit, `internal/store`: `EnqueuePush` writes one row per device, none | |
| 328 | when `notify_push` is off, none when the account has no devices. | |
| 329 | ||
| 330 | Unit, `internal/config`: each malformed `[push]` is refused at load. | |
| 331 | ||
| 332 | `TestStdinCommandsReadStdin` covers `device add` once it is registered | |
| 333 | with `ReadsStdin`; `TestReadOnlyCommandsWriteNothing` covers | |
| 334 | `device list`. Both are existing registry tests that pick up the new | |
| 335 | commands without being edited — the `cmd/gitbay/main.go` `pass()` table | |
| 336 | does need the new commands or its coverage test fails. | |
| 337 | ||
| 338 | E2E, `e2e/push_test.go`: an httptest server standing in for APNs, its | |
| 339 | host injected through config. Register a device, act as another user on | |
| 340 | a watched repository, assert the queue drains and the fake received a | |
| 341 | payload whose body matches the inbox row's summary. Then a 410 and | |
| 342 | assert the device row is gone. The fake speaks HTTP/1.1 — the real | |
| 343 | transport is h2 by ALPN, which is stdlib behaviour and not this | |
| 344 | repository's to test. | |
| 345 | ||
| 346 | E2E, `e2e/assign_test.go` or the existing issue test: assigning files an | |
| 347 | inbox row for the assignee and none for the actor. | |
| 348 | ||
| 349 | Verified already: outbound HTTP/2 from bay1 to `api.push.apple.com:443` | |
| 350 | reaches Apple — a GET to `/3/device/test` answers `405` over h2. | |
| 351 | ||
| 352 | ## Docs | |
| 353 | ||
| 354 | - Wiki `Parity`: rows for the four commands, in the merge request that | |
| 355 | adds them. | |
| 356 | - Wiki `Admin`: the `[push]` section, obtaining a `.p8`, and the | |
| 357 | one-instance-one-bundle-ID constraint for self-hosters. | |
| 358 | - Wiki `Users`: `notifications settings push`, and what a device row is. | |
| 359 | - `CHANGELOG.org`. | |
| 360 | - `krz/gitbay-ios` `DESIGN.org`: the gap row. | |