Commit e4483e3d3c

e4483e3d3c7765a51ac2def8e6799c1e3dff5bed

parent: cfeb70f956

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:02 UTC

docs: spec for iOS push notifications

gitbayd speaks APNs directly with an operator-supplied .p8; notices
become queue rows drained like the mail queue. Covers the schema, the
[push] config, four control commands, payload shape, the app's half of
the contract, and adding a notice to issue assign.

Ref #89
docs/specs/2026-09-20-ios-push-notifications-design.md added +360
@@ -0,0 +1,360 @@
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`. |
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 "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
215string 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
218to 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
222two notices.
223
224Response 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
228payload will not fix it.
229
230`internal/notify`'s `redactAddresses` has no analogue to write — a
231device token is not a mail address — but the token is never logged
232either. Log lines name the device id.
233
234## Assignment notices
235
236`runIssueAssign` (`internal/control/issue.go:423`) files a notice for
237each 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
239succeed:
240
241```go
242notify(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,
249and widening it to watchers would report "assigned you" to people it did
250not assign. Removals file nothing. `notify()` already drops the actor,
251so assigning yourself is silent.
252
253There 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
258device list with a remove button per row, dispatching the same commands
259through `runControlStdin`.
260
261There is no web form to add a device — a browser cannot produce an APNs
262token. `notifications device add` is therefore reachable on the web in
263the sense that every command is, but no page offers it, which is the
264Parity page's "CLI only, for now" made literal rather than a refusal.
265
266A new template means a row in `TestMainWidthClass`
267(`internal/web/web_test.go`) or CI fails on it.
268
269## The app
270
271Separate 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
288Because an account is an instance plus a user, a device registers once
289per signed-in account and holds one row per account it is signed in to.
290A token registered against two instances gets two pushes, which is
291correct — they are two accounts.
292
293Ships with the Push Notifications capability on `org.gitbay.gitbay`
294(team ZCNAX3VL9D), a privacy nutrition label declaring the device token
295under Identifiers, and a resubmission.
296
297`DESIGN.org`'s "No push notifications" gap row is rewritten to point at
298the implemented feature.
299
300## Sequencing
301
302Server first, app second, in separate merge requests on separate
303repositories. The server half is self-contained and testable against a
304fake APNs endpoint, which keeps an App Store review off the critical
305path. Between the two, `[push]` is configured and inert — nothing has
306registered a device, so nothing queues.
307
308`[push]` stays `enabled = false` on bay1 until the app is submitted.
309
310The implementation plan that follows this spec covers the server half
311only. The app half is scoped here to fix the contract it has to meet —
312the payload keys, the registration calls, the capability and the
313nutrition label — and gets its own plan on `krz/gitbay-ios` once the
314server has shipped.
315
316## Testing
317
318Unit, `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
327Unit, `internal/store`: `EnqueuePush` writes one row per device, none
328when `notify_push` is off, none when the account has no devices.
329
330Unit, `internal/config`: each malformed `[push]` is refused at load.
331
332`TestStdinCommandsReadStdin` covers `device add` once it is registered
333with `ReadsStdin`; `TestReadOnlyCommandsWriteNothing` covers
334`device list`. Both are existing registry tests that pick up the new
335commands without being edited — the `cmd/gitbay/main.go` `pass()` table
336does need the new commands or its coverage test fails.
337
338E2E, `e2e/push_test.go`: an httptest server standing in for APNs, its
339host injected through config. Register a device, act as another user on
340a watched repository, assert the queue drains and the fake received a
341payload whose body matches the inbox row's summary. Then a 410 and
342assert the device row is gone. The fake speaks HTTP/1.1 — the real
343transport is h2 by ALPN, which is stdlib behaviour and not this
344repository's to test.
345
346E2E, `e2e/assign_test.go` or the existing issue test: assigning files an
347inbox row for the assignee and none for the actor.
348
349Verified already: outbound HTTP/2 from bay1 to `api.push.apple.com:443`
350reaches 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.