Commit 66304b6cd8

66304b6cd875bcf0992c8ff04201c46e6be1c917

parent: f327db6192

Verified · cmc ci/build: success ci/test: success

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

push: name the account an alert is for

The payload carried path and nothing else. A device token is one
install and an install registers against every account signed in on
it, so path alone could not say which instance a notice came from —
two instances can hold the same owner/name, and the alert title is the
repository path, which does not disambiguate either.

It now carries instance, the instance's site_url, and user, the
recipient's username. Those are what the client keys an account by, so
it can resolve one before routing. DuePush joins users for the
recipient; no schema change.

Ref #89
CHANGELOG.org +11
@@ -4,6 +4,17 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.32.1 — 2026-09-20
8
9A push alert names the account it is for (#89).
10
11- The payload carried =path= and nothing else, so a device signed in to
12 more than one account could not tell which instance a notice came
13 from: two instances can hold the same =owner/name=. It now carries
14 =instance=, the instance's =site_url=, and =user=, the recipient's
15 username — together the account's identity.
16- =DuePush= joins =users= for the recipient; no schema change.
17
718* v1.32.0 — 2026-09-20
819
920Push notifications to iOS devices (#89): activity reaches a registered
cmd/gitbayd/main.go +1 −1
@@ -178,7 +178,7 @@ func serveCmd() *cobra.Command {
178178 go notify.New(st, cfg, retryBase).Run(whCtx)
179179 }
180180 if cfg.Push.Enabled {
181 p, err := push.New(st, cfg.Push, retryBase)
181 p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase)
182182 if err != nil {
183183 // Config validation already parsed the key, so this
184184 // is not a misconfiguration; fail loudly rather than
docs/specs/2026-09-20-ios-push-notifications-design.md +10 −1
@@ -207,7 +207,9 @@ The payload:
207207 "sound": "default",
208208 "thread-id": "krz/gitbay"
209209 },
210 "path": "krz/gitbay/issues/12"
210 "path": "krz/gitbay/issues/12",
211 "instance": "https://gitbay.org",
212 "user": "cmc"
211213}
212214```
213215
@@ -217,6 +219,13 @@ string the inbox row carries, so the two surfaces cannot disagree.
217219`path` is the inbox row's `path` field, which the app already knows how
218220to turn into a link.
219221
222`instance` is this instance's `site_url` and `user` the recipient's
223username. A device token is one install, and an install registers
224against every account signed in on it, so `path` alone cannot say which
225account a notice belongs to — two instances can hold the same
226`owner/name`. The pair is the account's identity, and the client
227resolves it before routing.
228
220229`apns-push-type: alert`, `apns-topic` from config, and
221230`apns-collapse-id` unset — collapsing is wrong here, two comments are
222231two notices.
e2e/push_test.go +8
@@ -125,6 +125,14 @@ environment = "production"
125125 if got[0]["path"] != "alice/app/issues/1" {
126126 t.Fatalf("path = %v", got[0]["path"])
127127 }
128 // The account the notice is for. A device signed in to several
129 // accounts cannot tell from the path alone which one this is.
130 if got[0]["user"] != "bob" {
131 t.Fatalf("user = %v", got[0]["user"])
132 }
133 if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") {
134 t.Fatalf("instance = %v, want this instance's site_url", inst)
135 }
128136
129137 // Apple retires the token. The next push reaps the device.
130138 mu.Lock()
internal/push/apns.go +17 −6
@@ -40,16 +40,21 @@ type Client struct {
4040 host string
4141 scheme string
4242 topic string
43 // siteURL is this instance, as the alert reports it. With the
44 // recipient's username it identifies the account a notice belongs
45 // to, which a device signed in to several cannot otherwise tell.
46 siteURL string
4347}
4448
45func NewClient(cfg config.Push) (*Client, error) {
49func NewClient(cfg config.Push, siteURL string) (*Client, error) {
4650 c := &Client{
4751 // stdlib negotiates HTTP/2 over ALPN, which is what APNs
4852 // requires; no explicit http2 transport is needed.
49 http: &http.Client{Timeout: 30 * time.Second},
50 host: cfg.Host(),
51 scheme: apnsScheme(),
52 topic: cfg.Topic,
53 http: &http.Client{Timeout: 30 * time.Second},
54 host: cfg.Host(),
55 scheme: apnsScheme(),
56 topic: cfg.Topic,
57 siteURL: siteURL,
5358 }
5459 if cfg.KeyFile != "" {
5560 key, err := config.LoadAPNSKey(cfg.KeyFile)
@@ -106,7 +111,7 @@ func loopbackHost(hostport string) bool {
106111
107112// Send delivers one alert. The returned duration is the server's
108113// Retry-After when it gave one, zero otherwise.
109func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) {
114func (c *Client) Send(ctx context.Context, token, user, title, body, path string) (result, time.Duration, error) {
110115 if len(body) > maxBodyBytes {
111116 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
112117 // accents, non-Latin usernames). ToValidUTF8 drops the
@@ -121,6 +126,12 @@ func (c *Client) Send(ctx context.Context, token, title, body, path string) (res
121126 "thread-id": title,
122127 },
123128 "path": path,
129 // Which account this is for. A device token is one install, and
130 // an install registers against every account signed in on it, so
131 // path alone is ambiguous — two instances can hold the same
132 // owner/name. Together these are the account's identity.
133 "instance": c.siteURL,
134 "user": user,
124135 })
125136 if err != nil {
126137 return resultDead, 0, err
internal/push/apns_test.go +33 −4
@@ -24,7 +24,7 @@ func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
2424 c, err := NewClient(config.Push{
2525 Enabled: true, KeyID: "K", TeamID: "T",
2626 Topic: "org.gitbay.gitbay", Environment: "production",
27 })
27 }, "https://gitbay.example")
2828 if err != nil {
2929 t.Fatal(err)
3030 }
@@ -45,7 +45,7 @@ func TestSendShapesTheRequest(t *testing.T) {
4545 json.Unmarshal(raw, &payload)
4646 w.WriteHeader(200)
4747 })
48 res, _, err := c.Send(context.Background(), "DEVTOKEN", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
48 res, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
4949 if err != nil || res != resultSent {
5050 t.Fatalf("res = %v, err = %v", res, err)
5151 }
@@ -103,7 +103,7 @@ func TestSendMapsResponses(t *testing.T) {
103103 w.WriteHeader(tc.status)
104104 io.WriteString(w, tc.body)
105105 })
106 res, after, err := c.Send(context.Background(), "T", "t", "b", "p")
106 res, after, err := c.Send(context.Background(), "T", "u", "t", "b", "p")
107107 // Only a delivered push has no error. Every other result
108108 // carries the status and reason, which is what the drainer
109109 // records on the queue row.
@@ -134,7 +134,7 @@ func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
134134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on
135135 // the second byte of one of them rather than a rune boundary.
136136 long := "x" + strings.Repeat("é", 2000)
137 res, _, err := c.Send(context.Background(), "T", "t", long, "p")
137 res, _, err := c.Send(context.Background(), "T", "u", "t", long, "p")
138138 if err != nil || res != resultSent {
139139 t.Fatalf("res = %v, err = %v", res, err)
140140 }
@@ -172,3 +172,32 @@ func TestAPNSSchemeDowngradesOnlyOnLoopback(t *testing.T) {
172172 }
173173 }
174174}
175
176// The alert names the account it belongs to. One device token is one
177// install, and an install registers against every account signed in on
178// it, so `path` alone cannot say which instance a notice came from — two
179// instances can hold the same owner/name.
180func TestSendNamesTheAccount(t *testing.T) {
181 var payload map[string]any
182 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
183 raw, _ := io.ReadAll(r.Body)
184 json.Unmarshal(raw, &payload)
185 w.WriteHeader(200)
186 })
187 c.siteURL = "https://gitbay.org"
188
189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc",
190 "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
191 t.Fatalf("Send: %v", err)
192 }
193 if payload["instance"] != "https://gitbay.org" {
194 t.Fatalf("instance = %v", payload["instance"])
195 }
196 if payload["user"] != "cmc" {
197 t.Fatalf("user = %v", payload["user"])
198 }
199 // Still carries what it always did.
200 if payload["path"] != "krz/gitbay/issues/12" {
201 t.Fatalf("path = %v", payload["path"])
202 }
203}
internal/push/push.go +3 −3
@@ -20,8 +20,8 @@ type Deliverer struct {
2020 MaxAttempts int
2121}
2222
23func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error) {
24 cl, err := NewClient(cfg)
23func New(st *store.Store, cfg config.Push, siteURL string, retryBase time.Duration) (*Deliverer, error) {
24 cl, err := NewClient(cfg, siteURL)
2525 if err != nil {
2626 return nil, err
2727 }
@@ -49,7 +49,7 @@ func (d *Deliverer) drain(ctx context.Context) {
4949 return
5050 }
5151 for _, q := range due {
52 res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Title, q.Body, q.Path)
52 res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Username, q.Title, q.Body, q.Path)
5353 msg := ""
5454 if sendErr != nil {
5555 msg = sendErr.Error()
internal/store/push.go +9 −3
@@ -120,6 +120,10 @@ type QueuedPush struct {
120120 ID int64
121121 DeviceID int64
122122 Token string
123 // Username is the recipient. One device token is one install, and an
124 // install registers against every account signed in on it, so the
125 // alert has to name which of them it is for.
126 Username string
123127 Title string
124128 Body string
125129 Path string
@@ -145,8 +149,10 @@ func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
145149
146150func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
147151 rows, err := s.DB.Query(`
148 SELECT q.id, q.device_id, d.token, q.title, q.body, q.path, q.attempts
149 FROM push_queue q JOIN push_devices d ON d.id = q.device_id
152 SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts
153 FROM push_queue q
154 JOIN push_devices d ON d.id = q.device_id
155 JOIN users u ON u.id = d.user_id
150156 WHERE q.sent_at IS NULL AND q.failed_at IS NULL
151157 AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
152158 ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit)
@@ -157,7 +163,7 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
157163 var out []QueuedPush
158164 for rows.Next() {
159165 var p QueuedPush
160 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil {
166 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil {
161167 return nil, err
162168 }
163169 out = append(out, p)
internal/store/push_test.go +28
@@ -247,3 +247,31 @@ func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) {
247247 t.Fatalf("queued rows outlived their device")
248248 }
249249}
250
251// A queued push carries the recipient's username, so the alert can name
252// the account it belongs to. A device token is one install, and one
253// install registers against every account signed in on it; without the
254// username the client cannot tell which of them a push is for.
255func TestDuePushCarriesTheUsername(t *testing.T) {
256 s := pushFixture(t)
257 uid, err := s.CreateUser("alice", false)
258 if err != nil {
259 t.Fatal(err)
260 }
261 if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil {
262 t.Fatal(err)
263 }
264 if err := s.EnqueuePush(uid, "alice/app", "bob opened issue #1", "alice/app/issues/1"); err != nil {
265 t.Fatal(err)
266 }
267 due, err := s.DuePush(20)
268 if err != nil {
269 t.Fatal(err)
270 }
271 if len(due) != 1 {
272 t.Fatalf("want one queued push, got %d", len(due))
273 }
274 if due[0].Username != "alice" {
275 t.Fatalf("Username = %q, want alice", due[0].Username)
276 }
277}