diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 2dbc0f731f..9224a9f71a 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -731,6 +731,32 @@ describe('NotificationsService.refreshGlanceableSessions', () => { ]); }); + it('holds the push-to-start fence across a rotated push-to-start token', async () => { + const pem = await generateTestPrivateKeyPem(); + const { createService, apns, activityRows } = setupService({ + privateKey: async () => pem, + iosTokens: [{ token: 'scope-token', kind: 'ios_push_to_start' }], + response: () => Response.json(freshSnapshot({ running: 1 })), + }); + await createService().refreshGlanceableSessions(personalRefresh); + expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]); + + // The device registers a new push-to-start token while the card the first + // one raised is still on screen and unadopted. The fence belongs to the + // scope, so the new token must not raise a second card beside it: a + // push-to-start carries the alert APNs requires, and that alert is what + // lights the screen and expands the card. + activityRows.delete('scope-token'); + activityRows.set('rotated-token', { + id: 'row-rotated', + kind: 'ios_push_to_start', + updated_at: '2026-08-27 10:00:01+00', + }); + vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:20.000Z')); + await createService().refreshGlanceableSessions(personalRefresh); + expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]); + }); + it('retires a token APNs rejects with 410 on an update, not only on an end', async () => { const pem = await generateTestPrivateKeyPem(); const { service, activityRows } = setupService({ diff --git a/services/notifications/src/lib/glanceable-refresh.ts b/services/notifications/src/lib/glanceable-refresh.ts index 64bd33e88b..fa12579d91 100644 --- a/services/notifications/src/lib/glanceable-refresh.ts +++ b/services/notifications/src/lib/glanceable-refresh.ts @@ -336,7 +336,6 @@ export async function refreshGlanceableSnapshot( if (current.revision !== request.revision) return []; const withoutFencedStarts = await dropFencedStarts(tokens, storage, { prefix: iosStartPrefix, - key: iosStartKey, }); // Empty work can retry ends. Eligible work excludes every accepted or uncertain end. if (!eligible) return withoutFencedStarts; @@ -565,13 +564,20 @@ export async function foldPendingGlanceableRefreshDeadline( * fence has not lapsed is removed from the list, which leaves * `apnsSendsForTokens` with no start to send while that ambiguity stands. * + * The fence belongs to the scope, not to the token that wrote it. A card raised + * by push-to-start is still on screen while the device registers a fresh + * push-to-start token, and a start on that token would raise a second card + * beside it. APNs requires an `alert` on every push-to-start, and that alert is + * what lights the screen and expands the card, so while any fence in the scope + * is live every push-to-start token in it stays out of the list. + * * Every read also drops the lapsed fences, including those of tokens the device * has since rotated away and will never present again. */ async function dropFencedStarts( tokens: readonly T[], storage: DurableObjectStorage, - fence: { prefix: string; key: (token: string) => string } + fence: { prefix: string } ): Promise { const held = await storage.list({ prefix: fence.prefix }); // Only exactly one live (non-superseded) `ios_activity` row means the scope is @@ -593,8 +599,6 @@ async function dropFencedStarts 0) { await storage.delete(lapsed); } - return tokens.filter(({ token, kind }) => { - const until = held.get(fence.key(token)); - return kind !== 'ios_push_to_start' || until === undefined || until <= now; - }); + const scopeHeld = [...held].some(([, until]) => until > now); + return scopeHeld ? tokens.filter(({ kind }) => kind !== 'ios_push_to_start') : [...tokens]; }