diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 0c17e834e..c4b175d21 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -119,7 +119,7 @@ Installed means `navigator.standalone === true` (iOS) or the standard `(display- **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. -**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. +**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 another Burrow's row, and never the scope's shared `PushSubscription`, which only forgetting this phone drops ([What Pocket stores](#what-pocket-stores)). 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`. @@ -133,9 +133,11 @@ Source of truth: `getPushAvailability` / `subscribeToPushInBrowser` in `lib/src/ **One module owns the IndexedDB name, its version, its upgrade, and every open** (rationale). `dormouse-pocket` is at **v4**: `known-burrows` (`KnownBurrowV1`, keyed by `burrowId`) and `pending-deletions` (`PendingDeliveryDeletionV1`, keyed `burrowId:deliveryId`); every earlier version upgrades to that shape. **A version bump is a compatibility event: it must never empty `pending-deletions` for a store already at v4**, which would discard owed deletions. **`navigator.storage.persist()` is requested best-effort**: a browser that refuses gets ordinary eviction-prone storage, which re-pairing survives ([remote-security-model.md](./remote-security-model.md) → Client static loss). -**A `KnownBurrowV1` is this Client's whole authorization state** for one Burrow. **`localStorage` holds only the `:passkey:` cache, the `:push-endpoint` digest, and the Relay session** (`dormouse-pocket:session`: token, account, credential id, expiry; rationale). **A launch holding an unexpired session opens on the Burrows list once the capability gate passes and its first read answers**, which finds out whether the Relay still honors it. **Sign out forgets the session locally; nothing revokes it on the Relay.** +**A `KnownBurrowV1` is this Client's whole authorization state** for one Burrow. **`localStorage` holds only the `:passkey:` cache, the `:push-endpoint` digest, and the Relay session** (`dormouse-pocket:session`: token, account, credential id, expiry; rationale). **A launch holding an unexpired session opens on the Burrows list once the capability gate passes and its first read answers**, which finds out whether the Relay still honors it. **Sign out forgets the session locally; nothing revokes it on the Relay.** **Sign out keeps every `KnownBurrowV1`, the `:passkey:` cache, and the push digest**, so signing in to the same account again restores access without pairing. -Source of truth: `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; `generatePocketKeyPair` in `lib/src/remote/client/pocket-private-key.ts`. +**Sign out and forget this phone asks first, then runs Remove for every record, of every account**, draining the deletion queue once; then it clears the `:passkey:` cache and the push digest, unsubscribes the scope's `PushSubscription` (rationale), and signs out, so the next launch is a first visit. **It reaches nothing on a Burrow**: each counts this phone among its paired phones until that Burrow's own Disconnect ([remote-security-model.md](./remote-security-model.md) → Revocation propagation). + +Source of truth: `PocketClient.forgetThisPhone` in `lib/src/remote/client/pocket-client.ts`; `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; `generatePocketKeyPair` in `lib/src/remote/client/pocket-private-key.ts`. ## Serving the built bundle diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index cabaaf30e..77fff3752 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -78,6 +78,8 @@ The phone subsequently reported `write-record / DataError` in the disposable dat **Why the Relay session may sit in `localStorage`.** Without it every cold launch, and every relaunch of an installed app iOS evicted, cost a sign-in prompt before the Burrows list. The token is authentication-plane only: it reaches the Relay's routes and sockets for at most its 12 hours, and never stands in for a presence proof at a Burrow (remote-security-model, Presence proofs). Its exposure is the Client statics' class, which already live in the same origin's storage and are the stronger capability; script running in the Pocket origin is outside the model either way. It holds the credential id because pairing names the passkey a proof is built from, which a relaunch could not otherwise recover without a prompt. +**Why forgetting this phone also drops the browser's push subscription.** Another account's tombstone drains only under that account's session, which a leaving phone may never hold again. With the subscription gone, the push service answers that row's next delivery with a 410, and the Relay deletes it then. + **Why one module owns every IndexedDB open.** Two modules opening the same database can disagree about the version, and a connection held open across an upgrade blocks it indefinitely. Centralizing name, version, upgrade and open makes both states unreachable rather than merely unlikely. ## Serving the built bundle diff --git a/docs/specs/security.md b/docs/specs/security.md index dfa0daf4f..51c579d0f 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -76,7 +76,7 @@ Gaps rather than accepted risks: we intend to close them. - *One-time link, Hosted phone, Self-hosted Relay.* **No independent third party has reviewed remote control's cryptography.** Dormouse's Noise integration, passkey (WebAuthn) channel binding, key storage, and push sealing are checked only by this project's own tests, lints, and nightly audits, which AI agents run ([Noise suite](./remote-security-model.md#noise-suite)). - *Self-hosted Relay.* **Anyone who can reach your Relay can hold off sign-in by flooding it**: unauthenticated requests share the capped pool of sign-in challenges, evicting a real one before its passkey prompt is answered. The default install admits only your tailnet ([Network posture (self-hosted)](./security-remote.md#network-posture-self-hosted)). - *Hosted phone, Self-hosted Relay.* **A user-verification demand raised after enrollment can be lost if this computer cannot save it.** The Burrow keeps the raise in memory and retries the save at each start, but a relaunch while saving keeps failing starts it at presence until the Relay repeats the demand ([Trust boundary](./security-remote.md#trust-boundary)). -- *Hosted phone, Self-hosted Relay.* **Revocation has no mechanism**: nothing in the product revokes a paired phone ([Revocation propagation](./remote-security-model.md#revocation-propagation)). +- *Hosted phone, Self-hosted Relay.* **Revocation has no mechanism**: nothing in the product revokes a paired phone ([Revocation propagation](./remote-security-model.md#revocation-propagation)); a phone can only forget its own pairings, with Pocket's Sign out and forget this phone. - *One-time link, Hosted phone, Self-hosted Relay.* **There is no structured audit trail** covering connects, attaches, denials, or writes, so a self-hoster cannot answer "did anyone connect to my laptop last night"; each pairing records its approval, and owner-local logs report some rejections ([Burrow Authorization](./remote-security-model.md#burrow-authorization), [Trust boundary](./security-remote.md#trust-boundary)). - **A fork PR the bot checks out mid-run reaches it with that fork's instruction files.** The harness pins project-instruction paths to the base branch only for the checkouts it makes before the agent starts ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). - **The workflow audit's window can be evaded.** A pusher-set committer date hides a commit from every later window, and a branch pushed, run with repo-level secrets in scope, and deleted before the nightly fetch is in none ([Automated Maintainer](./security-ci.md#automated-maintainer-tend)). diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index aed99f3d6..cd0381501 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1753,6 +1753,80 @@ describe('the durable deletion queue', () => { }); }); +describe('forgetting this phone', () => { + /** A signed-in phone holding two records, a cached passkey, and a push digest. */ + async function leavingPhone(routes: Record = {}) { + const storage = memoryStorage(); + const harness = await signedIn(routes, { storage }); + await seedRecord(harness.knownBurrows, 'h1'); + await seedRecord(harness.knownBurrows, 'h2'); + storage.setRegisteredPushEndpoint('endpoint-digest'); + return { ...harness, storage }; + } + + it('removes every record, queues every delivery id, wipes the caches, and signs out, offline', async () => { + // No deletion route: every DELETE fails as an unreachable Relay's would. + const phone = await leavingPhone(); + expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]); + + await phone.client.forgetThisPhone(); + + expect(phone.knownBurrows.records.size).toBe(0); + expect( + [...phone.pendingDeletions.records.values()].map(({ accountId, deliveryId }) => ({ accountId, deliveryId })), + ).toEqual([ + { accountId: ACCOUNT_ID, deliveryId: 'delivery-h1' }, + { accountId: ACCOUNT_ID, deliveryId: 'delivery-h2' }, + ]); + expect(phone.storage.knownCredentialIds()).toEqual([]); + expect(phone.client.hasPriorUse()).toBe(false); + expect(phone.storage.getRegisteredPushEndpoint()).toBeNull(); + expect(phone.client.sessionToken).toBeNull(); + expect(phone.storage.getSession()).toBeNull(); + }); + + it('retires every delivery row under the session before signing out', async () => { + const deletes: string[] = []; + const phone = await leavingPhone({ + '/api/push/subscriptions/delivery-h1': () => (deletes.push('delivery-h1'), { status: 204 }), + '/api/push/subscriptions/delivery-h2': () => (deletes.push('delivery-h2'), { status: 204 }), + }); + + await phone.client.forgetThisPhone(); + + expect(deletes.sort()).toEqual(['delivery-h1', 'delivery-h2']); + expect(phone.pendingDeletions.records.size).toBe(0); + expect(phone.client.sessionToken).toBeNull(); + }); + + it('queues another account’s delivery id for that account', async () => { + const phone = await leavingPhone({ + '/api/push/subscriptions/delivery-h1': () => ({ status: 204 }), + '/api/push/subscriptions/delivery-h2': () => ({ status: 204 }), + }); + await seedRecord(phone.knownBurrows, 'h3', { accountId: 'other-account' }); + + await phone.client.forgetThisPhone(); + + expect(phone.knownBurrows.records.size).toBe(0); + expect([...phone.pendingDeletions.records.values()]).toEqual([ + { burrowId: 'h3', accountId: 'other-account', deliveryId: 'delivery-h3', queuedAt: expect.any(Number) }, + ]); + }); + + it('is not what plain sign-out does', async () => { + const phone = await leavingPhone(); + + phone.client.signOut(); + + expect([...phone.knownBurrows.records.keys()]).toEqual(['h1', 'h2']); + expect(phone.pendingDeletions.records.size).toBe(0); + expect(phone.storage.knownCredentialIds()).toEqual([CREDENTIAL_ID]); + expect(phone.storage.getRegisteredPushEndpoint()).toBe('endpoint-digest'); + expect(phone.client.sessionToken).toBeNull(); + }); +}); + // --- The account plane ------------------------------------------------------ describe('the session across launches', () => { @@ -2392,6 +2466,23 @@ describe('localStoragePocketStorage', () => { expect(map.has('dormouse-pocket:session')).toBe(false); }); + it('forgets the passkey cache and the push digest in the tab and in storage', () => { + const { map, store } = fakeLocalStorage(); + vi.stubGlobal('localStorage', store); + const earlierVisit = localStoragePocketStorage(); + earlierVisit.setPasskeyPublicKey('cred-1', 'pk-1'); + earlierVisit.setRegisteredPushEndpoint('digest'); + const storage = localStoragePocketStorage(); + storage.setPasskeyPublicKey('cred-2', 'pk-2'); + + for (const id of storage.knownCredentialIds()) storage.forgetPasskeyPublicKey(id); + storage.clearRegisteredPushEndpoint(); + + expect(storage.knownCredentialIds()).toEqual([]); + expect(storage.getRegisteredPushEndpoint()).toBeNull(); + expect([...map.keys()]).toEqual([]); + }); + it('clears the stored session only while it holds the token being cleared', () => { const { map, store } = fakeLocalStorage(); vi.stubGlobal('localStorage', store); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index bc8c2e6fe..e4deadc64 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -125,9 +125,9 @@ export interface PocketStorage { getPasskeyPublicKey(credentialId: string): string | null; setPasskeyPublicKey(credentialId: string, publicKey: string): void; /** - * Drop a cached key again. Only {@link PocketClient.setup} calls it, for a - * credential it cached moments earlier and the Relay then refused, so it - * never has an older visit's key to lose. + * Drop a cached key again: {@link PocketClient.setup} for a credential it + * cached moments earlier and the Relay then refused, and + * {@link PocketClient.forgetThisPhone} for every key this device holds. */ forgetPasskeyPublicKey(credentialId: string): void; /** Credential ids this device has stored a public key for (may be empty). */ @@ -140,6 +140,8 @@ export interface PocketStorage { */ getRegisteredPushEndpoint(): string | null; setRegisteredPushEndpoint(fingerprint: string): void; + /** Back to "never registered"; only {@link PocketClient.forgetThisPhone} calls it. */ + clearRegisteredPushEndpoint(): void; /** * The Relay session, kept so a relaunch lands signed in. It reaches the * Relay alone; every Burrow ceremony still demands its own presence proof. @@ -659,18 +661,46 @@ export class PocketClient { return this.#knownBurrows.listSummaries(); } + /** Forget one Burrow locally (Remove), then drain the deletion queue. */ + async forgetBurrow(burrowId: string): Promise { + await this.#removeRecord(burrowId); + await this.retirePendingDeletions(); + } + /** - * Forget one Burrow locally: the tombstone is written *before* the record that - * holds the delivery id is deleted, so an unreachable Relay cannot strand a - * push row nothing can name again. + * Make this device forget every Burrow and the account, then sign out: a + * phone that is leaving. Each record goes the way {@link forgetBurrow} takes + * it, its delivery id tombstoned first, and the queue drains once at the end + * under this session, so an unreachable Relay leaves the tombstones for a + * later session of their account. Then the passkey cache and the push + * endpoint digest go, which makes the next launch a first visit. + * + * The browser's `PushSubscription` is the caller's to drop: this class + * touches no browser push API. */ - async forgetBurrow(burrowId: string): Promise { + async forgetThisPhone(): Promise { + for (const { burrowId } of await this.#knownBurrows.listSummaries()) { + await this.#removeRecord(burrowId); + } + await this.retirePendingDeletions(); + for (const credentialId of this.#storage.knownCredentialIds()) { + this.#storage.forgetPasskeyPublicKey(credentialId); + } + this.#storage.clearRegisteredPushEndpoint(); + this.signOut(); + } + + /** + * Delete one record locally: the tombstone is written *before* the record + * that holds the delivery id is deleted, so an unreachable Relay cannot + * strand a push row nothing can name again. + */ + async #removeRecord(burrowId: string): Promise { const record = await this.#knownBurrows.getSummary(burrowId); if (record?.authorization.state === 'paired') { await this.#tombstone(record, record.authorization.deliveryId); } await this.#knownBurrows.delete(burrowId); - await this.retirePendingDeletions(); } // --- Web Push ------------------------------------------------------------ @@ -1499,9 +1529,8 @@ export function localStoragePocketStorage(): PocketStorage { passkeys.set(credentialId, publicKey); write(PASSKEY_PREFIX + credentialId, publicKey); }, - // No tombstone: the only key ever forgotten was written by this same run, - // so storage holds it only if that write worked — in which case this - // removal does too. + // No tombstone in the mirror: storage that refuses this removal refuses + // the reads too, so a key it still holds is one nothing reads back. forgetPasskeyPublicKey: (credentialId) => { passkeys.delete(credentialId); drop(PASSKEY_PREFIX + credentialId); @@ -1521,6 +1550,11 @@ export function localStoragePocketStorage(): PocketStorage { pushEndpoint = fingerprint; write(PUSH_ENDPOINT_KEY, fingerprint); }, + // As with the passkeys, no tombstone: blocked storage reads nothing back. + clearRegisteredPushEndpoint: () => { + pushEndpoint = undefined; + drop(PUSH_ENDPOINT_KEY); + }, // No mirror: the client reads this once, at construction, and holds the // session itself for the life of the tab. getSession: () => parseStoredSession(read(SESSION_KEY)), diff --git a/lib/src/remote/client/push-subscribe.test.ts b/lib/src/remote/client/push-subscribe.test.ts index 8cef7c420..3473212af 100644 --- a/lib/src/remote/client/push-subscribe.test.ts +++ b/lib/src/remote/client/push-subscribe.test.ts @@ -12,6 +12,7 @@ import { hasCurrentPushSubscription, requiresInstallForPush, subscribeToPushInBrowser, + unsubscribeFromPushInBrowser, } from './push-subscribe'; /** @@ -353,3 +354,24 @@ describe('subscribeToPushInBrowser', () => { expect(onReplaced).toHaveBeenCalledOnce(); }); }); + +describe('unsubscribeFromPushInBrowser', () => { + it('drops the scope-wide subscription', async () => { + stubBrowser({ permission: 'granted' }); + const unsubscribe = vi.fn(async () => true); + getRegistration.mockResolvedValue(registration({ unsubscribe })); + + await unsubscribeFromPushInBrowser(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('never throws, so a leaving phone still signs out', async () => { + stubBrowser({ permission: 'granted' }); + getRegistration.mockResolvedValue( + registration({ unsubscribe: () => Promise.reject(new Error('push service unreachable')) }), + ); + + await expect(unsubscribeFromPushInBrowser()).resolves.toBeUndefined(); + }); +}); diff --git a/lib/src/remote/client/push-subscribe.ts b/lib/src/remote/client/push-subscribe.ts index d45c06f08..049725339 100644 --- a/lib/src/remote/client/push-subscribe.ts +++ b/lib/src/remote/client/push-subscribe.ts @@ -135,6 +135,21 @@ export async function subscribeToPushInBrowser( return { endpoint, keys: { p256dh, auth } }; } +/** + * Drop the scope's `PushSubscription`, so its address reaches this device no + * more. Best-effort and never throws: a phone that is leaving must still sign + * out, and a Relay row left pointing at a dead address is pruned on its next + * delivery. + */ +export async function unsubscribeFromPushInBrowser(): Promise { + try { + const registration = await getPushServiceWorkerRegistration(); + await (await registration?.pushManager.getSubscription())?.unsubscribe(); + } catch { + // Nothing left to do with a subscription the browser will not drop. + } +} + /** A missing reported key mismatches, safely forcing rotation. */ function sameBytes(actual: ArrayBuffer | null, expected: Uint8Array): boolean { if (!actual) return false; diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index 13eec37ea..4cf5df165 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -128,6 +128,7 @@ export function memoryStorage(): PocketStorage { knownCredentialIds: () => [...passkeys.keys()], getRegisteredPushEndpoint: () => pushEndpoint, setRegisteredPushEndpoint: (fingerprint) => void (pushEndpoint = fingerprint), + clearRegisteredPushEndpoint: () => void (pushEndpoint = null), getSession: () => session, setSession: (next) => void (session = next), clearSession: (token) => { diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index 09983c151..f839b8c6a 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -25,6 +25,9 @@ import App, { CAMERA_BOOTSTRAP_MESSAGE, BURROWS_EMPTY, BURROWS_TITLE, + FORGET_PHONE_CONFIRM, + FORGET_PHONE_LABEL, + FORGET_PHONE_TITLE, SCAN_LABEL, } from './App'; import { @@ -82,6 +85,8 @@ const fake = vi.hoisted(() => ({ >(), connect: vi.fn<(burrowId: string) => Promise>(), forgetBurrow: vi.fn<(burrowId: string) => Promise>(), + forgetThisPhone: vi.fn<() => Promise>(), + unsubscribePush: vi.fn<() => Promise>(), clientClose: vi.fn<() => void>(), hello: vi.fn<() => Promise>(), adapterInit: vi.fn<() => Promise>(), @@ -102,6 +107,7 @@ vi.mock('../client/push-subscribe', () => ({ hasCurrentPushSubscription: () => Promise.resolve(false), needsHomeScreenInstall: () => false, subscribeToPushInBrowser: () => Promise.reject(new Error('not under test')), + unsubscribeFromPushInBrowser: () => fake.unsubscribePush(), })); vi.mock('../client/pocket-db', async (importOriginal) => ({ @@ -142,6 +148,7 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ listKnownBurrows = () => fake.listKnownBurrows(); listBurrows = () => fake.listBurrows(); forgetBurrow = (burrowId: string) => fake.forgetBurrow(burrowId); + forgetThisPhone = () => fake.forgetThisPhone(); pair = ( invitation: PairingInvitation, label: string, @@ -247,6 +254,11 @@ beforeEach(() => { fake.pair.mockReset(); fake.connect.mockReset().mockResolvedValue({ ok: true, burrowLabel: 'First laptop' }); fake.forgetBurrow.mockReset().mockResolvedValue(undefined); + fake.forgetThisPhone.mockReset().mockImplementation(async () => { + fake.hasPriorUse = false; + fake.sessionToken = null; + }); + fake.unsubscribePush.mockReset().mockResolvedValue(undefined); fake.clientClose.mockReset(); fake.hello.mockReset().mockResolvedValue({}); fake.adapterInit.mockReset().mockResolvedValue(undefined); @@ -586,6 +598,10 @@ describe('a session kept from an earlier launch', () => { expect(fake.signOut).toHaveBeenCalledOnce(); expect(buttonNamed(container, 'Sign in with passkey')).not.toBeNull(); + // The records, the passkey cache, and the push address all stay. + expect(fake.forgetThisPhone).not.toHaveBeenCalled(); + expect(fake.forgetBurrow).not.toHaveBeenCalled(); + expect(fake.unsubscribePush).not.toHaveBeenCalled(); }); /** @@ -1035,3 +1051,58 @@ describe('a Burrow the Relay no longer lists', () => { expect(alertText(container)).toBe(BURROW_UNAVAILABLE_MESSAGE); }); }); + +describe('forgetting this phone', () => { + beforeEach(async () => { + fake.hasPriorUse = true; + fake.sessionToken = 'stored'; + fake.listKnownBurrows.mockResolvedValue([await knownBurrow('burrow-1')]); + fake.listBurrows.mockResolvedValue([{ burrowId: 'burrow-1', label: 'x', online: true }]); + }); + + const confirmation = () => container.querySelector('[role="group"]'); + + it('asks first, and Cancel forgets nothing', async () => { + await boot(); + + await click(container, FORGET_PHONE_LABEL); + + expect(confirmation()?.textContent).toContain(FORGET_PHONE_TITLE); + expect(fake.forgetThisPhone).not.toHaveBeenCalled(); + // The question replaces both exits rather than sitting beside them. + expect(buttonNamed(container, 'Sign out')).toBeNull(); + + await click(container, 'Cancel'); + + expect(confirmation()).toBeNull(); + expect(fake.forgetThisPhone).not.toHaveBeenCalled(); + expect(fake.unsubscribePush).not.toHaveBeenCalled(); + expect(rowFor(container, 'First laptop')).not.toBeNull(); + }); + + it('forgets every record, drops the push subscription, and lands on the auth screen', async () => { + await boot(); + + await click(container, FORGET_PHONE_LABEL); + await click(container, FORGET_PHONE_CONFIRM); + + expect(fake.forgetThisPhone).toHaveBeenCalledOnce(); + expect(fake.unsubscribePush).toHaveBeenCalledOnce(); + expect(container.textContent).not.toContain(BURROWS_TITLE); + expect(buttonNamed(container, SCAN_LABEL)).not.toBeNull(); + expect(alertText(container)).toBeNull(); + }); + + it('stays signed in on the list, saying why, when forgetting fails', async () => { + fake.forgetThisPhone.mockRejectedValue(new Error('storage unavailable')); + await boot(); + + await click(container, FORGET_PHONE_LABEL); + await click(container, FORGET_PHONE_CONFIRM); + + expect(alertText(container)).toBe('storage unavailable'); + expect(fake.unsubscribePush).not.toHaveBeenCalled(); + expect(container.textContent).toContain(BURROWS_TITLE); + expect(buttonNamed(container, FORGET_PHONE_CONFIRM)).not.toBeNull(); + }); +}); diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 7e76c2139..242b2dfa7 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -14,7 +14,7 @@ * `pocket-chrome.tsx` and docs/specs/theme.md. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { clsx } from 'clsx'; import { PocketClient, @@ -41,6 +41,7 @@ import { hasCurrentPushSubscription, needsHomeScreenInstall, subscribeToPushInBrowser, + unsubscribeFromPushInBrowser, type PushAvailability, } from '../client/push-subscribe'; import type { RemotePtyAdapter } from '../client/remote-adapter'; @@ -645,6 +646,17 @@ export default function App({ setPhase({ at: 'auth' }); }; + // A phone that is leaving: every record goes the way Remove takes it, and + // the browser's push address with them. A failure part-way leaves this + // screen up, signed in, to try again; what was already removed stays removed. + const forgetThisPhone = () => + run(FORGET_PHONE_OP, async () => { + await client.forgetThisPhone(); + await unsubscribeFromPushInBrowser(); + setBurrows([]); + setPhase({ at: 'auth' }); + }); + // --- Views --------------------------------------------------------------- // Gated, not degraded: nothing above has performed a remote operation, and @@ -736,6 +748,7 @@ export default function App({ onEnablePush={onEnablePush} onRetryPushConfig={onRetryPushConfig} onSignOut={signOut} + onForgetThisPhone={forgetThisPhone} /> ); } @@ -1042,6 +1055,22 @@ function InstallNotice(): React.ReactElement { /** The `run` label the registration owns; the card selects its spinner on it. */ const PUSH_OP = 'push'; +const FORGET_PHONE_OP = 'forget-phone'; + +/** Plain Sign out keeps every record; this is the one that does not. */ +export const FORGET_PHONE_LABEL = 'Sign out and forget this phone'; +export const FORGET_PHONE_TITLE = 'Forget this phone?'; +export const FORGET_PHONE_CONFIRM = 'Forget this phone'; +/** + * What forgetting cannot reach. A Burrow has no per-phone removal: its + * Disconnect, which drops every phone, is the only way it stops listing this + * one (`docs/specs/remote-security-model.md` -> "Revocation propagation"). + */ +export const FORGET_PHONE_BODY = + 'Removes every Burrow from this phone, stops its push notifications, and signs out. ' + + 'This can’t be undone: each Burrow must be paired again. ' + + 'Each Burrow still lists this phone until its Disconnect, in Settings → Network, clears it.'; + const PUSH_ENABLE_LABEL = 'Enable push notifications'; /** The one pitch, on the one card. */ @@ -1195,6 +1224,7 @@ export function BurrowsView({ onEnablePush, onRetryPushConfig, onSignOut, + onForgetThisPhone, }: { /** The pinned records; a Burrow with no record is not one of these. */ burrows: BurrowView[]; @@ -1217,7 +1247,11 @@ export function BurrowsView({ onEnablePush: () => void; onRetryPushConfig: () => void; onSignOut: () => void; + /** Runs only from the confirmation this view asks first. */ + onForgetThisPhone: () => void; }): React.ReactElement { + const [confirmingForget, setConfirmingForget] = useState(false); + const forgetTitleId = useId(); const pushNotice = pushNoticeState({ pairedBurrowIds: burrows.filter(isPushTarget).map((h) => h.burrowId), isPushSubscribed, @@ -1322,14 +1356,53 @@ export function BurrowsView({ > {SCAN_LABEL} - + {/* Asked inline rather than in a dialog: the screen is a single column + already, and the question replaces the two exits it asks about. */} + {confirmingForget ? ( +
+
+ {FORGET_PHONE_TITLE} +
+

{FORGET_PHONE_BODY}

+
+ + +
+
+ ) : ( +
+ + +
+ )} ); diff --git a/lib/src/stories/BurrowsView.stories.tsx b/lib/src/stories/BurrowsView.stories.tsx index 08bf13dea..b182af27f 100644 --- a/lib/src/stories/BurrowsView.stories.tsx +++ b/lib/src/stories/BurrowsView.stories.tsx @@ -1,9 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { within } from 'storybook/test'; +import { userEvent, within } from 'storybook/test'; // Importing from App.tsx runs `pocket-chrome`'s `index.css` side-effect import, // so Tailwind's utilities load for these stories. Storybook manages the theme // tokens (`--vscode-*`) itself. -import { BurrowsView, type BurrowView } from '../remote/pocket-app/App'; +import { + BurrowsView, + FORGET_PHONE_CONFIRM, + FORGET_PHONE_LABEL, + FORGET_PHONE_TITLE, + type BurrowView, +} from '../remote/pocket-app/App'; import { PhoneFrame } from './PhoneFrame'; // A paired online burrow (Connect), a burrow whose authorization the laptop revoked @@ -48,6 +54,7 @@ const meta: Meta = { onEnablePush: () => {}, onRetryPushConfig: () => {}, onSignOut: () => {}, + onForgetThisPhone: () => {}, }, decorators: [ (Story, context) => ( @@ -133,6 +140,18 @@ export const Forgetting: Story = { args: { busy: 'forget' }, }; +// Sign out and forget this phone → asked inline first, in place of both exits: +// it removes every record and cannot be undone. +export const ForgetThisPhoneConfirm: Story = { + args: { burrows: MIXED_BURROWS.slice(0, 1) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: FORGET_PHONE_LABEL })); + await canvas.findByRole('group', { name: FORGET_PHONE_TITLE }); + await canvas.findByRole('button', { name: FORGET_PHONE_CONFIRM }); + }, +}; + // Burrow dropped → the red error text above the list, in the words `setOnBurrowGone` // puts there (`App.tsx`). export const Error: Story = { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0fa7257e3..af9d617c5 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -17,7 +17,7 @@ "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4150, "docs/specs/one-time.md": 2600, - "docs/specs/pocket-app.md": 3500, + "docs/specs/pocket-app.md": 3600, "docs/specs/pricing.md": 2600, "docs/specs/relay.md": 5650, "docs/specs/remote-api.md": 4450,