Skip to content

Commit 3c8621a

Browse files
authored
Register push only for the signed-in account's Burrows (#1238)
2 parents 76def24 + 3a32427 commit 3c8621a

3 files changed

Lines changed: 63 additions & 15 deletions

File tree

‎docs/specs/pocket-app.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ Installed means `navigator.standalone === true` (iOS) or the standard `(display-
117117

118118
**Registering another Burrow, or retrying that POST, reuses the scope's existing `PushSubscription`** when its `applicationServerKey` matches the Relay's VAPID key byte-for-byte; a new endpoint is minted only when the key differs (rationale). **When it does rotate, the Relay drops that device's other Burrow rows in the same mutation** and its response lists what survived, which makes a committed POST whose response was lost self-repairing (rationale).
119119

120+
**Enable registers, and the offer counts, only the signed-in account's paired Burrows its list still names**: another account's record is never a target, since the session's Relay holds none of that account's Burrows.
121+
120122
**Obsolete delivery mappings are retired, durably.** A `pairing-required` transition, a re-pair that mints a new id, and an explicit **Remove** each write the old `{ burrowId, accountId, deliveryId }` (the record's account) to `PendingDeliveryDeletionV1` *before* the record forgets it (rationale), then call the idempotent deletion route; tombstones retry until a Relay answer clears them. **Only a session of the tombstone's account sends or clears it** (rationale). **This deletes the delivery row alone** — never the scope's shared `PushSubscription`, and never another Burrow's row.
121123

122124
Source of truth: `getPushAvailability` / `subscribeToPushInBrowser` in `lib/src/remote/client/push-subscribe.ts`; `pushNoticeState` in `lib/src/remote/pocket-app/App.tsx`; `PocketClient.subscribeToPush` / `retirePendingDeletions` in `lib/src/remote/client/pocket-client.ts`; `pushEndpointFingerprint` in `remote-lib-common/src/security/push.ts`.

‎lib/src/remote/pocket-app/App.push.test.tsx‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const fake = vi.hoisted(() => ({
3535
retirePendingDeletions: vi.fn<() => Promise<void>>(),
3636
/** Paired Burrows the Relay's list no longer names: removed rows. */
3737
unlisted: [] as string[],
38+
/** Burrows paired under an earlier account, which this session's list never names. */
39+
previousAccount: [] as string[],
3840
/** Every client call, in order, so "before" can be asserted rather than assumed. */
3941
order: [] as string[],
4042
}));
@@ -68,9 +70,15 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({
6870
setOnTransportChanged = () => undefined;
6971
close = () => undefined;
7072
openSocket = async () => undefined;
71-
listKnownBurrows = async () => KNOWN;
73+
listKnownBurrows = async () =>
74+
KNOWN.map((record) =>
75+
fake.previousAccount.includes(record.burrowId) ? { ...record, accountId: 'previous' } : record,
76+
);
7277
listBurrows = async () =>
73-
KNOWN.filter((record) => !fake.unlisted.includes(record.burrowId)).map((record) => ({
78+
KNOWN.filter(
79+
(record) =>
80+
!fake.unlisted.includes(record.burrowId) && !fake.previousAccount.includes(record.burrowId),
81+
).map((record) => ({
7482
burrowId: record.burrowId,
7583
label: record.label,
7684
online: true,
@@ -134,6 +142,7 @@ beforeEach(() => {
134142
fake.availability = 'ready';
135143
fake.order = [];
136144
fake.unlisted = [];
145+
fake.previousAccount = [];
137146
fake.subscribeInBrowser.mockReset();
138147
fake.retirePendingDeletions.mockReset().mockResolvedValue(undefined);
139148
fake.listPushSubscribedBurrows.mockReset().mockResolvedValue([]);
@@ -264,6 +273,30 @@ describe('the one Enable on the Burrows view', () => {
264273
expect(buttonNamed(container, ENABLE)).toBeNull();
265274
});
266275

276+
/**
277+
* Records outlive an account switch, but the Relay scopes registration to the
278+
* session's account and refuses a Burrow another account holds. Registering
279+
* one would stop the loop before this account's own Burrows, and re-offer an
280+
* Enable that can never finish.
281+
*/
282+
it('skips a Burrow paired under another account, and keeps its row', async () => {
283+
fake.previousAccount = ['burrow-1'];
284+
fake.subscribeInBrowser.mockResolvedValue({ endpoint: 'https://push.example/abc' });
285+
fake.subscribeToPush.mockImplementation(async (burrowId) => {
286+
if (fake.previousAccount.includes(burrowId)) throw new Error('unknown burrow');
287+
return { burrowIds: [...registered.add(burrowId)] };
288+
});
289+
await openBurrows();
290+
291+
expect(rowFor(container, 'First laptop')).not.toBeNull();
292+
await click(container, ENABLE);
293+
294+
expect(fake.subscribeToPush.mock.calls.map(([burrowId]) => burrowId)).toEqual(['burrow-2']);
295+
expect(alertText(container)).toBeNull();
296+
expect(container.textContent).toContain('Push notifications on.');
297+
expect(buttonNamed(container, ENABLE)).toBeNull();
298+
});
299+
267300
/**
268301
* The readback is the only thing that says which Burrows hold a row. A read
269302
* that threw learned nothing — and empty is not nothing — so the card

‎lib/src/remote/pocket-app/App.tsx‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,21 @@ export interface BurrowView {
9292
* Absent is listed.
9393
*/
9494
removed?: boolean;
95+
/**
96+
* The record was paired under an account other than the signed-in one, so
97+
* this session's Relay does not hold the Burrow. The row keeps its offline
98+
* face; absent is the signed-in account's.
99+
*/
100+
otherAccount?: boolean;
95101
}
96102

97103
/**
98-
* A Burrow push can be registered against: paired, and still on the Relay's
99-
* list, whose `pushSubscribe` refuses a Burrow it no longer holds.
104+
* A Burrow push can be registered against: paired, of the signed-in account,
105+
* and still on the Relay's list, whose `pushSubscribe` refuses a Burrow the
106+
* session's account does not hold.
100107
*/
101108
function isPushTarget(burrow: BurrowView): boolean {
102-
return !burrow.needsPairing && !burrow.removed;
109+
return !burrow.needsPairing && !burrow.removed && !burrow.otherAccount;
103110
}
104111

105112
/** What a removed Burrow's row says, by who serves this Pocket. */
@@ -352,8 +359,9 @@ export default function App({
352359
* The Burrows list: the pinned records, with online state stamped on from the
353360
* Relay, and a record of the signed-in account the Relay's list no longer
354361
* names marked removed — only ever off a list that was read, since a failed
355-
* read throws. **Another account's record is never marked removed**: this
356-
* session's list names only its own account's Burrows. **A Burrow with no
362+
* read throws. **Another account's record is never marked removed**, only
363+
* `otherAccount`: this session's list names only its own account's Burrows,
364+
* so it can say nothing about that one. **A Burrow with no
357365
* record is not shown** — the Relay's list is discovery, and a row for a
358366
* computer this phone holds no key for would offer an action that cannot
359367
* exist. Answers the rows it painted.
@@ -362,13 +370,13 @@ export default function App({
362370
const account = client.accountId;
363371
const [records, enrolled] = await Promise.all([client.listKnownBurrows(), client.listBurrows()]);
364372
const online = new Map(enrolled.map((burrow) => [burrow.burrowId, burrow.online]));
365-
const views = records.map((record) =>
366-
toBurrowView(
367-
record,
368-
online.get(record.burrowId) ?? false,
369-
account !== null && record.accountId === account && !online.has(record.burrowId),
370-
),
371-
);
373+
const views = records.map((record) => {
374+
const ours = account !== null && record.accountId === account;
375+
return toBurrowView(record, online.get(record.burrowId) ?? false, {
376+
removed: ours && !online.has(record.burrowId),
377+
otherAccount: !ours,
378+
});
379+
});
372380
setBurrows(views);
373381
setPhase({ at: 'burrows' });
374382
// Owed deletions retry here: this runs after every sign-in and on every
@@ -748,13 +756,18 @@ async function retireUnlessExpired(client: PocketClient, setupToken: string): Pr
748756
}
749757

750758
/** One pinned record as the list renders it. */
751-
function toBurrowView(record: KnownBurrowSummary, online: boolean, removed = false): BurrowView {
759+
function toBurrowView(
760+
record: KnownBurrowSummary,
761+
online: boolean,
762+
{ removed, otherAccount }: Pick<BurrowView, 'removed' | 'otherAccount'> = {},
763+
): BurrowView {
752764
return {
753765
burrowId: record.burrowId,
754766
label: record.label || record.burrowId,
755767
online,
756768
needsPairing: record.authorization.state !== 'paired',
757769
...(removed ? { removed } : {}),
770+
...(otherAccount ? { otherAccount } : {}),
758771
};
759772
}
760773

0 commit comments

Comments
 (0)