docs/plans/2026-09-20-ios-push-notifications.md
2420 lines · 74949 bytes
1# iOS push notifications — server half — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** gitbayd delivers activity notices to registered Apple devices over APNs, as a third route beside the inbox row and the activity mail `notify()` already sends.
6
7**Architecture:** A notice becomes one `push_queue` row per registered device. An `internal/push.Deliverer` drains the queue on a ticker and POSTs each row to APNs over HTTP/2, authenticated by an ES256 JWT signed with an operator-supplied `.p8`. This is the third instance of a shape the repository already has twice: `internal/notify` (mail) and `internal/webhook` (HTTP POSTs) — a queue table, a drainer goroutine, exponential backoff, dead-lettering.
8
9**Tech Stack:** Go 1.27, SQLite (hand-written SQL, no ORM), stdlib only. No new module dependencies: `net/http` negotiates HTTP/2 over ALPN, and the JWT is `crypto/ecdsa` plus `encoding/json`.
10
11**Spec:** `docs/specs/2026-09-20-ios-push-notifications-design.md`
12
13## Global Constraints
14
15- **No new Go module dependencies.** Nothing is added to `go.mod`. APNs needs HTTP/2, which stdlib `net/http` does over ALPN. The JWT is hand-rolled; do not reach for a JWT library.
16- **Never attribute anything to an assistant or model.** Not in commits, not in code comments, not in MR bodies, not in docs.
17- **Never push to `main`.** All work is on the `ios-push` branch in the worktree `/Users/cmc/git/krz/gitbay-push`. `require_mr` is on for this repository — a direct push to `main` is refused in pre-receive.
18- **Commits must be signed.** This repository refuses unsigned commits. Use `git -c commit.gpgsign=true commit`.
19- **Commit messages reference the issue:** `Ref #89`, and `Closes #89` on the last one.
20- **Secrets on stdin, never argv.** `/proc` is world-readable.
21- **A command that reads stdin must set `ReadsStdin: true`** on its `Command`. Otherwise `control.go` swaps in an empty reader and `--file -` silently stores nothing — it does not error.
22- **A new control command needs a `pass()` entry** in `cmd/gitbay/main.go` or the CLI coverage test fails.
23- **A new page template needs a row in `TestMainWidthClass`** (`internal/web/web_test.go`) or CI fails on it.
24- **Test scope while working:** build, `go vet ./...`, and the unit tests of the packages you touched. Full `go test ./...` belongs to CI on bay1 — the e2e suite is most of the runtime. Run `go vet ./...` after any signature change; `go build` skips `_test.go` files and will not catch a stale test caller.
25- **Never define a color only inside the dark media query** (relevant only to Task 10).
26
27---
28
29### Task 1: Migration 0059 and the device table
30
31**Files:**
32- Create: `internal/store/migrations/0059_push.up.sql`
33- Create: `internal/store/migrations/0059_push.down.sql`
34- Create: `internal/store/push.go`
35- Test: `internal/store/push_test.go`
36
37**Interfaces:**
38- Consumes: nothing.
39- Produces:
40 - `type PushDevice struct { ID int64; UserID int64; Token string; Label string; CreatedAt string; LastSeenAt string }`
41 - `func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error)`
42 - `func (s *Store) PushDevices(userID int64) ([]PushDevice, error)`
43 - `func (s *Store) RemovePushDevice(userID, id int64) error`
44 - `func (s *Store) PushEnabled(userID int64) (bool, error)`
45 - `func (s *Store) SetPushEnabled(userID int64, on bool) error`
46
47- [ ] **Step 1: Write the migration**
48
49`internal/store/migrations/0059_push.up.sql`:
50
51```sql
52-- Apple devices an account has registered, and the queue of pushes bound
53-- for them. The mail queue's table is named `notifications`, so this one
54-- cannot be; the columns mirror it so the drainer is the mailer's loop.
55CREATE TABLE push_devices (
56 id INTEGER PRIMARY KEY,
57 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
58 token TEXT NOT NULL UNIQUE,
59 label TEXT NOT NULL DEFAULT '',
60 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
61 last_seen_at TEXT
62);
63CREATE INDEX push_devices_user ON push_devices(user_id);
64
65CREATE TABLE push_queue (
66 id INTEGER PRIMARY KEY,
67 device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
68 title TEXT NOT NULL,
69 body TEXT NOT NULL,
70 path TEXT NOT NULL,
71 attempts INTEGER NOT NULL DEFAULT 0,
72 next_attempt_at TEXT,
73 sent_at TEXT,
74 failed_at TEXT,
75 last_error TEXT,
76 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
77);
78CREATE INDEX push_queue_due ON push_queue(next_attempt_at)
79 WHERE sent_at IS NULL AND failed_at IS NULL;
80
81-- Whether activity reaches the account's registered devices. Defaults on
82-- and costs nothing for an account with no devices; it exists so a user
83-- with a phone and an iPad silences both without deregistering each.
84ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1;
85```
86
87`internal/store/migrations/0059_push.down.sql`:
88
89```sql
90DROP TABLE push_queue;
91DROP TABLE push_devices;
92ALTER TABLE users DROP COLUMN notify_push;
93```
94
95- [ ] **Step 2: Write the failing test**
96
97`internal/store/push_test.go`:
98
99```go
100package store
101
102import "testing"
103
104func TestPushDevices(t *testing.T) {
105 s := testStore(t)
106 uid := testUser(t, s, "alice")
107
108 if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil {
109 t.Fatalf("AddPushDevice: %v", err)
110 }
111 devices, err := s.PushDevices(uid)
112 if err != nil {
113 t.Fatalf("PushDevices: %v", err)
114 }
115 if len(devices) != 1 || devices[0].Token != "tok-a" || devices[0].Label != "iphone" {
116 t.Fatalf("got %+v", devices)
117 }
118
119 // Apple reuses tokens: re-registering updates the label and the owner
120 // rather than erroring, so a reinstall under another account works.
121 bob := testUser(t, s, "bob")
122 if _, err := s.AddPushDevice(bob, "tok-a", "ipad"); err != nil {
123 t.Fatalf("re-register: %v", err)
124 }
125 if d, _ := s.PushDevices(uid); len(d) != 0 {
126 t.Fatalf("token still owned by alice: %+v", d)
127 }
128 d, _ := s.PushDevices(bob)
129 if len(d) != 1 || d[0].Label != "ipad" {
130 t.Fatalf("got %+v", d)
131 }
132
133 // Removal is scoped to the owner: alice cannot remove bob's device.
134 if err := s.RemovePushDevice(uid, d[0].ID); err != ErrNotFound {
135 t.Fatalf("cross-account remove: got %v, want ErrNotFound", err)
136 }
137 if err := s.RemovePushDevice(bob, d[0].ID); err != nil {
138 t.Fatalf("RemovePushDevice: %v", err)
139 }
140 if d, _ := s.PushDevices(bob); len(d) != 0 {
141 t.Fatalf("device survived removal: %+v", d)
142 }
143}
144
145func TestPushEnabledDefaultsOn(t *testing.T) {
146 s := testStore(t)
147 uid := testUser(t, s, "alice")
148 on, err := s.PushEnabled(uid)
149 if err != nil {
150 t.Fatalf("PushEnabled: %v", err)
151 }
152 if !on {
153 t.Fatal("notify_push should default on")
154 }
155 if err := s.SetPushEnabled(uid, false); err != nil {
156 t.Fatalf("SetPushEnabled: %v", err)
157 }
158 if on, _ := s.PushEnabled(uid); on {
159 t.Fatal("SetPushEnabled(false) did not stick")
160 }
161}
162```
163
164Check the helper names `testStore` and `testUser` against the existing
165`internal/store/inbox_test.go` and use whatever that file uses; do not
166invent new helpers.
167
168- [ ] **Step 3: Run the test to verify it fails**
169
170Run: `go test ./internal/store/ -run 'TestPush' -v`
171Expected: FAIL — `s.AddPushDevice undefined`.
172
173- [ ] **Step 4: Write the implementation**
174
175`internal/store/push.go`:
176
177```go
178package store
179
180import (
181 "database/sql"
182 "errors"
183)
184
185// PushDevice is one Apple device an account has registered. Token is the
186// APNs device token: an address, not a credential, but device-identifying
187// and never logged or echoed in full.
188type PushDevice struct {
189 ID int64
190 UserID int64
191 Token string
192 Label string
193 CreatedAt string
194 LastSeenAt string
195}
196
197// AddPushDevice registers a token to an account. A token already present
198// changes hands rather than erroring: Apple reuses tokens, and a reinstall
199// hands the same one to whichever account signs in next.
200func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) {
201 res, err := s.DB.Exec(`
202 INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?)
203 ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`,
204 userID, token, label)
205 if err != nil {
206 return 0, err
207 }
208 return res.LastInsertId()
209}
210
211func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
212 rows, err := s.DB.Query(`
213 SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '')
214 FROM push_devices WHERE user_id = ? ORDER BY id`, userID)
215 if err != nil {
216 return nil, err
217 }
218 defer rows.Close()
219 var out []PushDevice
220 for rows.Next() {
221 var d PushDevice
222 if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil {
223 return nil, err
224 }
225 out = append(out, d)
226 }
227 return out, rows.Err()
228}
229
230// RemovePushDevice deletes one of the account's own devices. Scoping the
231// delete by user_id rather than checking ownership first means another
232// account's id is ErrNotFound, which is the same answer as an id that
233// never existed — a caller learns nothing about other accounts' devices.
234func (s *Store) RemovePushDevice(userID, id int64) error {
235 res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID)
236 if err != nil {
237 return err
238 }
239 n, err := res.RowsAffected()
240 if err != nil {
241 return err
242 }
243 if n == 0 {
244 return ErrNotFound
245 }
246 return nil
247}
248
249func (s *Store) PushEnabled(userID int64) (bool, error) {
250 var on int
251 err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on)
252 if errors.Is(err, sql.ErrNoRows) {
253 return false, ErrNotFound
254 }
255 return on != 0, err
256}
257
258func (s *Store) SetPushEnabled(userID int64, on bool) error {
259 v := 0
260 if on {
261 v = 1
262 }
263 _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID)
264 return err
265}
266```
267
268- [ ] **Step 5: Run the tests to verify they pass**
269
270Run: `go test ./internal/store/ -run 'TestPush' -v`
271Expected: PASS, both tests.
272
273- [ ] **Step 6: Commit**
274
275```bash
276git add internal/store/migrations/0059_push.up.sql internal/store/migrations/0059_push.down.sql internal/store/push.go internal/store/push_test.go
277git -c commit.gpgsign=true commit -m "store: push device registrations
278
279Migration 0059 adds push_devices, push_queue and users.notify_push. A
280re-registered token changes hands rather than erroring, since Apple
281reuses tokens across reinstalls.
282
283Ref #89"
284```
285
286---
287
288### Task 2: The push queue and its retention
289
290**Files:**
291- Modify: `internal/store/push.go`
292- Modify: `internal/store/retention.go:30-35` (the `Retention` struct) and the `aged` table around `:66-75`
293- Modify: `internal/config/config.go` (the `Retention` struct and its `Durations` method)
294- Modify: `cmd/gitbayd/main.go:518-520`
295- Test: `internal/store/push_test.go`
296
297**Interfaces:**
298- Consumes: `PushDevice`, `PushEnabled` from Task 1.
299- Produces:
300 - `type QueuedPush struct { ID int64; DeviceID int64; Token string; Title string; Body string; Path string; Attempts int }`
301 - `func (s *Store) EnqueuePush(userID int64, title, body, path string) error`
302 - `func (s *Store) DuePush(limit int) ([]QueuedPush, error)`
303 - `func (s *Store) MarkPushSent(id int64) error`
304 - `func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error`
305 - `func (s *Store) DeletePushDeviceByToken(token string) error`
306 - `config.Retention.Push string` with toml key `push`, and a fifth return from `Durations()`
307 - `store.Retention.Push time.Duration`
308
309- [ ] **Step 1: Write the failing test**
310
311Append to `internal/store/push_test.go`:
312
313```go
314func TestEnqueuePush(t *testing.T) {
315 s := testStore(t)
316 uid := testUser(t, s, "alice")
317 s.AddPushDevice(uid, "tok-a", "iphone")
318 s.AddPushDevice(uid, "tok-b", "ipad")
319
320 // One row per device, so a retry to the phone does not resend to the
321 // iPad.
322 if err := s.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
323 t.Fatalf("EnqueuePush: %v", err)
324 }
325 due, err := s.DuePush(20)
326 if err != nil {
327 t.Fatalf("DuePush: %v", err)
328 }
329 if len(due) != 2 {
330 t.Fatalf("want a row per device, got %d", len(due))
331 }
332 if due[0].Token == "" || due[0].Body != "cmc opened issue #12" {
333 t.Fatalf("got %+v", due[0])
334 }
335
336 // Sent rows stop being due.
337 if err := s.MarkPushSent(due[0].ID); err != nil {
338 t.Fatalf("MarkPushSent: %v", err)
339 }
340 if due, _ := s.DuePush(20); len(due) != 1 {
341 t.Fatalf("sent row still due")
342 }
343
344 // A failure with a next attempt in the future is not due yet.
345 next := time.Now().Add(time.Hour)
346 if err := s.MarkPushFailed(due[1].ID, "503", &next); err != nil {
347 t.Fatalf("MarkPushFailed: %v", err)
348 }
349 if due, _ := s.DuePush(20); len(due) != 0 {
350 t.Fatalf("backed-off row is due too early")
351 }
352}
353
354func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) {
355 s := testStore(t)
356 uid := testUser(t, s, "alice")
357
358 // No devices: nothing queued, no error.
359 if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil {
360 t.Fatalf("EnqueuePush with no devices: %v", err)
361 }
362 if due, _ := s.DuePush(20); len(due) != 0 {
363 t.Fatalf("queued for an account with no devices")
364 }
365
366 // Setting off: nothing queued.
367 s.AddPushDevice(uid, "tok-a", "iphone")
368 s.SetPushEnabled(uid, false)
369 if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil {
370 t.Fatalf("EnqueuePush with push off: %v", err)
371 }
372 if due, _ := s.DuePush(20); len(due) != 0 {
373 t.Fatalf("queued with notify_push off")
374 }
375}
376
377func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) {
378 s := testStore(t)
379 uid := testUser(t, s, "alice")
380 s.AddPushDevice(uid, "tok-a", "iphone")
381 s.EnqueuePush(uid, "t", "b", "p")
382
383 if err := s.DeletePushDeviceByToken("tok-a"); err != nil {
384 t.Fatalf("DeletePushDeviceByToken: %v", err)
385 }
386 if d, _ := s.PushDevices(uid); len(d) != 0 {
387 t.Fatalf("device survived")
388 }
389 // push_queue.device_id is ON DELETE CASCADE, so the queued rows go
390 // with it rather than being retried at a dead token forever.
391 if due, _ := s.DuePush(20); len(due) != 0 {
392 t.Fatalf("queued rows outlived their device")
393 }
394}
395```
396
397Add `"time"` to the test file's imports.
398
399- [ ] **Step 2: Run the test to verify it fails**
400
401Run: `go test ./internal/store/ -run 'TestEnqueuePush|TestDeletePushDevice' -v`
402Expected: FAIL — `s.EnqueuePush undefined`.
403
404- [ ] **Step 3: Write the queue implementation**
405
406Append to `internal/store/push.go` (and add `"time"` to its imports):
407
408```go
409// QueuedPush is one pending push, joined to the token it is bound for so
410// the drainer needs one query rather than two.
411type QueuedPush struct {
412 ID int64
413 DeviceID int64
414 Token string
415 Title string
416 Body string
417 Path string
418 Attempts int
419}
420
421// EnqueuePush writes one row per registered device, and nothing when the
422// account has push off or no devices — the same shape as
423// ActivityMailAddress returning "" when notify_mail is off. Mute, watch
424// and actor-exclusion are already settled by NotifyRecipients before a
425// caller reaches here.
426func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
427 on, err := s.PushEnabled(userID)
428 if err != nil || !on {
429 return err
430 }
431 _, err = s.DB.Exec(`
432 INSERT INTO push_queue (device_id, title, body, path)
433 SELECT id, ?, ?, ? FROM push_devices WHERE user_id = ?`,
434 title, body, path, userID)
435 return err
436}
437
438func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
439 rows, err := s.DB.Query(`
440 SELECT q.id, q.device_id, d.token, q.title, q.body, q.path, q.attempts
441 FROM push_queue q JOIN push_devices d ON d.id = q.device_id
442 WHERE q.sent_at IS NULL AND q.failed_at IS NULL
443 AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
444 ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit)
445 if err != nil {
446 return nil, err
447 }
448 defer rows.Close()
449 var out []QueuedPush
450 for rows.Next() {
451 var p QueuedPush
452 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil {
453 return nil, err
454 }
455 out = append(out, p)
456 }
457 return out, rows.Err()
458}
459
460func (s *Store) MarkPushSent(id int64) error {
461 _, err := s.DB.Exec(
462 "UPDATE push_queue SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id)
463 return err
464}
465
466func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error {
467 if nextAt == nil {
468 _, err := s.DB.Exec(
469 "UPDATE push_queue SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?",
470 errMsg, id)
471 return err
472 }
473 _, err := s.DB.Exec(
474 "UPDATE push_queue SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?",
475 errMsg, fmtTime(*nextAt), id)
476 return err
477}
478
479// DeletePushDeviceByToken drops a device Apple has told us is gone. The
480// queue rows cascade, so nothing is left retrying at a dead token.
481func (s *Store) DeletePushDeviceByToken(token string) error {
482 _, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token)
483 return err
484}
485```
486
487- [ ] **Step 4: Run the tests to verify they pass**
488
489Run: `go test ./internal/store/ -run 'TestPush|TestEnqueuePush|TestDeletePushDevice' -v`
490Expected: PASS.
491
492If `TestDeletePushDeviceByTokenTakesItsQueue` fails with the queue rows
493surviving, foreign keys are not on for that connection. Check how
494`testStore` opens the database against the rest of `internal/store` —
495do not work around it by deleting the queue rows by hand.
496
497- [ ] **Step 5: Add the retention key**
498
499In `internal/config/config.go`, add to the `Retention` struct:
500
501```go
502 // Push is the outbound device queue: rows already sent or given up on.
503 Push string `toml:"push"`
504```
505
506Find `Retention.Durations()` in the same file and give it a fifth return
507value parsed the same way as `Mail`.
508
509In `internal/store/retention.go`, add to the `Retention` struct:
510
511```go
512 Push time.Duration
513```
514
515and to the `aged` slice in `Sweep`, after the `notifications` row:
516
517```go
518 {"push_queue", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Push},
519```
520
521In `cmd/gitbayd/main.go`, the `sweep` function around line 518:
522
523```go
524 audit, events, deliveries, mail, push := cfg.Retention.Durations()
525 r := store.Retention{Audit: audit, Events: events,
526 WebhookDeliveries: deliveries, Mail: mail, Push: push}
527```
528
529- [ ] **Step 6: Build and vet**
530
531Run: `go build ./... && go vet ./...`
532Expected: clean. `Durations()` gained a return value, so `go vet` is what
533catches any caller `go build` skipped — check for callers in
534`internal/config`'s own tests.
535
536Run: `go test ./internal/config/ ./internal/store/ ./cmd/gitbayd/`
537Expected: PASS.
538
539- [ ] **Step 7: Commit**
540
541```bash
542git add internal/store/push.go internal/store/push_test.go internal/store/retention.go internal/config/config.go cmd/gitbayd/main.go
543git -c commit.gpgsign=true commit -m "store: the push queue, swept like the mail queue
544
545One row per device per notice, so a retry to one device does not
546resend to another. EnqueuePush writes nothing when the account has
547push off or no devices. [retention] push caps the table.
548
549Ref #89"
550```
551
552---
553
554### Task 3: The `[push]` config section
555
556**Files:**
557- Modify: `internal/config/config.go`
558- Test: `internal/config/config_test.go`
559
560**Interfaces:**
561- Consumes: nothing.
562- Produces:
563 - `type Push struct { Enabled bool; KeyFile string; KeyID string; TeamID string; Topic string; Environment string }` with toml keys `enabled`, `key_file`, `key_id`, `team_id`, `topic`, `environment`
564 - `Config.Push Push` with toml key `push`
565 - `func (p Push) Host() string` returning `api.push.apple.com` or `api.sandbox.push.apple.com`, overridden by `GITBAY_APNS_HOST`
566
567- [ ] **Step 1: Write the failing test**
568
569Append to `internal/config/config_test.go`. It already has
570`writeConfig(t, body) string` and a `minimal` constant; use both rather
571than adding a second way to load a config.
572
573```go
574// writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's
575// .p8 provider key, and returns its path.
576func writeP8(t *testing.T) string {
577 t.Helper()
578 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
579 if err != nil {
580 t.Fatal(err)
581 }
582 der, err := x509.MarshalPKCS8PrivateKey(key)
583 if err != nil {
584 t.Fatal(err)
585 }
586 p := filepath.Join(t.TempDir(), "apns.p8")
587 f, err := os.Create(p)
588 if err != nil {
589 t.Fatal(err)
590 }
591 defer f.Close()
592 if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil {
593 t.Fatal(err)
594 }
595 return p
596}
597
598func TestPushConfigValidation(t *testing.T) {
599 keyPath := writeP8(t)
600 full := `
601[push]
602enabled = true
603key_file = "` + keyPath + `"
604key_id = "KEYID"
605team_id = "TEAMID"
606topic = "org.gitbay.gitbay"
607environment = "production"
608`
609 cases := []struct {
610 name string
611 body string
612 want string // substring of the expected error; "" means valid
613 }{
614 {"disabled needs nothing", "\n[push]\nenabled = false\n", ""},
615 {"complete is valid", full, ""},
616 {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"},
617 {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"},
618 {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"},
619 {"environment must be a known name",
620 strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1),
621 "push.environment"},
622 }
623 for _, tc := range cases {
624 t.Run(tc.name, func(t *testing.T) {
625 _, err := Load(writeConfig(t, minimal+tc.body))
626 if tc.want == "" {
627 if err != nil {
628 t.Fatalf("want valid, got %v", err)
629 }
630 return
631 }
632 if err == nil || !strings.Contains(err.Error(), tc.want) {
633 t.Fatalf("want an error mentioning %q, got %v", tc.want, err)
634 }
635 })
636 }
637}
638
639// A key_file that exists but is not a PKCS#8 EC key is refused at load,
640// not at the first notice: the failure mode otherwise is a queue that
641// fills and dead-letters with nobody watching.
642func TestPushConfigRejectsAnUnparseableKey(t *testing.T) {
643 p := filepath.Join(t.TempDir(), "junk.p8")
644 if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil {
645 t.Fatal(err)
646 }
647 body := `
648[push]
649enabled = true
650key_file = "` + p + `"
651key_id = "K"
652team_id = "T"
653topic = "org.gitbay.gitbay"
654environment = "production"
655`
656 _, err := Load(writeConfig(t, minimal+body))
657 if err == nil || !strings.Contains(err.Error(), "push.key_file") {
658 t.Fatalf("want a push.key_file error, got %v", err)
659 }
660}
661
662func TestPushHost(t *testing.T) {
663 if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" {
664 t.Fatalf("production host = %q", got)
665 }
666 if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" {
667 t.Fatalf("sandbox host = %q", got)
668 }
669 t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234")
670 if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" {
671 t.Fatalf("GITBAY_APNS_HOST ignored: %q", got)
672 }
673}
674```
675
676Add `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/x509` and
677`encoding/pem` to the test file's imports.
678
679- [ ] **Step 2: Run the test to verify it fails**
680
681Run: `go test ./internal/config/ -run TestPush -v`
682Expected: FAIL — `Push` undefined.
683
684- [ ] **Step 3: Write the implementation**
685
686Add to `internal/config/config.go`, beside the other section structs:
687
688```go
689// Push is APNs delivery to registered Apple devices. A key belongs to a
690// bundle ID, so an instance pushes to the app built under the topic named
691// here and no other; a self-hoster points this at their own key and their
692// own build.
693type Push struct {
694 Enabled bool `toml:"enabled"`
695 KeyFile string `toml:"key_file"`
696 KeyID string `toml:"key_id"`
697 TeamID string `toml:"team_id"`
698 Topic string `toml:"topic"` // the app's bundle identifier
699 // Environment is a name rather than a URL so a typo cannot aim the
700 // key at a host that is not Apple's.
701 Environment string `toml:"environment"` // production | sandbox
702}
703
704// Host is the APNs endpoint for the configured environment.
705// GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for
706// the retention sweep.
707func (p Push) Host() string {
708 if h := os.Getenv("GITBAY_APNS_HOST"); h != "" {
709 return h
710 }
711 if p.Environment == "sandbox" {
712 return "api.sandbox.push.apple.com"
713 }
714 return "api.push.apple.com"
715}
716```
717
718Add the field to `Config`:
719
720```go
721 Push Push `toml:"push"`
722```
723
724In the validate function, beside the `MaxSnippetsPerUser` check:
725
726```go
727 if c.Push.Enabled {
728 for _, f := range []struct{ name, val string }{
729 {"push.key_file", c.Push.KeyFile},
730 {"push.key_id", c.Push.KeyID},
731 {"push.team_id", c.Push.TeamID},
732 {"push.topic", c.Push.Topic},
733 } {
734 if f.val == "" {
735 errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name))
736 }
737 }
738 if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil {
739 errs = append(errs, err)
740 }
741 if c.Push.KeyFile != "" {
742 if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil {
743 errs = append(errs, fmt.Errorf("push.key_file: %w", err))
744 }
745 }
746 }
747```
748
749And the key loader, in the same file:
750
751```go
752// LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8
753// P-256 private key. Read at startup and validated there, so a
754// misconfigured [push] refuses to start rather than filling a queue
755// nobody is watching.
756func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
757 data, err := os.ReadFile(path)
758 if err != nil {
759 return nil, err
760 }
761 block, _ := pem.Decode(data)
762 if block == nil {
763 return nil, errors.New("not PEM")
764 }
765 any, err := x509.ParsePKCS8PrivateKey(block.Bytes)
766 if err != nil {
767 return nil, err
768 }
769 key, ok := any.(*ecdsa.PrivateKey)
770 if !ok {
771 return nil, errors.New("not an EC private key")
772 }
773 return key, nil
774}
775```
776
777Add `crypto/ecdsa`, `crypto/x509` and `encoding/pem` to the file's imports.
778
779- [ ] **Step 4: Run the tests to verify they pass**
780
781Run: `go test ./internal/config/ -v`
782Expected: PASS. The whole package, because adding a `Config` field can
783break a test that round-trips the struct or asserts on unknown keys.
784
785- [ ] **Step 5: Commit**
786
787```bash
788git add internal/config/config.go internal/config/config_test.go
789git -c commit.gpgsign=true commit -m "config: the [push] section
790
791Validated at load: with push.enabled, the four fields are required,
792environment is one of two names, and key_file must parse as a PKCS#8
793EC key. GITBAY_APNS_HOST redirects the endpoint for tests.
794
795Ref #89"
796```
797
798---
799
800### Task 4: The APNs provider token
801
802**Files:**
803- Create: `internal/push/token.go`
804- Test: `internal/push/token_test.go`
805
806**Interfaces:**
807- Consumes: nothing. `token.go` takes a parsed key and two strings; it imports no `config` symbol.
808- Produces:
809 - `type tokenSource struct { key *ecdsa.PrivateKey; keyID, teamID string; now func() time.Time; mu sync.Mutex; cached string; issued time.Time }`
810 - `func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource`
811 - `func (t *tokenSource) token() (string, error)`
812
813- [ ] **Step 1: Write the failing test**
814
815`internal/push/token_test.go`:
816
817```go
818package push
819
820import (
821 "crypto/ecdsa"
822 "crypto/elliptic"
823 "crypto/rand"
824 "crypto/sha256"
825 "encoding/base64"
826 "encoding/json"
827 "math/big"
828 "strings"
829 "testing"
830 "time"
831)
832
833func testKey(t *testing.T) *ecdsa.PrivateKey {
834 t.Helper()
835 k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
836 if err != nil {
837 t.Fatal(err)
838 }
839 return k
840}
841
842func TestTokenShapeAndSignature(t *testing.T) {
843 key := testKey(t)
844 ts := newTokenSource(key, "KEYID123", "TEAMID456")
845 tok, err := ts.token()
846 if err != nil {
847 t.Fatalf("token: %v", err)
848 }
849 parts := strings.Split(tok, ".")
850 if len(parts) != 3 {
851 t.Fatalf("want three dot-separated parts, got %d", len(parts))
852 }
853
854 var hdr struct{ Alg, Kid string }
855 raw, _ := base64.RawURLEncoding.DecodeString(parts[0])
856 if err := json.Unmarshal(raw, &hdr); err != nil {
857 t.Fatalf("header: %v", err)
858 }
859 if hdr.Alg != "ES256" || hdr.Kid != "KEYID123" {
860 t.Fatalf("header = %+v", hdr)
861 }
862
863 // APNs provider tokens carry iss (team id) and iat, and nothing else.
864 var claims map[string]any
865 raw, _ = base64.RawURLEncoding.DecodeString(parts[1])
866 if err := json.Unmarshal(raw, &claims); err != nil {
867 t.Fatalf("claims: %v", err)
868 }
869 if claims["iss"] != "TEAMID456" {
870 t.Fatalf("iss = %v", claims["iss"])
871 }
872 if _, ok := claims["iat"]; !ok {
873 t.Fatal("no iat")
874 }
875 if len(claims) != 2 {
876 t.Fatalf("unexpected claims: %v", claims)
877 }
878
879 // The signature is raw r||s, 64 bytes — not the ASN.1 DER that
880 // ecdsa.SignASN1 returns. Sending DER gets every push rejected.
881 sig, err := base64.RawURLEncoding.DecodeString(parts[2])
882 if err != nil {
883 t.Fatalf("signature not base64url: %v", err)
884 }
885 if len(sig) != 64 {
886 t.Fatalf("signature is %d bytes, want 64 (raw r||s)", len(sig))
887 }
888 sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
889 r := new(big.Int).SetBytes(sig[:32])
890 s := new(big.Int).SetBytes(sig[32:])
891 if !ecdsa.Verify(&key.PublicKey, sum[:], r, s) {
892 t.Fatal("signature does not verify")
893 }
894}
895
896func TestTokenCachedThenReminted(t *testing.T) {
897 ts := newTokenSource(testKey(t), "K", "T")
898 base := time.Now()
899 ts.now = func() time.Time { return base }
900
901 first, _ := ts.token()
902 second, _ := ts.token()
903 if first != second {
904 t.Fatal("token reminted inside the cache window; APNs answers TooManyProviderTokenUpdates")
905 }
906
907 // Valid for an hour, not to be reminted faster than every twenty
908 // minutes: refresh at fifty.
909 ts.now = func() time.Time { return base.Add(51 * time.Minute) }
910 third, _ := ts.token()
911 if third == first {
912 t.Fatal("token not reminted after fifty minutes")
913 }
914}
915```
916
917- [ ] **Step 2: Run the test to verify it fails**
918
919Run: `go test ./internal/push/ -run TestToken -v`
920Expected: FAIL — `newTokenSource` undefined.
921
922- [ ] **Step 3: Write the implementation**
923
924`internal/push/token.go`:
925
926```go
927// Package push delivers activity notices to Apple devices over APNs: the
928// third delivery route beside the inbox row and the activity mail, with
929// the bounded-retry discipline the mail queue and webhook deliverer use.
930package push
931
932import (
933 "crypto/ecdsa"
934 "crypto/rand"
935 "crypto/sha256"
936 "encoding/base64"
937 "encoding/json"
938 "sync"
939 "time"
940)
941
942// tokenLifetime is how long a provider token is reused. APNs accepts one
943// for an hour and answers TooManyProviderTokenUpdates if they are minted
944// faster than roughly once every twenty minutes, so the useful window is
945// between the two.
946const tokenLifetime = 50 * time.Minute
947
948type tokenSource struct {
949 key *ecdsa.PrivateKey
950 keyID string
951 teamID string
952 now func() time.Time
953
954 mu sync.Mutex
955 cached string
956 issued time.Time
957}
958
959func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource {
960 return &tokenSource{key: key, keyID: keyID, teamID: teamID, now: time.Now}
961}
962
963// token returns the cached provider token, minting a new one when the old
964// one is near its end.
965func (t *tokenSource) token() (string, error) {
966 t.mu.Lock()
967 defer t.mu.Unlock()
968 now := t.now()
969 if t.cached != "" && now.Sub(t.issued) < tokenLifetime {
970 return t.cached, nil
971 }
972 tok, err := t.sign(now)
973 if err != nil {
974 return "", err
975 }
976 t.cached, t.issued = tok, now
977 return tok, nil
978}
979
980func (t *tokenSource) sign(now time.Time) (string, error) {
981 header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": t.keyID})
982 if err != nil {
983 return "", err
984 }
985 claims, err := json.Marshal(map[string]any{"iss": t.teamID, "iat": now.Unix()})
986 if err != nil {
987 return "", err
988 }
989 enc := base64.RawURLEncoding
990 signing := enc.EncodeToString(header) + "." + enc.EncodeToString(claims)
991 sum := sha256.Sum256([]byte(signing))
992 r, s, err := ecdsa.Sign(rand.Reader, t.key, sum[:])
993 if err != nil {
994 return "", err
995 }
996 // JWS wants the raw pair, each left-padded to the curve's byte size —
997 // not ecdsa.SignASN1's DER. A DER signature is well-formed ECDSA and
998 // is rejected by every JWT verifier, APNs included.
999 sig := make([]byte, 64)
1000 r.FillBytes(sig[:32])
1001 s.FillBytes(sig[32:])
1002 return signing + "." + enc.EncodeToString(sig), nil
1003}
1004```
1005
1006- [ ] **Step 4: Run the tests to verify they pass**
1007
1008Run: `go test ./internal/push/ -run TestToken -v`
1009Expected: PASS, both tests.
1010
1011- [ ] **Step 5: Commit**
1012
1013```bash
1014git add internal/push/token.go internal/push/token_test.go
1015git -c commit.gpgsign=true commit -m "push: APNs provider tokens
1016
1017ES256 over iss and iat, cached fifty minutes. The signature is raw
1018r||s rather than DER, which is the difference between a token APNs
1019accepts and one it rejects.
1020
1021Ref #89"
1022```
1023
1024---
1025
1026### Task 5: The APNs client
1027
1028**Files:**
1029- Create: `internal/push/apns.go`
1030- Test: `internal/push/apns_test.go`
1031
1032**Interfaces:**
1033- Consumes: `tokenSource` from Task 4, `config.Push` from Task 3.
1034- Produces:
1035 - `type Client struct { ... }`
1036 - `func NewClient(cfg config.Push) (*Client, error)`
1037 - `type result int` with constants `resultSent`, `resultRetry`, `resultReap`, `resultDead`
1038 - `func (c *Client) Send(ctx context.Context, token, title, body, path string) (res result, retryAfter time.Duration, err error)`
1039
1040- [ ] **Step 1: Write the failing test**
1041
1042`internal/push/apns_test.go`:
1043
1044```go
1045package push
1046
1047import (
1048 "context"
1049 "encoding/json"
1050 "net/http"
1051 "net/http/httptest"
1052 "io"
1053 "strings"
1054 "testing"
1055 "time"
1056
1057 "gitbay.org/gitbay/internal/config"
1058)
1059
1060// fakeAPNs stands in for Apple. It speaks HTTP/1.1; the real transport is
1061// h2 by ALPN, which is stdlib behaviour and not this repository's to test.
1062func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
1063 t.Helper()
1064 srv := httptest.NewServer(h)
1065 t.Cleanup(srv.Close)
1066 t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(srv.URL, "http://"))
1067 c, err := NewClient(config.Push{
1068 Enabled: true, KeyID: "K", TeamID: "T",
1069 Topic: "org.gitbay.gitbay", Environment: "production",
1070 })
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 c.key = testKey(t)
1075 c.tokens = newTokenSource(c.key, "K", "T")
1076 c.scheme = "http"
1077 return c, srv
1078}
1079
1080func TestSendShapesTheRequest(t *testing.T) {
1081 var gotPath, gotTopic, gotType, gotAuth string
1082 var payload map[string]any
1083 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
1084 gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic")
1085 gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization")
1086 raw, _ := io.ReadAll(r.Body)
1087 json.Unmarshal(raw, &payload)
1088 w.WriteHeader(200)
1089 })
1090 res, _, err := c.Send(context.Background(), "DEVTOKEN", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
1091 if err != nil || res != resultSent {
1092 t.Fatalf("res = %v, err = %v", res, err)
1093 }
1094 if gotPath != "/3/device/DEVTOKEN" {
1095 t.Fatalf("path = %q", gotPath)
1096 }
1097 if gotTopic != "org.gitbay.gitbay" || gotType != "alert" {
1098 t.Fatalf("topic = %q, push-type = %q", gotTopic, gotType)
1099 }
1100 if !strings.HasPrefix(gotAuth, "bearer ") {
1101 t.Fatalf("authorization = %q", gotAuth)
1102 }
1103 aps := payload["aps"].(map[string]any)
1104 alert := aps["alert"].(map[string]any)
1105 if alert["title"] != "krz/gitbay" || alert["body"] != "cmc opened issue #12" {
1106 t.Fatalf("alert = %v", alert)
1107 }
1108 if aps["thread-id"] != "krz/gitbay" {
1109 t.Fatalf("thread-id = %v", aps["thread-id"])
1110 }
1111 if payload["path"] != "krz/gitbay/issues/12" {
1112 t.Fatalf("path = %v", payload["path"])
1113 }
1114 // Collapsing is wrong here: two comments are two notices.
1115 if _, ok := payload["apns-collapse-id"]; ok {
1116 t.Fatal("collapse id set")
1117 }
1118}
1119
1120func TestSendMapsResponses(t *testing.T) {
1121 cases := []struct {
1122 name string
1123 status int
1124 body string
1125 retryAfter string
1126 want result
1127 wantAfter time.Duration
1128 }{
1129 {"ok", 200, "", "", resultSent, 0},
1130 {"gone", 410, `{"reason":"Unregistered"}`, "", resultReap, 0},
1131 {"bad token", 400, `{"reason":"BadDeviceToken"}`, "", resultReap, 0},
1132 {"other 400 is permanent", 400, `{"reason":"PayloadTooLarge"}`, "", resultDead, 0},
1133 {"forbidden is permanent", 403, `{"reason":"InvalidProviderToken"}`, "", resultDead, 0},
1134 {"too many requests retries", 429, `{"reason":"TooManyRequests"}`, "7", resultRetry, 7 * time.Second},
1135 {"server error retries", 503, `{"reason":"ServiceUnavailable"}`, "", resultRetry, 0},
1136 }
1137 for _, tc := range cases {
1138 t.Run(tc.name, func(t *testing.T) {
1139 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
1140 if tc.retryAfter != "" {
1141 w.Header().Set("Retry-After", tc.retryAfter)
1142 }
1143 w.WriteHeader(tc.status)
1144 io.WriteString(w, tc.body)
1145 })
1146 res, after, err := c.Send(context.Background(), "T", "t", "b", "p")
1147 // Only a delivered push has no error. Every other result
1148 // carries the status and reason, which is what the drainer
1149 // records on the queue row.
1150 if tc.want == resultSent && err != nil {
1151 t.Fatalf("err = %v", err)
1152 }
1153 if tc.want != resultSent && err == nil {
1154 t.Fatalf("want an error explaining %v, got nil", tc.want)
1155 }
1156 if res != tc.want {
1157 t.Fatalf("res = %v, want %v", res, tc.want)
1158 }
1159 if after != tc.wantAfter {
1160 t.Fatalf("retryAfter = %v, want %v", after, tc.wantAfter)
1161 }
1162 })
1163 }
1164}
1165```
1166
1167- [ ] **Step 2: Run the test to verify it fails**
1168
1169Run: `go test ./internal/push/ -run TestSend -v`
1170Expected: FAIL — `NewClient` undefined.
1171
1172- [ ] **Step 3: Write the implementation**
1173
1174`internal/push/apns.go`:
1175
1176```go
1177package push
1178
1179import (
1180 "bytes"
1181 "context"
1182 "crypto/ecdsa"
1183 "encoding/json"
1184 "fmt"
1185 "io"
1186 "net/http"
1187 "strconv"
1188 "time"
1189
1190 "gitbay.org/gitbay/internal/config"
1191)
1192
1193// result is what one send means for the queue row.
1194type result int
1195
1196const (
1197 resultSent result = iota // delivered
1198 resultRetry // transient; back off and try again
1199 resultReap // Apple says the token is dead; drop the device
1200 resultDead // permanent for this payload; dead-letter it
1201)
1202
1203// maxBodyBytes keeps an alert inside APNs' 4KB payload limit with room
1204// for the rest of the JSON. A summary longer than this is cut rather
1205// than rejected.
1206const maxBodyBytes = 3000
1207
1208type Client struct {
1209 http *http.Client
1210 tokens *tokenSource
1211 key *ecdsa.PrivateKey
1212 host string
1213 scheme string
1214 topic string
1215}
1216
1217func NewClient(cfg config.Push) (*Client, error) {
1218 c := &Client{
1219 // stdlib negotiates HTTP/2 over ALPN, which is what APNs
1220 // requires; no explicit http2 transport is needed.
1221 http: &http.Client{Timeout: 30 * time.Second},
1222 host: cfg.Host(),
1223 scheme: "https",
1224 topic: cfg.Topic,
1225 }
1226 if cfg.KeyFile != "" {
1227 key, err := config.LoadAPNSKey(cfg.KeyFile)
1228 if err != nil {
1229 return nil, err
1230 }
1231 c.key = key
1232 c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID)
1233 }
1234 return c, nil
1235}
1236```
1237
1238`c.tokens` is nil when `KeyFile` is empty, and `Send` would panic on it.
1239Production cannot reach that: config validation requires `key_file`
1240whenever `push.enabled`, and `Deliverer` is only started when it is. The
1241tests above set `c.tokens` themselves. Leave it rather than adding a nil
1242check that can only fire in a test that forgot one.
1243
1244```go
1245
1246// Send delivers one alert. The returned duration is the server's
1247// Retry-After when it gave one, zero otherwise.
1248func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) {
1249 if len(body) > maxBodyBytes {
1250 body = body[:maxBodyBytes]
1251 }
1252 payload, err := json.Marshal(map[string]any{
1253 "aps": map[string]any{
1254 "alert": map[string]string{"title": title, "body": body},
1255 "sound": "default",
1256 "thread-id": title,
1257 },
1258 "path": path,
1259 })
1260 if err != nil {
1261 return resultDead, 0, err
1262 }
1263 bearer, err := c.tokens.token()
1264 if err != nil {
1265 return resultRetry, 0, err
1266 }
1267 url := c.scheme + "://" + c.host + "/3/device/" + token
1268 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
1269 if err != nil {
1270 return resultDead, 0, err
1271 }
1272 req.Header.Set("authorization", "bearer "+bearer)
1273 req.Header.Set("apns-topic", c.topic)
1274 req.Header.Set("apns-push-type", "alert")
1275 req.Header.Set("apns-priority", "10")
1276 req.Header.Set("content-type", "application/json")
1277
1278 resp, err := c.http.Do(req)
1279 if err != nil {
1280 return resultRetry, 0, err
1281 }
1282 defer resp.Body.Close()
1283 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
1284
1285 var apnsErr struct {
1286 Reason string `json:"reason"`
1287 }
1288 json.Unmarshal(raw, &apnsErr)
1289
1290 var after time.Duration
1291 if v := resp.Header.Get("Retry-After"); v != "" {
1292 if n, err := strconv.Atoi(v); err == nil && n > 0 {
1293 after = time.Duration(n) * time.Second
1294 }
1295 }
1296
1297 switch {
1298 case resp.StatusCode == http.StatusOK:
1299 return resultSent, 0, nil
1300 case resp.StatusCode == http.StatusGone,
1301 apnsErr.Reason == "BadDeviceToken",
1302 apnsErr.Reason == "Unregistered":
1303 // Apple is authoritative about which tokens are live.
1304 return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
1305 case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
1306 return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
1307 default:
1308 // Retrying a rejected payload will not fix it.
1309 return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
1310 }
1311}
1312```
1313
1314- [ ] **Step 4: Run the tests to verify they pass**
1315
1316Run: `go test ./internal/push/ -v`
1317Expected: PASS, all tests.
1318
1319- [ ] **Step 5: Commit**
1320
1321```bash
1322git add internal/push/apns.go internal/push/apns_test.go
1323git -c commit.gpgsign=true commit -m "push: the APNs client
1324
1325POSTs one alert per call and maps the response: 200 sent, 410 and
1326BadDeviceToken reap the device, 429 and 5xx retry honouring
1327Retry-After, everything else dead-letters.
1328
1329Ref #89"
1330```
1331
1332---
1333
1334### Task 6: The drainer, and gitbayd wiring
1335
1336**Files:**
1337- Create: `internal/push/push.go`
1338- Modify: `cmd/gitbayd/main.go:175-178`
1339- Test: `internal/push/push_test.go`
1340
1341**Interfaces:**
1342- Consumes: `Client`, `result` constants from Task 5; the store queue functions from Task 2.
1343- Produces:
1344 - `type Deliverer struct { St *store.Store; Cl *Client; RetryBase time.Duration; MaxAttempts int }`
1345 - `func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error)`
1346 - `func (d *Deliverer) Run(ctx context.Context)`
1347 - `func (d *Deliverer) drain(ctx context.Context)` — one pass, for tests
1348 - `const DefaultMaxAttempts = 5`
1349
1350- [ ] **Step 1: Write the failing test**
1351
1352`internal/push/push_test.go`:
1353
1354```go
1355package push
1356
1357import (
1358 "context"
1359 "net/http"
1360 "testing"
1361 "time"
1362)
1363
1364func TestDrainSendsAndMarks(t *testing.T) {
1365 var hits int
1366 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
1367 hits++
1368 w.WriteHeader(200)
1369 })
1370 st := testStoreWithQueuedPush(t, "tok-a")
1371 d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5}
1372
1373 d.drain(context.Background())
1374
1375 if hits != 1 {
1376 t.Fatalf("sent %d times, want 1", hits)
1377 }
1378 if due, _ := st.DuePush(20); len(due) != 0 {
1379 t.Fatalf("row still due after a 200")
1380 }
1381}
1382
1383func TestDrainReapsADeadToken(t *testing.T) {
1384 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
1385 w.WriteHeader(410)
1386 w.Write([]byte(`{"reason":"Unregistered"}`))
1387 })
1388 st := testStoreWithQueuedPush(t, "tok-a")
1389 d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5}
1390
1391 d.drain(context.Background())
1392
1393 if due, _ := st.DuePush(20); len(due) != 0 {
1394 t.Fatalf("queue survived the reap")
1395 }
1396 // The device is gone, not merely its queue row.
1397 if n := countPushDevices(t, st); n != 0 {
1398 t.Fatalf("%d devices left after 410", n)
1399 }
1400}
1401
1402func TestDrainBacksOffThenDeadLetters(t *testing.T) {
1403 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
1404 w.WriteHeader(503)
1405 })
1406 st := testStoreWithQueuedPush(t, "tok-a")
1407 d := &Deliverer{St: st, Cl: c, RetryBase: time.Nanosecond, MaxAttempts: 3}
1408
1409 // Three passes: two back off, the third gives up.
1410 for i := 0; i < 3; i++ {
1411 d.drain(context.Background())
1412 }
1413 if due, _ := st.DuePush(20); len(due) != 0 {
1414 t.Fatalf("row still due after MaxAttempts")
1415 }
1416 // A transient failure must not take the device with it.
1417 if n := countPushDevices(t, st); n != 1 {
1418 t.Fatalf("device reaped on a 503")
1419 }
1420}
1421```
1422
1423Write `testStoreWithQueuedPush` and `countPushDevices` as helpers in this
1424file. `testStoreWithQueuedPush` opens a store the way `internal/store`'s
1425own tests do, creates a user, calls `AddPushDevice` and `EnqueuePush`,
1426and returns the store. If opening a store from `internal/push` is
1427awkward, put the helpers in `internal/store/export_test.go` style — check
1428what `internal/webhook`'s tests do for the same problem and follow it
1429rather than inventing a third way.
1430
1431- [ ] **Step 2: Run the test to verify it fails**
1432
1433Run: `go test ./internal/push/ -run TestDrain -v`
1434Expected: FAIL — `Deliverer` undefined.
1435
1436- [ ] **Step 3: Write the implementation**
1437
1438`internal/push/push.go`:
1439
1440```go
1441package push
1442
1443import (
1444 "context"
1445 "log/slog"
1446 "time"
1447
1448 "gitbay.org/gitbay/internal/config"
1449 "gitbay.org/gitbay/internal/store"
1450)
1451
1452// DefaultMaxAttempts matches the mailer's: a flaky APNs delays a
1453// notification rather than losing it, up to a point.
1454const DefaultMaxAttempts = 5
1455
1456type Deliverer struct {
1457 St *store.Store
1458 Cl *Client
1459 RetryBase time.Duration
1460 MaxAttempts int
1461}
1462
1463func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error) {
1464 cl, err := NewClient(cfg)
1465 if err != nil {
1466 return nil, err
1467 }
1468 return &Deliverer{St: st, Cl: cl, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}, nil
1469}
1470
1471// Run drains the push queue until ctx is done.
1472func (d *Deliverer) Run(ctx context.Context) {
1473 tick := time.NewTicker(2 * time.Second)
1474 defer tick.Stop()
1475 for {
1476 select {
1477 case <-ctx.Done():
1478 return
1479 case <-tick.C:
1480 d.drain(ctx)
1481 }
1482 }
1483}
1484
1485func (d *Deliverer) drain(ctx context.Context) {
1486 due, err := d.St.DuePush(20)
1487 if err != nil {
1488 slog.Error("push: listing due", "err", err)
1489 return
1490 }
1491 for _, q := range due {
1492 res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Title, q.Body, q.Path)
1493 msg := ""
1494 if sendErr != nil {
1495 msg = sendErr.Error()
1496 }
1497 switch res {
1498 case resultSent:
1499 d.St.MarkPushSent(q.ID)
1500 case resultReap:
1501 // The queued rows cascade with the device.
1502 if err := d.St.DeletePushDeviceByToken(q.Token); err != nil {
1503 slog.Error("push: reaping device", "device", q.DeviceID, "err", err)
1504 }
1505 case resultRetry:
1506 attempt := q.Attempts + 1
1507 if attempt >= d.MaxAttempts {
1508 d.St.MarkPushFailed(q.ID, msg, nil)
1509 // The device id, never the token.
1510 slog.Warn("push dead-lettered",
1511 "push", q.ID, "device", q.DeviceID, "attempts", attempt, "err", msg)
1512 continue
1513 }
1514 wait := after
1515 if wait == 0 {
1516 wait = d.RetryBase << (attempt - 1)
1517 }
1518 next := time.Now().Add(wait)
1519 d.St.MarkPushFailed(q.ID, msg, &next)
1520 default: // resultDead
1521 d.St.MarkPushFailed(q.ID, msg, nil)
1522 slog.Warn("push rejected", "push", q.ID, "device", q.DeviceID, "err", msg)
1523 }
1524 }
1525}
1526```
1527
1528- [ ] **Step 4: Wire it into gitbayd**
1529
1530In `cmd/gitbayd/main.go`, beside the mailer at line 177:
1531
1532```go
1533 if cfg.Push.Enabled {
1534 p, err := push.New(st, cfg.Push, retryBase)
1535 if err != nil {
1536 // Config validation already parsed the key, so this
1537 // is not a misconfiguration; fail loudly rather than
1538 // running with a silent delivery route.
1539 slog.Error("push: starting deliverer", "err", err)
1540 } else {
1541 go p.Run(whCtx)
1542 }
1543 }
1544```
1545
1546Add `"gitbay.org/gitbay/internal/push"` to the file's imports.
1547
1548- [ ] **Step 5: Run the tests to verify they pass**
1549
1550Run: `go test ./internal/push/ -v && go build ./... && go vet ./...`
1551Expected: PASS and clean.
1552
1553- [ ] **Step 6: Commit**
1554
1555```bash
1556git add internal/push/push.go internal/push/push_test.go cmd/gitbayd/main.go
1557git -c commit.gpgsign=true commit -m "push: drain the queue, started by gitbayd
1558
1559Two-second ticker in the mailer's shape. A reap drops the device and
1560its queued rows cascade; a retry honours Retry-After when APNs gave
1561one. Log lines name the device id, never the token.
1562
1563Ref #89"
1564```
1565
1566---
1567
1568### Task 7: The control commands
1569
1570**Files:**
1571- Modify: `internal/control/notifications.go`
1572- Modify: `cmd/gitbay/main.go:64-73`
1573- Test: `internal/control/notifications_test.go`
1574
1575**Interfaces:**
1576- Consumes: the store device functions from Task 1.
1577- Produces: registry entries `notifications device add|list|remove` and `notifications settings push`; `emitNotificationSettings` gains a `push` key.
1578
1579- [ ] **Step 1: Write the failing test**
1580
1581Add to `internal/control/notifications_test.go` (create it if absent,
1582following `internal/control/mr_test.go` for how a `Ctx` is built):
1583
1584```go
1585func TestNotificationsDeviceAddReadsStdin(t *testing.T) {
1586 c := testCtx(t, "alice")
1587 c.Stdin = strings.NewReader("DEVTOKEN\n")
1588 if code := runNotificationsDeviceAdd(c, []string{"--label", "iphone"}); code != 0 {
1589 t.Fatalf("exit %d", code)
1590 }
1591 devices, _ := c.Store.PushDevices(c.User.ID)
1592 if len(devices) != 1 || devices[0].Token != "DEVTOKEN" {
1593 t.Fatalf("got %+v", devices)
1594 }
1595 if devices[0].Label != "iphone" {
1596 t.Fatalf("label = %q", devices[0].Label)
1597 }
1598}
1599
1600func TestNotificationsDeviceListTruncatesTheToken(t *testing.T) {
1601 c := testCtx(t, "alice")
1602 long := strings.Repeat("a", 64)
1603 c.Store.AddPushDevice(c.User.ID, long, "iphone")
1604 var out bytes.Buffer
1605 c.Stdout = &out
1606 if code := runNotificationsDeviceList(c, nil); code != 0 {
1607 t.Fatalf("exit %d", code)
1608 }
1609 if strings.Contains(out.String(), long) {
1610 t.Fatal("the full token was printed")
1611 }
1612}
1613
1614func TestNotificationsSettingsShowsPush(t *testing.T) {
1615 c := testCtx(t, "alice")
1616 var out bytes.Buffer
1617 c.Stdout, c.JSON = &out, true
1618 if code := runNotificationsSettingsShow(c, nil); code != 0 {
1619 t.Fatalf("exit %d", code)
1620 }
1621 if !strings.Contains(out.String(), `"push":true`) {
1622 t.Fatalf("no push key: %s", out.String())
1623 }
1624}
1625```
1626
1627- [ ] **Step 2: Run the test to verify it fails**
1628
1629Run: `go test ./internal/control/ -run TestNotifications -v`
1630Expected: FAIL — `runNotificationsDeviceAdd` undefined.
1631
1632- [ ] **Step 3: Register and implement the commands**
1633
1634In the `init()` of `internal/control/notifications.go`:
1635
1636```go
1637 register(Command{Path: []string{"notifications", "device", "add"},
1638 Summary: "register an Apple device for push, token on stdin",
1639 Usage: "notifications device add [--label <name>] < token",
1640 // Mandatory: without it control.go swaps in an empty reader and
1641 // this command stores an empty token without erroring.
1642 ReadsStdin: true, Run: runNotificationsDeviceAdd})
1643 register(Command{Path: []string{"notifications", "device", "list"},
1644 Summary: "your registered devices",
1645 Usage: "notifications device list",
1646 ReadOnly: true, Run: runNotificationsDeviceList})
1647 register(Command{Path: []string{"notifications", "device", "remove"},
1648 Summary: "deregister a device",
1649 Usage: "notifications device remove <id>", Run: runNotificationsDeviceRemove})
1650 register(Command{Path: []string{"notifications", "settings", "push"},
1651 Summary: "activity on your registered devices as well as the inbox",
1652 Usage: "notifications settings push on|off", Run: runNotificationsSettingsPush})
1653```
1654
1655And the implementations:
1656
1657```go
1658// maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex
1659// characters, and stops a stdin that is not a token from becoming a row.
1660const maxDeviceTokenBytes = 512
1661
1662func runNotificationsDeviceAdd(c *Ctx, args []string) int {
1663 f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
1664 if err != nil {
1665 return c.fail(protocol.ExitUsage, "%v", err)
1666 }
1667 if len(f.Pos) != 0 {
1668 return c.usage()
1669 }
1670 raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1))
1671 if err != nil {
1672 return c.fail(protocol.ExitFailure, "reading stdin: %v", err)
1673 }
1674 token := strings.TrimSpace(string(raw))
1675 if token == "" {
1676 return c.usageWith("no device token on stdin")
1677 }
1678 if len(token) > maxDeviceTokenBytes {
1679 return c.fail(protocol.ExitUsage, "device token is too long")
1680 }
1681 if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil {
1682 return c.fail(protocol.ExitFailure, "%v", err)
1683 }
1684 return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) {
1685 fmt.Fprintln(w, "device registered")
1686 })
1687}
1688
1689func runNotificationsDeviceList(c *Ctx, args []string) int {
1690 if len(args) != 0 {
1691 return c.usage()
1692 }
1693 devices, err := c.Store.PushDevices(c.User.ID)
1694 if err != nil {
1695 return c.fail(protocol.ExitFailure, "%v", err)
1696 }
1697 type row struct {
1698 ID int64 `json:"id"`
1699 Label string `json:"label"`
1700 Token string `json:"token"` // truncated; a token is not echoed in full
1701 Added string `json:"added"`
1702 }
1703 rows := make([]row, 0, len(devices))
1704 for _, d := range devices {
1705 rows = append(rows, row{ID: d.ID, Label: d.Label,
1706 Token: shortToken(d.Token), Added: d.CreatedAt})
1707 }
1708 return c.emit(rows, func(w io.Writer) {
1709 for _, r := range rows {
1710 fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added)
1711 }
1712 })
1713}
1714
1715// shortToken renders a device token as its first eight characters. Enough
1716// to tell two devices apart in a list, not enough to push to one.
1717func shortToken(t string) string {
1718 if len(t) <= 8 {
1719 return t
1720 }
1721 return t[:8] + "…"
1722}
1723
1724func runNotificationsDeviceRemove(c *Ctx, args []string) int {
1725 if len(args) != 1 {
1726 return c.usage()
1727 }
1728 id, err := strconv.ParseInt(args[0], 10, 64)
1729 if err != nil {
1730 return c.usageWith("device id must be a number")
1731 }
1732 if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil {
1733 if errors.Is(err, store.ErrNotFound) {
1734 return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours")
1735 }
1736 return c.fail(protocol.ExitFailure, "%v", err)
1737 }
1738 return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) {
1739 fmt.Fprintln(w, "device removed")
1740 })
1741}
1742
1743func runNotificationsSettingsPush(c *Ctx, args []string) int {
1744 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
1745 return c.usage()
1746 }
1747 if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil {
1748 return c.fail(protocol.ExitFailure, "%v", err)
1749 }
1750 return emitNotificationSettings(c)
1751}
1752```
1753
1754Add `"errors"` and `"gitbay.org/gitbay/internal/store"` to the imports if
1755the file lacks them.
1756
1757- [ ] **Step 4: Extend `emitNotificationSettings`**
1758
1759Replace the body of `emitNotificationSettings` so it reads `push` too and
1760adds it to both outputs:
1761
1762```go
1763 push, err := c.Store.PushEnabled(c.User.ID)
1764 if err != nil {
1765 return c.fail(protocol.ExitFailure, "%v", err)
1766 }
1767 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) {
1768 ...
1769 fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push))
1770 })
1771```
1772
1773- [ ] **Step 5: Add the CLI passthroughs**
1774
1775In `cmd/gitbay/main.go`, inside the `notifications` group around line 64,
1776add a `device` subgroup and the settings entry:
1777
1778```go
1779 group("device", "Apple devices registered for push",
1780 pass("add", "register a device, token on stdin: [--label name]",
1781 passOpts{server: []string{"notifications", "device", "add"}, stdin: true}),
1782 pass("list", "your registered devices",
1783 passOpts{server: []string{"notifications", "device", "list"}}),
1784 pass("remove", "deregister a device: <id>",
1785 passOpts{server: []string{"notifications", "device", "remove"}}),
1786 ),
1787```
1788
1789and beside `mail` and `watch` in the settings group:
1790
1791```go
1792 pass("push", "activity on your registered devices: on|off", passOpts{server: []string{"notifications", "settings", "push"}}),
1793```
1794
1795Check `passOpts`' real field for a stdin-reading command against how
1796`snippet create` is registered in the same file — use that name, not
1797`stdin:` if it differs.
1798
1799- [ ] **Step 6: Run the tests to verify they pass**
1800
1801Run: `go test ./internal/control/ ./cmd/gitbay/ -v`
1802Expected: PASS. Four registry tests exercise the new commands without
1803being edited: `TestStdinCommandsReadStdin` (which fails if `device add`
1804lacks `ReadsStdin`), `TestReadOnlyCommandsWriteNothing`, the
1805`cmd/gitbay` coverage test (which fails without the `pass()` entries),
1806and the usage-literal check.
1807
1808- [ ] **Step 7: Commit**
1809
1810```bash
1811git add internal/control/notifications.go internal/control/notifications_test.go cmd/gitbay/main.go
1812git -c commit.gpgsign=true commit -m "control: notifications device and settings push
1813
1814Token on stdin, never argv. device list truncates the token to eight
1815characters: enough to tell two devices apart, not enough to push to
1816one. settings show gains a third key.
1817
1818Ref #89"
1819```
1820
1821---
1822
1823### Task 8: Push as the third route in `notify()`
1824
1825**Files:**
1826- Modify: `internal/control/notifications.go:66-85` (`notify`)
1827- Test: `internal/control/notifications_test.go`
1828
1829**Interfaces:**
1830- Consumes: `EnqueuePush` from Task 2.
1831- Produces: `func pushTitle(n notice) string` and `func pushBody(n notice) string`.
1832
1833- [ ] **Step 1: Write the failing test**
1834
1835```go
1836func TestNotifyQueuesPush(t *testing.T) {
1837 c, repo, bob := testRepoWithWatcher(t) // alice acts, bob watches
1838 c.Store.AddPushDevice(bob, "tok-b", "iphone")
1839
1840 notify(c, []int64{bob}, notice{repo: repo, kind: "issue",
1841 subject: "[alice/app] #1: title",
1842 action: "opened issue #1",
1843 path: "alice/app/issues/1"})
1844
1845 due, err := c.Store.DuePush(20)
1846 if err != nil {
1847 t.Fatalf("DuePush: %v", err)
1848 }
1849 if len(due) != 1 {
1850 t.Fatalf("want one queued push, got %d", len(due))
1851 }
1852 // The push body is the inbox row's summary, so the two surfaces
1853 // cannot disagree about what happened.
1854 if due[0].Title != "alice/app" {
1855 t.Fatalf("title = %q", due[0].Title)
1856 }
1857 if due[0].Body != "alice opened issue #1" {
1858 t.Fatalf("body = %q", due[0].Body)
1859 }
1860 if due[0].Path != "alice/app/issues/1" {
1861 t.Fatalf("path = %q", due[0].Path)
1862 }
1863}
1864
1865func TestNotifyQueuesNoPushForTheActor(t *testing.T) {
1866 c, repo, _ := testRepoWithWatcher(t)
1867 c.Store.AddPushDevice(c.User.ID, "tok-self", "iphone")
1868
1869 notify(c, []int64{c.User.ID}, notice{repo: repo, kind: "issue",
1870 subject: "s", action: "opened issue #1", path: "alice/app/issues/1"})
1871
1872 // NotifyRecipients already drops the actor; push inherits that and
1873 // must not find its own way around it.
1874 if due, _ := c.Store.DuePush(20); len(due) != 0 {
1875 t.Fatalf("queued a push to the actor")
1876 }
1877}
1878```
1879
1880Write `testRepoWithWatcher` to return a `*Ctx` acting as alice, a
1881`store.Repo` she owns, and bob's user id with a watch row on it. Follow
1882whatever `internal/control`'s existing tests do to build a repo.
1883
1884- [ ] **Step 2: Run the test to verify it fails**
1885
1886Run: `go test ./internal/control/ -run TestNotifyQueues -v`
1887Expected: FAIL — one queued push wanted, none found.
1888
1889- [ ] **Step 3: Write the implementation**
1890
1891In `notify()`, inside the existing `for _, id := range recipients` loop,
1892after `AddNotice` and before the `if !sendMail { continue }`:
1893
1894```go
1895 c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path)
1896```
1897
1898Putting it above the `continue` matters — an instance without SMTP still
1899pushes.
1900
1901And beside `noticeBody`:
1902
1903```go
1904// pushTitle and pushBody are the alert's two lines. The body is built
1905// from the same two values AddNotice files, so the alert and the inbox
1906// row cannot disagree about what happened. The title is the repository,
1907// which also groups a repository's notices in Notification Center.
1908func pushTitle(n notice) string { return n.repo.Path() }
1909
1910func pushBody(actor string, n notice) string { return actor + " " + n.action }
1911```
1912
1913`notice` has no `actor` field and does not gain one: the actor is
1914`c.User.Username`, already passed to `AddNotice` on the line above, so
1915threading it through the struct would be a second copy of the same
1916value. The call in the loop is therefore:
1917
1918```go
1919 c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path)
1920```
1921
1922- [ ] **Step 4: Run the tests to verify they pass**
1923
1924Run: `go test ./internal/control/ -v`
1925Expected: PASS, the whole package — `notify` has sixteen call sites and
1926this changes all of them.
1927
1928- [ ] **Step 5: Commit**
1929
1930```bash
1931git add internal/control/notifications.go internal/control/notifications_test.go
1932git -c commit.gpgsign=true commit -m "control: push as the third route in notify
1933
1934Queued in the same loop as the inbox row and the mail, above the SMTP
1935check so an instance without a relay still pushes. The alert body is
1936the inbox summary, so the surfaces cannot disagree.
1937
1938Ref #89"
1939```
1940
1941---
1942
1943### Task 9: `issue assign` files a notice
1944
1945**Files:**
1946- Modify: `internal/control/issue.go:423-470`
1947- Test: `internal/control/issue_test.go`
1948
1949**Interfaces:**
1950- Consumes: `notify`, `notice` from Task 8.
1951- Produces: nothing new.
1952
1953- [ ] **Step 1: Write the failing test**
1954
1955```go
1956func TestIssueAssignNotifiesTheAssignee(t *testing.T) {
1957 c, repo, bob := testRepoWithWatcher(t)
1958
1959 if code := runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}); code != 0 {
1960 t.Fatalf("exit %d", code)
1961 }
1962 rows, _ := c.Store.Inbox(bob, false, 20, 0)
1963 if len(rows) != 1 || rows[0].Summary != "assigned you to #1" {
1964 t.Fatalf("got %+v", rows)
1965 }
1966}
1967
1968func TestIssueAssignIsSilentForTheActorAndForRemovals(t *testing.T) {
1969 c, repo, bob := testRepoWithWatcher(t)
1970
1971 // Assigning yourself announces nothing: notify drops the actor.
1972 runIssueAssign(c, []string{repo.Path(), "1", "--add", "alice"})
1973 if rows, _ := c.Store.Inbox(c.User.ID, false, 20, 0); len(rows) != 0 {
1974 t.Fatalf("self-assignment notified: %+v", rows)
1975 }
1976
1977 // Unassigning files nothing.
1978 runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"})
1979 before, _ := c.Store.Inbox(bob, false, 20, 0)
1980 runIssueAssign(c, []string{repo.Path(), "1", "--remove", "bob"})
1981 after, _ := c.Store.Inbox(bob, false, 20, 0)
1982 if len(after) != len(before) {
1983 t.Fatalf("removal filed a row: %d then %d", len(before), len(after))
1984 }
1985}
1986```
1987
1988The helper needs an issue #1 on the repo; extend `testRepoWithWatcher`
1989from Task 8 or add a sibling that also opens one.
1990
1991- [ ] **Step 2: Run the test to verify it fails**
1992
1993Run: `go test ./internal/control/ -run TestIssueAssign -v`
1994Expected: FAIL — the inbox is empty.
1995
1996- [ ] **Step 3: Write the implementation**
1997
1998In `runIssueAssign`, collect the ids as the add loop resolves them:
1999
2000```go
2001 var added []int64
2002 for _, name := range adds {
2003 u, code := resolve(name)
2004 if code >= 0 {
2005 return code
2006 }
2007 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
2008 return c.fail(protocol.ExitFailure, "%v", err)
2009 }
2010 added = append(added, u.ID)
2011 }
2012```
2013
2014and after both loops succeed, before the function's existing return:
2015
2016```go
2017 if len(added) > 0 {
2018 // direct, as a mention is: an assignment is addressed to someone,
2019 // and widening it to watchers would tell them "assigned you".
2020 // Removals file nothing, and notify drops the actor, so assigning
2021 // yourself is silent.
2022 notify(c, added, notice{repo: repo, kind: "issue", direct: true,
2023 subject: fmt.Sprintf("[%s] #%d: %s", repo.Path(), issue.Number, issue.Title),
2024 action: fmt.Sprintf("assigned you to #%d", issue.Number),
2025 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
2026 }
2027```
2028
2029- [ ] **Step 4: Run the tests to verify they pass**
2030
2031Run: `go test ./internal/control/ -run TestIssueAssign -v`
2032Expected: PASS.
2033
2034- [ ] **Step 5: Commit**
2035
2036```bash
2037git add internal/control/issue.go internal/control/issue_test.go
2038git -c commit.gpgsign=true commit -m "control: issue assign files a notice
2039
2040The dashboard surfaced assigned work and nothing announced it. Direct,
2041as a mention is, so watchers are not told they were assigned.
2042
2043Ref #89"
2044```
2045
2046---
2047
2048### Task 10: The web settings page
2049
2050**Files:**
2051- Modify: `internal/httpd/account.go` — `accountPage` around `:65-66`, the page struct around `:76`, and the `accountSubmit` switch at `:246`
2052- Modify: `internal/web/templates/account.html` — the `#notifications` section at `:132`
2053- Test: `internal/httpd/` package tests
2054
2055The page is `/settings`, rendered from `account.html`, with a
2056`#notifications` section that already carries the mail and watch
2057toggles. No new template file, so `TestMainWidthClass` is not involved.
2058
2059**Interfaces:**
2060- Consumes: the control commands from Task 7.
2061- Produces: nothing other tasks use.
2062
2063- [ ] **Step 1: Write the failing test**
2064
2065Follow the existing `internal/httpd` account-page test for how a page is
2066rendered and its body captured.
2067
2068```go
2069func TestAccountPagePushToggleAndDevices(t *testing.T) {
2070 // ... render /settings for a user with one registered device whose
2071 // token is `strings.Repeat("a", 64)`.
2072 if !strings.Contains(body, `value="notify-push"`) {
2073 t.Fatal("no push toggle")
2074 }
2075 if !strings.Contains(body, "iphone") {
2076 t.Fatal("the device is not listed")
2077 }
2078 // A token is device-identifying and is never printed in full.
2079 if strings.Contains(body, strings.Repeat("a", 64)) {
2080 t.Fatal("the page printed a device token in full")
2081 }
2082}
2083```
2084
2085- [ ] **Step 2: Run the test to verify it fails**
2086
2087Run: `go test ./internal/httpd/ -run TestAccountPage -v`
2088Expected: FAIL — no push toggle.
2089
2090- [ ] **Step 3: Accept the new field**
2091
2092In `accountSubmit` (`internal/httpd/account.go:246`), the case derives
2093`pref` by trimming `notify-`, so this is one token:
2094
2095```go
2096 case "notify-mail", "notify-watch", "notify-push":
2097```
2098
2099- [ ] **Step 4: Render the toggle and the list**
2100
2101In `accountPage`, beside `mailOn` and `watchOn`:
2102
2103```go
2104 pushOn, _ := s.st.PushEnabled(u.ID)
2105 devices, _ := s.st.PushDevices(u.ID)
2106```
2107
2108Add `PushOn bool` and a device slice to the anonymous page struct in the
2109`s.render` call. Render each device with `prefix8` — the truncation
2110helper this file already uses for key fingerprints — not the full token.
2111
2112In `account.html`, after the watch form in the `#notifications` section,
2113copying the shape of the two forms already there:
2114
2115```html
2116<form method="post" action="/settings" class="setform">
2117 <input type="hidden" name="field" value="notify-push">
2118 <label for="notify-push">Activity on your registered devices</label>
2119 <input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}>
2120 <button type="submit" class="btn">Save</button>
2121</form>
2122<p class="meta">Notification text is sent in full, including for private repositories, so a repository name and item number reach Apple and appear on a lock screen.</p>
2123```
2124
2125Then the device list, each row posting `field=device-remove` with the
2126id, dispatched through `s.runControl` to
2127`[]string{"notifications", "device", "remove", id}` as a new case in the
2128same switch.
2129
2130There is no add-a-device form: a browser cannot produce an APNs token.
2131That is the Parity page's "CLI only, for now", not a refusal.
2132
2133- [ ] **Step 5: Run the tests to verify they pass**
2134
2135Run: `go test ./internal/httpd/ ./internal/web/ -v`
2136Expected: PASS. Run `./internal/web/` too — the template registry tests
2137live there and a malformed template fails at render, not at build.
2138
2139- [ ] **Step 6: Commit**
2140
2141```bash
2142git add internal/httpd/ internal/web/
2143git -c commit.gpgsign=true commit -m "web: push toggle and device list on notification settings
2144
2145No add-a-device form: a browser cannot produce an APNs token.
2146
2147Ref #89"
2148```
2149
2150---
2151
2152### Task 11: End-to-end
2153
2154**Files:**
2155- Create: `e2e/push_test.go`
2156
2157**Interfaces:**
2158- Consumes: everything above.
2159- Produces: nothing.
2160
2161- [ ] **Step 1: Write the test**
2162
2163`e2e/push_test.go`, following `e2e/bookmarks_test.go` for how an instance
2164and accounts are set up:
2165
2166```go
2167package e2e
2168
2169import (
2170 "encoding/json"
2171 "io"
2172 "net/http"
2173 "net/http/httptest"
2174 "strings"
2175 "sync"
2176 "testing"
2177 "time"
2178)
2179
2180// A push reaches a registered device with the same words the inbox row
2181// carries, and a token Apple has retired takes its device with it.
2182func TestPush(t *testing.T) {
2183 var mu sync.Mutex
2184 var got []map[string]any
2185 var gone bool
2186
2187 apns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2188 raw, _ := io.ReadAll(r.Body)
2189 var payload map[string]any
2190 json.Unmarshal(raw, &payload)
2191 mu.Lock()
2192 defer mu.Unlock()
2193 if gone {
2194 w.WriteHeader(410)
2195 io.WriteString(w, `{"reason":"Unregistered"}`)
2196 return
2197 }
2198 got = append(got, payload)
2199 w.WriteHeader(200)
2200 }))
2201 defer apns.Close()
2202
2203 keyPath := writeTestAPNSKey(t)
2204 t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(apns.URL, "http://"))
2205 inst := startInstanceWith(t, `[push]
2206enabled = true
2207key_file = "`+keyPath+`"
2208key_id = "KEYID"
2209team_id = "TEAMID"
2210topic = "org.gitbay.gitbay"
2211environment = "production"
2212`)
2213
2214 aliceKey := inst.newKey(t, "alice")
2215 bobKey := inst.newKey(t, "bob")
2216 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
2217 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
2218 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
2219 t.Fatalf("repo create: %s", errOut)
2220 }
2221
2222 // Bob watches alice's repository and registers a device.
2223 if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "watch", "alice/app"); code != 0 {
2224 t.Fatalf("watch: %s%s", out, errOut)
2225 }
2226 if out, errOut, code := inst.ssh(t, bobKey, "DEVTOKEN\n", "notifications", "device", "add", "--label", "iphone"); code != 0 {
2227 t.Fatalf("device add: %s%s", out, errOut)
2228 }
2229 if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json"); !strings.Contains(out, `"label":"iphone"`) {
2230 t.Fatalf("device not listed:\n%s", out)
2231 } else if strings.Contains(out, "DEVTOKEN") {
2232 t.Fatalf("device list printed the token in full:\n%s", out)
2233 }
2234
2235 // Alice opens an issue. Bob hears about it.
2236 if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "a bug", "--body", "x"); code != 0 {
2237 t.Fatalf("issue create: %s%s", out, errOut)
2238 }
2239
2240 waitFor(t, 20*time.Second, func() bool {
2241 mu.Lock()
2242 defer mu.Unlock()
2243 return len(got) == 1
2244 }, "no push arrived")
2245
2246 mu.Lock()
2247 aps := got[0]["aps"].(map[string]any)
2248 alert := aps["alert"].(map[string]any)
2249 mu.Unlock()
2250 if alert["title"] != "alice/app" {
2251 t.Fatalf("title = %v", alert["title"])
2252 }
2253 // The same words the inbox row carries.
2254 if body, _ := alert["body"].(string); !strings.Contains(body, "opened issue #1") {
2255 t.Fatalf("body = %q", body)
2256 }
2257 if got[0]["path"] != "alice/app/issues/1" {
2258 t.Fatalf("path = %v", got[0]["path"])
2259 }
2260
2261 // Apple retires the token. The next push reaps the device.
2262 mu.Lock()
2263 gone = true
2264 mu.Unlock()
2265 if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--body", "ping"); code != 0 {
2266 t.Fatalf("issue comment: %s%s", out, errOut)
2267 }
2268 waitFor(t, 20*time.Second, func() bool {
2269 out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json")
2270 return !strings.Contains(out, "iphone")
2271 }, "the device survived a 410")
2272
2273 // The inbox is untouched by any of it: push is a side channel.
2274 if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "list", "--json"); !strings.Contains(out, "opened issue #1") {
2275 t.Fatalf("inbox missing the notice:\n%s", out)
2276 }
2277}
2278```
2279
2280Write `writeTestAPNSKey` (a P-256 PKCS#8 key in a `t.TempDir()`, as in
2281Task 3) and reuse the e2e suite's existing polling helper rather than
2282writing `waitFor` if one exists — check `e2e/` for it first.
2283
2284Note the `ssh` helper's second argument is stdin; that is how `DEVTOKEN`
2285reaches `device add`.
2286
2287- [ ] **Step 2: Run it**
2288
2289Run: `go test ./e2e/ -run TestPush -v`
2290Expected: PASS. This is the one e2e test to run locally; the rest of the
2291suite belongs to CI on bay1.
2292
2293- [ ] **Step 3: Commit**
2294
2295```bash
2296git add e2e/push_test.go
2297git -c commit.gpgsign=true commit -m "e2e: push delivery and device reaping
2298
2299A fake APNs over HTTP/1.1; the real transport is h2 by ALPN, which is
2300stdlib behaviour and not ours to test.
2301
2302Ref #89"
2303```
2304
2305---
2306
2307### Task 12: Documentation
2308
2309**Files:**
2310- Modify: `.gitbay/wiki/Parity.md`
2311- Modify: `.gitbay/wiki/Admin.md`
2312- Modify: `.gitbay/wiki/Users.md`
2313- Modify: `CHANGELOG.org`
2314
2315**Interfaces:**
2316- Consumes: everything above.
2317- Produces: nothing.
2318
2319- [ ] **Step 1: Parity**
2320
2321Add a row per new command — `notifications device add`, `device list`,
2322`device remove`, `settings push` — with its SSH/CLI/web/API columns.
2323`device add` is CLI only for now on the web column, because a browser
2324cannot produce an APNs token; write it as "CLI only, for now", which is
2325the page's current wording, not "no".
2326
2327- [ ] **Step 2: Admin**
2328
2329Document the `[push]` section: every key, how to obtain a `.p8` from the
2330developer portal, where the file goes (`/etc/gitbay/apns.p8`, mode 0600,
2331owned by the account gitbayd runs as), and the constraint that an APNs
2332key belongs to a bundle ID — a self-hoster pushes to their own build
2333under their own `topic`, not to the App Store app.
2334
2335- [ ] **Step 3: Users**
2336
2337Document `notifications settings push on|off` and what a device row is:
2338registered by the app, listed and removable from the CLI and the web,
2339and dropped automatically when Apple says the token is dead.
2340
2341State plainly that notification text is sent in full, private
2342repositories included, so a repository name and item number reach Apple
2343and appear on a lock screen.
2344
2345- [ ] **Step 4: CHANGELOG**
2346
2347Add the feature under the unreleased heading in `CHANGELOG.org`, in the
2348style of the entries already there.
2349
2350- [ ] **Step 5: Commit**
2351
2352```bash
2353git add .gitbay/wiki/ CHANGELOG.org
2354git -c commit.gpgsign=true commit -m "docs: push notifications
2355
2356Closes #89"
2357```
2358
2359---
2360
2361### Task 13: Open the merge request
2362
2363- [ ] **Step 1: Verify the branch**
2364
2365Run: `go build ./... && go vet ./... && go test ./internal/... ./cmd/...`
2366Expected: all PASS. Do not claim the branch is green without this output
2367in front of you.
2368
2369- [ ] **Step 2: Push and open the MR**
2370
2371```bash
2372git push -u origin ios-push
2373```
2374
2375```bash
2376gitbay mr create --source ios-push --target main --title "iOS push notifications (server)"
2377```
2378
2379Body via `--file -` from a file, not a heredoc. It states what landed and
2380references `Closes #89`. No attribution to any assistant or model.
2381
2382- [ ] **Step 3: Let CI run**
2383
2384The e2e suite runs on bay1. Watch it with `gitbay build list` and
2385`gitbay build log <n>`. Do not poll with several ssh calls per tick —
2386the auth limiter reads a burst as an attack.
2387
2388- [ ] **Step 4: Merge**
2389
2390Only with the full suite green:
2391
2392```bash
2393gitbay mr merge <n> --strategy ff
2394```
2395
2396Signed commits are required, so `squash` and `merge` are refused — both
2397would mint an unsigned commit. If the merge reports the branch is
2398behind, rebase onto `main`, re-push, merge again. Then delete the branch
2399locally and remotely.
2400
2401**Do not deploy.** `[push]` stays `enabled = false` on bay1 until the app
2402is submitted; there is nothing to deliver to until a device registers.
2403
2404---
2405
2406## Notes for whoever executes this
2407
2408- **Tasks 1-9 are the working feature.** Task 10 (web) and Task 12 (docs)
2409 can be reordered or split into a follow-up MR if the branch is getting
2410 long, but Task 11's e2e should land with the code it tests.
2411- **The classifier may refuse some of this.** Editing files under
2412 `internal/policy/` or anything that reads as relaxing an access-control
2413 flag has been refused before in auto mode. Nothing in this plan should
2414 trip it, but if a refusal happens, retry as a single-file edit with no
2415 chained build rather than treating it as a puzzle.
2416- **The `krz/gitbay-ios` half is a separate plan** on that repository,
2417 written once this has shipped. The spec's "The app" section is the
2418 contract it has to meet — payload keys `aps.alert.title`,
2419 `aps.alert.body`, `aps.thread-id` and top-level `path`, and the
2420 `notifications device add|remove` calls.