docs/plans/2026-09-20-ios-push-notifications.md

main
gitbay/docs/plans/2026-09-20-ios-push-notifications.md rendered · source · history · blame · raw

2420 lines · 74949 bytes

16 symbols in this file
   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.