diff --git a/src/App.tsx b/src/App.tsx index 37a0f404d9..3b4d5eb84a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import * as SystemUI from 'expo-system-ui' import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' +import {useAppBootstrap} from '#/lib/hooks/useAppBootstrap' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' @@ -31,7 +32,6 @@ import {listenSessionDropped} from '#/state/events' import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {MessagesProvider} from '#/state/messages' -import {init as initPersistedState} from '#/state/persisted' import {Provider as PrefsStateProvider} from '#/state/preferences' import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' @@ -44,10 +44,7 @@ import { useSession, useSessionApi, } from '#/state/session' -import { - getSessionRepository, - initSessionRepository, -} from '#/state/session/storage' +import {getSessionRepository} from '#/state/session/storage' import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' @@ -74,12 +71,7 @@ import { prefetchAgeAssuranceConfig, Provider as AgeAssuranceV2Provider, } from '#/ageAssurance' -import { - AnalyticsContext, - AnalyticsFeaturesContext, - features, - setupDeviceId, -} from '#/analytics' +import {AnalyticsContext, AnalyticsFeaturesContext, features} from '#/analytics' import {IS_ANDROID, IS_IOS} from '#/env' import { prefetchLiveEvents, @@ -219,40 +211,7 @@ function InnerApp() { } function App() { - const [isReady, setIsReady] = useState(false) - - useEffect(() => { - let cancelled = false - let retryTimer: ReturnType | undefined - let persistedInitialized = false - const ancillaryReady = Promise.all([Geo.resolve(), setupDeviceId]).catch( - error => { - // setupDeviceId is a module-level promise and cannot be restarted. - // Session storage is more important than blocking forever here. - logger.error('ancillary app initialization failed', {error}) - }, - ) - - async function initialize() { - try { - if (!persistedInitialized) { - await initPersistedState() - persistedInitialized = true - } - await initSessionRepository() - await ancillaryReady - if (!cancelled) setIsReady(true) - } catch (error) { - logger.error('app initialization failed', {error}) - if (!cancelled) retryTimer = setTimeout(() => void initialize(), 5_000) - } - } - void initialize() - return () => { - cancelled = true - if (retryTimer) clearTimeout(retryTimer) - } - }, []) + const isReady = useAppBootstrap() if (!isReady) { return null diff --git a/src/App.web.tsx b/src/App.web.tsx index c4ba54da05..be90051b27 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification' import {listenSessionDropped} from '#/state/events' import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {MessagesProvider} from '#/state/messages' -import {init as initPersistedState} from '#/state/persisted' import {Provider as PrefsStateProvider} from '#/state/preferences' import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' @@ -37,10 +36,7 @@ import { useSession, useSessionApi, } from '#/state/session' -import { - getSessionRepository, - initSessionRepository, -} from '#/state/session/storage' +import {getSessionRepository} from '#/state/session/storage' import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' @@ -67,12 +63,7 @@ import { prefetchAgeAssuranceConfig, Provider as AgeAssuranceV2Provider, } from '#/ageAssurance' -import { - AnalyticsContext, - AnalyticsFeaturesContext, - features, - setupDeviceId, -} from '#/analytics' +import {AnalyticsContext, AnalyticsFeaturesContext, features} from '#/analytics' import { prefetchLiveEvents, Provider as LiveEventsProvider, @@ -80,6 +71,7 @@ import { import * as Geo from '#/geolocation' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {useAppBootstrap} from './lib/hooks/useAppBootstrap' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' /** @@ -198,40 +190,7 @@ function InnerApp() { } function App() { - const [isReady, setIsReady] = useState(false) - - useEffect(() => { - let cancelled = false - let retryTimer: ReturnType | undefined - let persistedInitialized = false - const ancillaryReady = Promise.all([Geo.resolve(), setupDeviceId]).catch( - error => { - // setupDeviceId is a module-level promise and cannot be restarted. - // Session storage is more important than blocking forever here. - logger.error('ancillary app initialization failed', {error}) - }, - ) - - async function initialize() { - try { - if (!persistedInitialized) { - await initPersistedState() - persistedInitialized = true - } - await initSessionRepository() - await ancillaryReady - if (!cancelled) setIsReady(true) - } catch (error) { - logger.error('app initialization failed', {error}) - if (!cancelled) retryTimer = setTimeout(() => void initialize(), 5_000) - } - } - void initialize() - return () => { - cancelled = true - if (retryTimer) clearTimeout(retryTimer) - } - }, []) + const isReady = useAppBootstrap() if (!isReady) { return null diff --git a/src/lib/hooks/useAppBootstrap.ts b/src/lib/hooks/useAppBootstrap.ts new file mode 100644 index 0000000000..fca91f0550 --- /dev/null +++ b/src/lib/hooks/useAppBootstrap.ts @@ -0,0 +1,53 @@ +import {useEffect, useState} from 'react' + +import {logger} from '#/logger' +import {init as initPersistedState} from '#/state/persisted' +import {initSessionRepository} from '#/state/session/storage' +import {setupDeviceId} from '#/analytics' +import * as Geo from '#/geolocation' + +/** + * Runs the app-level initialization sequence shared by native and web: bring up + * persisted state, then the session repository, retrying both every 5s on + * failure so a locked keystore or unavailable localStorage recovers on its own. + * Geolocation and device-id setup are awaited but never block readiness on + * failure. Returns whether initialization has completed. + */ +export function useAppBootstrap(): boolean { + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + let cancelled = false + let retryTimer: ReturnType | undefined + let persistedInitialized = false + const ancillaryReady = Promise.all([Geo.resolve(), setupDeviceId]).catch( + error => { + // setupDeviceId is a module-level promise and cannot be restarted. + // Session storage is more important than blocking forever here. + logger.error('ancillary app initialization failed', {error}) + }, + ) + + async function initialize() { + try { + if (!persistedInitialized) { + await initPersistedState() + persistedInitialized = true + } + await initSessionRepository() + await ancillaryReady + if (!cancelled) setIsReady(true) + } catch (error) { + logger.error('app initialization failed', {error}) + if (!cancelled) retryTimer = setTimeout(() => void initialize(), 5_000) + } + } + void initialize() + return () => { + cancelled = true + if (retryTimer) clearTimeout(retryTimer) + } + }, []) + + return isReady +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index ee3e480c56..95b3e210cd 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -31,7 +31,6 @@ import { type SessionRepository, type SessionSnapshot, } from './storage' -import {type SessionStorageErrorKind} from './storage/types' export {isSignupQueued} from './util' import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' @@ -71,9 +70,6 @@ ApiContext.displayName = 'SessionApiContext' class SessionStore { private state: State private listeners = new Set<() => void>() - private storageErrorListeners = new Set< - (kind: SessionStorageErrorKind) => void - >() constructor(private repository: SessionRepository) { const initialState = getInitialState(repository.getSnapshot().accounts) @@ -102,17 +98,7 @@ class SessionStore { currentDid: nextState.currentAgentState.did, } addSessionDebugLog({type: 'persisted:broadcast', data: nextSnapshot}) - const result = this.repository.commit( - this.repository.getSnapshot(), - nextSnapshot, - ) - void Promise.resolve(result).then(commitResult => { - if (commitResult.status === 'pending') { - this.storageErrorListeners.forEach(listener => - listener(commitResult.error.kind), - ) - } - }) + this.repository.write(nextSnapshot) this.listeners.forEach(listener => listener()) } @@ -124,15 +110,6 @@ class SessionStore { }) this.listeners.forEach(listener => listener()) } - - subscribeStorageErrors = ( - listener: (kind: SessionStorageErrorKind) => void, - ) => { - this.storageErrorListeners.add(listener) - return () => { - this.storageErrorListeners.delete(listener) - } - } } export function Provider({children}: React.PropsWithChildren<{}>) { @@ -148,8 +125,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const showedStorageFullWarning = useRef(false) useEffect(() => { - return store.subscribeStorageErrors(kind => { - if (kind === 'storage-full' && !showedStorageFullWarning.current) { + return repository.onWriteFailure(error => { + if (error.kind === 'storage-full' && !showedStorageFullWarning.current) { showedStorageFullWarning.current = true Toast.show( l`We couldn't save your login. Free up some device storage and keep the app open while we retry.`, @@ -157,7 +134,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } }) - }, [l, store]) + }, [l, repository]) const onAgentSessionChange = useCallback( (agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { diff --git a/src/state/session/storage/__tests__/merge-test.ts b/src/state/session/storage/__tests__/merge-test.ts new file mode 100644 index 0000000000..47336c9142 --- /dev/null +++ b/src/state/session/storage/__tests__/merge-test.ts @@ -0,0 +1,202 @@ +import {describe, expect, it} from '@jest/globals' + +import {mergeSnapshots} from '../merge' +import {type SessionAccount, type SessionSnapshot} from '../schema' + +const alice: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:alice', + handle: 'alice.test', + refreshJwt: 'alice-refresh', + accessJwt: 'alice-access', + emailConfirmed: false, +} +const bob: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:bob', + handle: 'bob.test', + refreshJwt: 'bob-refresh', + accessJwt: 'bob-access', +} +const charlie: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:charlie', + handle: 'charlie.test', + refreshJwt: 'charlie-refresh', + accessJwt: 'charlie-access', +} + +const { + refreshJwt: _aliceRefresh, + accessJwt: _aliceAccess, + ...loggedOutAlice +} = alice + +describe('mergeSnapshots', () => { + it('folds our field change onto theirs and honors our removal and reorder', () => { + const base: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + const ours: SessionSnapshot = { + accounts: [{...alice, emailConfirmed: true}], + currentDid: alice.did, + } + const theirs: SessionSnapshot = { + accounts: [ + {...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'}, + bob, + charlie, + ], + currentDid: charlie.did, + } + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [ + { + ...alice, + refreshJwt: 'fresh-refresh', + accessJwt: 'fresh-access', + emailConfirmed: true, + }, + charlie, + ], + currentDid: charlie.did, + }) + }) + + it('does not restore a credential another tab revoked', () => { + const base: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + const ours: SessionSnapshot = { + accounts: [ + {...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'}, + ], + currentDid: alice.did, + } + const theirs: SessionSnapshot = { + accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}], + currentDid: undefined, + } + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [loggedOutAlice], + }) + }) + + it('preserves a revocation when we concurrently re-add the account', () => { + const base: SessionSnapshot = {accounts: [], currentDid: undefined} + const ours: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + const theirs: SessionSnapshot = { + accounts: [loggedOutAlice], + currentDid: undefined, + } + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [loggedOutAlice], + currentDid: alice.did, + }) + }) + + it('takes ours as-is when theirs lacks the account we added', () => { + const base: SessionSnapshot = {accounts: [bob], currentDid: bob.did} + const ours: SessionSnapshot = { + accounts: [bob, alice], + currentDid: alice.did, + } + const theirs: SessionSnapshot = {accounts: [bob], currentDid: bob.did} + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [bob, alice], + currentDid: alice.did, + }) + }) + + it('lets their removal win over our edit', () => { + const base: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + const ours: SessionSnapshot = { + accounts: [{...alice, emailConfirmed: true}, bob], + currentDid: alice.did, + } + const theirs: SessionSnapshot = {accounts: [bob], currentDid: bob.did} + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [bob], + currentDid: bob.did, + }) + }) + + it('drops an account we removed even if theirs still has it', () => { + const base: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + const ours: SessionSnapshot = {accounts: [bob], currentDid: bob.did} + const theirs: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [bob], + currentDid: bob.did, + }) + }) + + it('takes theirs entirely for an account we did not touch', () => { + const base: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + const ours: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + const theirs: SessionSnapshot = { + accounts: [{...alice, handle: 'alice-renamed.test'}], + currentDid: alice.did, + } + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [{...alice, handle: 'alice-renamed.test'}], + currentDid: alice.did, + }) + }) + + it('uses our order when we reordered and appends theirs-only dids', () => { + const base: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + const ours: SessionSnapshot = { + accounts: [bob, alice], + currentDid: bob.did, + } + const theirs: SessionSnapshot = { + accounts: [alice, bob, charlie], + currentDid: alice.did, + } + + const merged = mergeSnapshots(base, ours, theirs) + expect(merged.accounts.map(a => a.did)).toEqual([ + bob.did, + alice.did, + charlie.did, + ]) + expect(merged.currentDid).toBe(bob.did) + }) + + it('clears our currentDid when that account is not in the result', () => { + const base: SessionSnapshot = { + accounts: [alice, bob], + currentDid: alice.did, + } + // We switch to bob, but another tab removed bob. + const ours: SessionSnapshot = { + accounts: [alice, bob], + currentDid: bob.did, + } + const theirs: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + + expect(mergeSnapshots(base, ours, theirs)).toEqual({ + accounts: [alice], + currentDid: undefined, + }) + }) +}) diff --git a/src/state/session/storage/__tests__/repository-test.ts b/src/state/session/storage/__tests__/repository-test.ts index 2186bddb02..fb561ee513 100644 --- a/src/state/session/storage/__tests__/repository-test.ts +++ b/src/state/session/storage/__tests__/repository-test.ts @@ -1,8 +1,9 @@ -import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals' import {accountKeys, SESSION_INDEX_KEY} from '../keys' import {NativeSessionRepository} from '../repository' import {type SessionAccount, type SessionSnapshot} from '../schema' +import {type SessionStorageError} from '../types' const mockValues = new Map() let mockFailKey: string | undefined @@ -20,6 +21,8 @@ jest.mock('#/lib/appState', () => ({ onAppStateChange: jest.fn(() => ({remove: jest.fn()})), })) +const RETRY_DELAY = 5_000 + const alice: SessionAccount = { service: 'https://bsky.social', did: 'did:plc:alice', @@ -28,26 +31,42 @@ const alice: SessionAccount = { accessJwt: 'alice-access', } +const EMPTY: SessionSnapshot = {accounts: [], currentDid: undefined} + beforeEach(() => { + jest.useFakeTimers() mockValues.clear() mockFailKey = undefined mockSetItem.mockClear() mockGetItem.mockClear() - jest.clearAllTimers() }) +afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() +}) + +/** + * Register a failure collector on a repository. Failures surface through + * onWriteFailure now that write() is fire-and-forget. + */ +function collectFailures(repository: NativeSessionRepository) { + const failures: SessionStorageError[] = [] + repository.onWriteFailure(error => failures.push(error)) + return failures +} + describe('NativeSessionRepository', () => { it('migrates credentials synchronously and publishes the index last', async () => { const repository = new NativeSessionRepository() + const onDurable = jest.fn() const legacy: SessionSnapshot = { accounts: [alice], currentDid: alice.did, } - await expect(repository.open(legacy)).resolves.toMatchObject({ - status: 'ready', - shouldScrubLegacy: true, - }) + await expect(repository.init(legacy, onDurable)).resolves.toEqual(legacy) + expect(onDurable).toHaveBeenCalledTimes(1) const keys = accountKeys(alice.did) expect(mockSetItem.mock.calls.map(([key]) => key)).toEqual([ @@ -66,38 +85,49 @@ describe('NativeSessionRepository', () => { JSON.stringify({version: 1, dids: [], currentDid: undefined}), ) const repository = new NativeSessionRepository() + const onDurable = jest.fn() await expect( - repository.open({accounts: [alice], currentDid: alice.did}), - ).resolves.toEqual({ - status: 'ready', - snapshot: {accounts: [], currentDid: undefined}, - shouldScrubLegacy: true, - }) + repository.init({accounts: [alice], currentDid: alice.did}, onDurable), + ).resolves.toEqual(EMPTY) + expect(onDurable).toHaveBeenCalledTimes(1) expect(mockSetItem).not.toHaveBeenCalled() }) it('repairs invalid data without resurrecting a legacy snapshot', async () => { mockValues.set(SESSION_INDEX_KEY, '{invalid json') const repository = new NativeSessionRepository() + const onDurable = jest.fn() await expect( - repository.open({accounts: [alice], currentDid: alice.did}), - ).resolves.toMatchObject({ - status: 'ready', - snapshot: {accounts: [], currentDid: undefined}, - shouldScrubLegacy: true, - }) + repository.init({accounts: [alice], currentDid: alice.did}, onDurable), + ).resolves.toEqual(EMPTY) + expect(onDurable).toHaveBeenCalledTimes(1) }) - it('keeps the newest snapshot in memory and retries a complete commit', async () => { + it('rejects and stays re-callable when storage is unavailable', async () => { + mockFailKey = SESSION_INDEX_KEY const repository = new NativeSessionRepository() - await repository.open() + + await expect(repository.init(EMPTY, jest.fn())).rejects.toThrow( + 'session storage unavailable', + ) + + mockFailKey = undefined + const onDurable = jest.fn() + await expect(repository.init(EMPTY, onDurable)).resolves.toEqual(EMPTY) + expect(onDurable).toHaveBeenCalledTimes(1) + }) + + it('keeps the newest snapshot in memory and retries a complete write', async () => { + const repository = new NativeSessionRepository() + const failures = collectFailures(repository) + await repository.init(EMPTY, jest.fn()) const keys = accountKeys(alice.did) mockFailKey = keys.access - const first = {accounts: [alice], currentDid: alice.did} - expect(repository.commit({accounts: []}, first).status).toBe('pending') + repository.write({accounts: [alice], currentDid: alice.did}) + expect(failures.length).toBe(1) const refreshed = { accounts: [ @@ -105,11 +135,12 @@ describe('NativeSessionRepository', () => { ], currentDid: alice.did, } - expect(repository.commit(first, refreshed).status).toBe('pending') + repository.write(refreshed) + expect(failures.length).toBe(2) expect(repository.getSnapshot()).toEqual(refreshed) mockFailKey = undefined - expect(repository.retryPending()).toEqual({status: 'committed'}) + jest.advanceTimersByTime(RETRY_DELAY) expect(mockValues.get(keys.refresh)).toBe('new-refresh') expect(mockValues.get(keys.access)).toBe('new-access') expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({ @@ -119,17 +150,17 @@ describe('NativeSessionRepository', () => { }) }) - it('tombstones credentials before publishing logout', async () => { + it('journals a retained-account logout against the previous index', async () => { const repository = new NativeSessionRepository() const active = {accounts: [alice], currentDid: alice.did} - await repository.open(active) + await repository.init(active, jest.fn()) mockSetItem.mockClear() const loggedOut = { accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}], currentDid: undefined, } - expect(repository.commit(active, loggedOut)).toEqual({status: 'committed'}) + repository.write(loggedOut) const keys = accountKeys(alice.did) expect(mockSetItem.mock.calls).toEqual([ @@ -137,7 +168,7 @@ describe('NativeSessionRepository', () => { SESSION_INDEX_KEY, JSON.stringify({ version: 1, - currentDid: undefined, + currentDid: alice.did, dids: [alice.did], revokedDids: [alice.did], }), @@ -151,7 +182,7 @@ describe('NativeSessionRepository', () => { ]) }) - it('finishes an interrupted retained-account logout on open', async () => { + it('finishes an interrupted retained-account logout on init', async () => { const keys = accountKeys(alice.did) const { accessJwt: _accessJwt, @@ -171,17 +202,14 @@ describe('NativeSessionRepository', () => { ) const repository = new NativeSessionRepository() - await expect(repository.open()).resolves.toMatchObject({ - status: 'ready', - snapshot: { - accounts: [ - expect.objectContaining({ - did: alice.did, - refreshJwt: undefined, - accessJwt: undefined, - }), - ], - }, + await expect(repository.init(EMPTY, jest.fn())).resolves.toMatchObject({ + accounts: [ + expect.objectContaining({ + did: alice.did, + refreshJwt: undefined, + accessJwt: undefined, + }), + ], }) expect(mockValues.get(keys.access)).toBe('') expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({ @@ -193,11 +221,10 @@ describe('NativeSessionRepository', () => { it('tombstones a removed account without racing a later re-add', async () => { const repository = new NativeSessionRepository() const active = {accounts: [alice], currentDid: alice.did} - await repository.open(active) + await repository.init(active, jest.fn()) mockSetItem.mockClear() - const removed = {accounts: [], currentDid: undefined} - expect(repository.commit(active, removed)).toEqual({status: 'committed'}) + repository.write(EMPTY) const keys = accountKeys(alice.did) expect(mockSetItem.mock.calls).toEqual([ @@ -219,7 +246,7 @@ describe('NativeSessionRepository', () => { ], ]) - expect(repository.commit(removed, active)).toEqual({status: 'committed'}) + repository.write(active) expect(mockValues.get(keys.refresh)).toBe(alice.refreshJwt) expect(mockValues.get(keys.access)).toBe(alice.accessJwt) expect(JSON.parse(mockValues.get(keys.descriptor)!)).toMatchObject({ @@ -242,7 +269,7 @@ describe('NativeSessionRepository', () => { ) const repository = new NativeSessionRepository() - await expect(repository.open()).resolves.toMatchObject({status: 'ready'}) + await expect(repository.init(EMPTY, jest.fn())).resolves.toEqual(EMPTY) expect(mockValues.get(keys.refresh)).toBe('') expect(mockValues.get(keys.access)).toBe('') @@ -256,12 +283,10 @@ describe('NativeSessionRepository', () => { it('clears accounts known only to the last durable snapshot', async () => { const repository = new NativeSessionRepository() const active = {accounts: [alice], currentDid: alice.did} - await repository.open(active) + await repository.init(active, jest.fn()) mockFailKey = SESSION_INDEX_KEY - expect( - repository.commit(active, {accounts: [], currentDid: undefined}).status, - ).toBe('pending') + repository.write(EMPTY) mockFailKey = undefined await repository.clear() @@ -276,35 +301,27 @@ describe('NativeSessionRepository', () => { }) }) - it('cancels an older pending credential write when clear fails', async () => { + it('drops writes after a failed clear until the clear succeeds', async () => { const repository = new NativeSessionRepository() const active = {accounts: [alice], currentDid: alice.did} - await repository.open(active) + await repository.init(active, jest.fn()) const keys = accountKeys(alice.did) mockFailKey = keys.access - expect( - repository.commit(active, { - accounts: [{...alice, accessJwt: 'new-access'}], - currentDid: alice.did, - }).status, - ).toBe('pending') + repository.write({ + accounts: [{...alice, accessJwt: 'new-access'}], + currentDid: alice.did, + }) mockFailKey = SESSION_INDEX_KEY await expect(repository.clear()).rejects.toThrow('disk full') - expect(repository.getSnapshot()).toEqual({ - accounts: [], - currentDid: undefined, - }) + expect(repository.getSnapshot()).toEqual(EMPTY) - expect(repository.commit(active, active).status).toBe('pending') - expect(repository.getSnapshot()).toEqual({ - accounts: [], - currentDid: undefined, - }) + repository.write(active) + expect(repository.getSnapshot()).toEqual(EMPTY) mockFailKey = undefined - expect(repository.retryPending()).toEqual({status: 'committed'}) + jest.advanceTimersByTime(RETRY_DELAY) expect(mockValues.get(keys.refresh)).toBe('') expect(mockValues.get(keys.access)).toBe('') expect(mockValues.get(keys.descriptor)).toBe('') @@ -316,26 +333,16 @@ describe('NativeSessionRepository', () => { it('tombstones credentials from a superseded partial account write', async () => { const repository = new NativeSessionRepository() - await repository.open() + await repository.init(EMPTY, jest.fn()) const keys = accountKeys(alice.did) mockFailKey = SESSION_INDEX_KEY - expect( - repository.commit( - {accounts: []}, - {accounts: [alice], currentDid: alice.did}, - ).status, - ).toBe('pending') + repository.write({accounts: [alice], currentDid: alice.did}) expect(mockValues.get(keys.refresh)).toBe(alice.refreshJwt) expect(mockValues.get(keys.access)).toBe(alice.accessJwt) mockFailKey = undefined - expect( - repository.commit( - {accounts: [alice], currentDid: alice.did}, - {accounts: [], currentDid: undefined}, - ), - ).toEqual({status: 'committed'}) + repository.write(EMPTY) expect(mockValues.get(keys.refresh)).toBe('') expect(mockValues.get(keys.access)).toBe('') expect(mockValues.get(keys.descriptor)).toBe('') diff --git a/src/state/session/storage/__tests__/repository-web-test.ts b/src/state/session/storage/__tests__/repository-web-test.ts index 868491bae0..c92a5df638 100644 --- a/src/state/session/storage/__tests__/repository-web-test.ts +++ b/src/state/session/storage/__tests__/repository-web-test.ts @@ -4,6 +4,7 @@ import {WebSessionRepository} from '../repository.web' import {type SessionAccount, type SessionSnapshot} from '../schema' const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1' +const RETRY_DELAY = 5_000 const values = new Map() let failRead = false let failWrite = false @@ -54,6 +55,8 @@ const charlie: SessionAccount = { accessJwt: 'charlie-access', } +const EMPTY: SessionSnapshot = {accounts: [], currentDid: undefined} + beforeEach(() => { jest.useFakeTimers() values.clear() @@ -92,15 +95,14 @@ describe('WebSessionRepository', () => { it('repairs corrupt storage and starts logged out', async () => { values.set(STORAGE_KEY, '{invalid json') const repository = new WebSessionRepository() + const onDurable = jest.fn() await expect( - repository.open({accounts: [alice], currentDid: alice.did}), - ).resolves.toEqual({ - status: 'ready', - snapshot: {accounts: [], currentDid: undefined}, - shouldScrubLegacy: true, - }) - expect(readStoredSnapshot()).toEqual({accounts: []}) + repository.init({accounts: [alice], currentDid: alice.did}, onDurable), + ).resolves.toEqual(EMPTY) + await repository.whenSettled() + expect(onDurable).toHaveBeenCalledTimes(1) + expect(readStoredSnapshot()).toEqual(EMPTY) }) it('keeps a legacy session in memory when localStorage is unavailable', async () => { @@ -108,12 +110,11 @@ describe('WebSessionRepository', () => { failWrite = true const repository = new WebSessionRepository() const legacy = {accounts: [alice], currentDid: alice.did} + const onDurable = jest.fn() - await expect(repository.open(legacy)).resolves.toEqual({ - status: 'ready', - snapshot: legacy, - shouldScrubLegacy: false, - }) + await expect(repository.init(legacy, onDurable)).resolves.toEqual(legacy) + await repository.whenSettled() + expect(onDurable).not.toHaveBeenCalled() expect(repository.getSnapshot()).toEqual(legacy) }) @@ -123,7 +124,7 @@ describe('WebSessionRepository', () => { currentDid: alice.did, } const repository = new WebSessionRepository() - await repository.open(previous) + await repository.init(previous, jest.fn()) const latest: SessionSnapshot = { accounts: [ @@ -135,13 +136,11 @@ describe('WebSessionRepository', () => { } values.set(STORAGE_KEY, JSON.stringify(latest)) - const next: SessionSnapshot = { + repository.write({ accounts: [{...alice, emailConfirmed: true}], currentDid: alice.did, - } - await expect(repository.commit(previous, next)).resolves.toEqual({ - status: 'committed', }) + await repository.whenSettled() expect(readStoredSnapshot()).toEqual({ accounts: [ @@ -164,19 +163,18 @@ describe('WebSessionRepository', () => { } const firstTab = new WebSessionRepository() const secondTab = new WebSessionRepository() - await firstTab.open(previous) - await secondTab.open(previous) + await firstTab.init(previous, jest.fn()) + await secondTab.init(previous, jest.fn()) - await Promise.all([ - firstTab.commit(previous, { - accounts: [{...alice, emailConfirmed: true}, bob], - currentDid: alice.did, - }), - secondTab.commit(previous, { - accounts: [alice, {...bob, handle: 'new-bob.test'}], - currentDid: alice.did, - }), - ]) + firstTab.write({ + accounts: [{...alice, emailConfirmed: true}, bob], + currentDid: alice.did, + }) + secondTab.write({ + accounts: [alice, {...bob, handle: 'new-bob.test'}], + currentDid: alice.did, + }) + await Promise.all([firstTab.whenSettled(), secondTab.whenSettled()]) expect(readStoredSnapshot()).toEqual({ accounts: [ @@ -197,7 +195,7 @@ describe('WebSessionRepository', () => { currentDid: alice.did, } const repository = new WebSessionRepository() - await repository.open(previous) + await repository.init(previous, jest.fn()) values.set( STORAGE_KEY, @@ -206,12 +204,13 @@ describe('WebSessionRepository', () => { currentDid: undefined, }), ) - await repository.commit(previous, { + repository.write({ accounts: [ {...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'}, ], currentDid: alice.did, }) + await repository.whenSettled() const { refreshJwt: _refreshJwt, @@ -225,8 +224,7 @@ describe('WebSessionRepository', () => { it('preserves credential revocation during concurrent account adds', async () => { const repository = new WebSessionRepository() - const previous: SessionSnapshot = {accounts: [], currentDid: undefined} - await repository.open(previous) + await repository.init(EMPTY, jest.fn()) const { refreshJwt: _refreshJwt, @@ -238,10 +236,11 @@ describe('WebSessionRepository', () => { JSON.stringify({accounts: [loggedOutAlice], currentDid: undefined}), ) - await repository.commit(previous, { + repository.write({ accounts: [alice], currentDid: alice.did, }) + await repository.whenSettled() expect(readStoredSnapshot()).toEqual({ accounts: [loggedOutAlice], currentDid: alice.did, @@ -254,74 +253,96 @@ describe('WebSessionRepository', () => { currentDid: alice.did, } const repository = new WebSessionRepository() - await repository.open(previous) + await repository.init(previous, jest.fn()) values.set( STORAGE_KEY, JSON.stringify({accounts: [bob], currentDid: bob.did}), ) - const editedAlice = {...alice, emailConfirmed: true} - const next: SessionSnapshot = { - accounts: [editedAlice, bob], + repository.write({ + accounts: [{...alice, emailConfirmed: true}, bob], currentDid: alice.did, - } - - await expect(repository.commit(previous, next)).resolves.toEqual({ - status: 'committed', }) + await repository.whenSettled() + expect(readStoredSnapshot()).toEqual({ accounts: [bob], currentDid: bob.did, }) }) - it('replaces an older pending commit with a failed clear', async () => { + it('notifies subscribers when a merge changes the committed snapshot', async () => { const previous: SessionSnapshot = { accounts: [alice], currentDid: alice.did, } const repository = new WebSessionRepository() - await repository.open(previous) + await repository.init(previous, jest.fn()) + + const committed: SessionSnapshot[] = [] + repository.subscribe(snapshot => committed.push(snapshot)) + + // Another tab logged alice out; our unrelated edit must not resurrect her. + values.set( + STORAGE_KEY, + JSON.stringify({ + accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}], + currentDid: undefined, + }), + ) + repository.write({ + accounts: [{...alice, refreshJwt: 'fresh', accessJwt: 'fresh'}], + currentDid: alice.did, + }) + await repository.whenSettled() + + const { + refreshJwt: _refreshJwt, + accessJwt: _accessJwt, + ...loggedOutAlice + } = alice + expect(committed).toEqual([{accounts: [loggedOutAlice]}]) + }) + + it('drops writes after a failed clear until the clear succeeds', async () => { + const previous: SessionSnapshot = { + accounts: [alice], + currentDid: alice.did, + } + const repository = new WebSessionRepository() + await repository.init(previous, jest.fn()) failWrite = true - await expect( - repository.commit(previous, { - accounts: [{...alice, accessJwt: 'new-access'}], - currentDid: alice.did, - }), - ).resolves.toMatchObject({status: 'pending'}) + repository.write({ + accounts: [{...alice, accessJwt: 'new-access'}], + currentDid: alice.did, + }) + await repository.whenSettled() await expect(repository.clear()).rejects.toThrow( 'session storage clear failed: write-failed', ) - expect(repository.getSnapshot()).toEqual({ - accounts: [], - currentDid: undefined, - }) + expect(repository.getSnapshot()).toEqual(EMPTY) - await expect(repository.commit(previous, previous)).resolves.toMatchObject({ - status: 'pending', - }) - expect(repository.getSnapshot()).toEqual({ - accounts: [], - currentDid: undefined, - }) + repository.write(previous) + await repository.whenSettled() + expect(repository.getSnapshot()).toEqual(EMPTY) failWrite = false - await expect(repository.retryPending()).resolves.toEqual({ - status: 'committed', - }) - expect(readStoredSnapshot()).toEqual({accounts: []}) - expect(repository.getSnapshot()).toEqual({accounts: []}) + jest.advanceTimersByTime(RETRY_DELAY) + await repository.whenSettled() + expect(readStoredSnapshot()).toEqual(EMPTY) + expect(repository.getSnapshot()).toEqual(EMPTY) }) - it('does not overwrite newer storage when initial persistence retries', async () => { + it('does not overwrite newer storage when the initial persist retries', async () => { failWrite = true const repository = new WebSessionRepository() const legacy = {accounts: [alice], currentDid: alice.did} - const onLegacyMigrationComplete = jest.fn() - await repository.open(legacy, onLegacyMigrationComplete) - expect(onLegacyMigrationComplete).not.toHaveBeenCalled() + const onDurable = jest.fn() + await repository.init(legacy, onDurable) + await repository.whenSettled() + expect(onDurable).not.toHaveBeenCalled() const refreshed = { accounts: [{...alice, accessJwt: 'fresh-access'}], @@ -330,12 +351,11 @@ describe('WebSessionRepository', () => { failWrite = false values.set(STORAGE_KEY, JSON.stringify(refreshed)) - await expect(repository.retryPending()).resolves.toEqual({ - status: 'committed', - }) + jest.advanceTimersByTime(RETRY_DELAY) + await repository.whenSettled() expect(readStoredSnapshot()).toEqual(refreshed) expect(repository.getSnapshot()).toEqual(refreshed) - expect(onLegacyMigrationComplete).toHaveBeenCalledTimes(1) + expect(onDurable).toHaveBeenCalledTimes(1) }) }) diff --git a/src/state/session/storage/errors.ts b/src/state/session/storage/errors.ts new file mode 100644 index 0000000000..b606bd7acb --- /dev/null +++ b/src/state/session/storage/errors.ts @@ -0,0 +1,44 @@ +import {logger} from '#/logger' +import {type SessionStorageError} from './types' + +/** + * Thrown when stored session data exists but fails to parse or validate. Kept + * distinct from an unavailable-storage failure so callers can repair to a + * clean empty state instead of resurrecting the legacy blob. + */ +export class InvalidSessionStorageDataError extends Error {} + +/** + * Classify a thrown error into a `SessionStorageError`. Invalid data is + * reported as such; a storage-full message is detected by the regex below; + * every other failure is `unavailable` during init and `write-failed` + * otherwise. + */ +export function storageError( + operation: SessionStorageError['operation'], + cause: unknown, +): SessionStorageError { + const message = cause instanceof Error ? cause.message : String(cause) + const kind = + cause instanceof InvalidSessionStorageDataError + ? 'invalid-data' + : /quota|disk.*full|storage.*full|no space/i.test(message) + ? 'storage-full' + : operation === 'init' + ? 'unavailable' + : 'write-failed' + return {kind, operation} +} + +export function logStorageError(error: SessionStorageError) { + // Never attach the underlying native error: some platforms include the key + // in it. Keys are hashed, but keeping telemetry credential-agnostic is safer. + logger.error('session storage operation failed', { + kind: error.kind, + operation: error.operation, + tags: { + session_storage_kind: error.kind, + session_storage_operation: error.operation, + }, + }) +} diff --git a/src/state/session/storage/index.ts b/src/state/session/storage/index.ts index 53070d9d7d..4f4fee9d23 100644 --- a/src/state/session/storage/index.ts +++ b/src/state/session/storage/index.ts @@ -5,6 +5,11 @@ import {type SessionSnapshot} from './schema' const repository = createSessionRepository() let initialized = false +/** + * Initialize the session repository, migrating from the legacy persisted blob + * and scrubbing it once the new store is durable. Rejects if storage is + * unavailable; the app-level bootstrap retries, and this is safe to call again. + */ export async function initSessionRepository() { if (initialized) return repository @@ -18,23 +23,19 @@ export async function initSessionRepository() { ? legacyCurrentDid : undefined, } - const scrubLegacy = () => - persisted.write('session', { - accounts: [], - currentAccount: undefined, - }) - const result = await repository.open(legacySnapshot, () => { - void scrubLegacy() - }) - if (result.status === 'unavailable') { - throw new Error(`session storage unavailable: ${result.error.kind}`) - } - if (result.shouldScrubLegacy) { - // The new repository has been read back successfully. Scrub the old blob - // so future preference writes cannot keep rewriting bearer credentials. - await scrubLegacy() - } + await repository.init(legacySnapshot, () => { + // Fires once the new store is known durable. Scrub the old blob so future + // preference writes cannot keep rewriting bearer credentials. Only needed + // when the legacy location actually held accounts. + if (legacySnapshot.accounts.length > 0) { + void persisted.write('session', { + accounts: [], + currentAccount: undefined, + }) + } + }) + initialized = true return repository } diff --git a/src/state/session/storage/merge.ts b/src/state/session/storage/merge.ts new file mode 100644 index 0000000000..373ad32bf5 --- /dev/null +++ b/src/state/session/storage/merge.ts @@ -0,0 +1,165 @@ +import { + type SessionAccount, + sessionAccountSchema, + type SessionSnapshot, + sessionSnapshotSchema, +} from './schema' + +/** + * Three-way merge of session snapshots for cross-tab coherence on web. + * + * `base` is what this tab last read or wrote, `ours` is what it now wants to + * write, and `theirs` is what storage holds right now (possibly changed by + * another tab). The result folds our intended change onto the latest stored + * state so concurrent tabs converge without losing each other's edits. + * + * Rules: + * - An account we removed (in base, not in ours) is dropped even if theirs + * still has it. + * - An account theirs removed wins that removal, whether or not we edited it - + * removal beats edit, and we never re-add an account another tab deleted. + * - An account we did not touch takes theirs' version entirely. + * - An account we changed keeps theirs' fields except the specific fields we + * changed (base to ours differ). Exception: for refreshJwt/accessJwt, if we + * changed the field but theirs also changed it to empty (a revocation), the + * revocation wins - we never resurrect tokens another tab dropped. + * - An account we added (not in base) merges as {...theirs, ...ours} when + * theirs also has it, but each credential survives only if BOTH sides hold + * it (we cannot tell a fresh login from a stale re-add). When theirs lacks + * it we take ours as-is. + * - Ordering: if we reordered accounts, use our order and append theirs-only + * dids; otherwise use theirs' order and append ours-only dids. + * - currentDid is ours when we changed it, else theirs, and is cleared if it + * is not in the result. + */ +export function mergeSnapshots( + base: SessionSnapshot, + ours: SessionSnapshot, + theirs: SessionSnapshot, +): SessionSnapshot { + const baseByDid = new Map( + base.accounts.map(account => [account.did, account]), + ) + const oursByDid = new Map( + ours.accounts.map(account => [account.did, account]), + ) + const resultByDid = new Map( + theirs.accounts.map(account => [account.did, account]), + ) + + // Accounts we removed drop out even if theirs still holds them. + for (const did of baseByDid.keys()) { + if (!oursByDid.has(did)) resultByDid.delete(did) + } + + for (const account of ours.accounts) { + const prior = baseByDid.get(account.did) + if (!prior) { + resultByDid.set(account.did, mergeAddedAccount(account, resultByDid)) + continue + } + if (JSON.stringify(prior) === JSON.stringify(account)) { + // We did not touch this account; theirs' version wins entirely. + continue + } + const theirsAccount = resultByDid.get(account.did) + if (!theirsAccount) { + // Their removal wins over our edit. + continue + } + resultByDid.set( + account.did, + sessionAccountSchema.parse( + mergeChangedAccount(prior, account, theirsAccount), + ), + ) + } + + const order = mergeOrder(base, ours, theirs, resultByDid) + const accounts = order.flatMap(did => { + const account = resultByDid.get(did) + return account ? [account] : [] + }) + + let currentDid = + base.currentDid === ours.currentDid ? theirs.currentDid : ours.currentDid + if (currentDid && !resultByDid.has(currentDid)) currentDid = undefined + + return sessionSnapshotSchema.parse({accounts, currentDid}) +} + +/** + * Merge an account we added onto whatever another tab may already hold under + * the same did. Credentials survive only when both sides have them. + */ +function mergeAddedAccount( + account: SessionAccount, + resultByDid: Map, +): SessionAccount { + const theirsAccount = resultByDid.get(account.did) + if (!theirsAccount) { + return sessionAccountSchema.parse(account) + } + return sessionAccountSchema.parse({ + ...theirsAccount, + ...account, + refreshJwt: + theirsAccount.refreshJwt && account.refreshJwt + ? account.refreshJwt + : undefined, + accessJwt: + theirsAccount.accessJwt && account.accessJwt + ? account.accessJwt + : undefined, + }) +} + +/** + * Overlay the fields we changed (base to ours) onto theirs' version. A + * credential we changed is not applied if theirs revoked it to empty. + */ +function mergeChangedAccount( + base: SessionAccount, + ours: SessionAccount, + theirs: SessionAccount, +): SessionAccount { + const merged: SessionAccount = {...theirs} + for (const key of changedKeys(base, ours)) { + if (key === 'refreshJwt' || key === 'accessJwt') { + const theirsRevoked = theirs[key] !== base[key] && !theirs[key] + if (theirsRevoked) continue + } + ;(merged as Record)[key] = ours[key] + } + return merged +} + +/** Keys whose value differs between two accounts. */ +function changedKeys( + base: SessionAccount, + ours: SessionAccount, +): (keyof SessionAccount)[] { + const keys = new Set([ + ...(Object.keys(base) as (keyof SessionAccount)[]), + ...(Object.keys(ours) as (keyof SessionAccount)[]), + ]) + return [...keys].filter(key => base[key] !== ours[key]) +} + +function mergeOrder( + base: SessionSnapshot, + ours: SessionSnapshot, + theirs: SessionSnapshot, + resultByDid: Map, +): string[] { + const baseOrder = base.accounts.map(account => account.did) + const oursOrder = ours.accounts.map(account => account.did) + const theirsOrder = theirs.accounts.map(account => account.did) + const oursDids = new Set(oursOrder) + const theirsDids = new Set(theirsOrder) + const reordered = JSON.stringify(baseOrder) !== JSON.stringify(oursOrder) + const order = reordered + ? [...oursOrder, ...theirsOrder.filter(did => !oursDids.has(did))] + : [...theirsOrder, ...oursOrder.filter(did => !theirsDids.has(did))] + return order.filter(did => resultByDid.has(did)) +} diff --git a/src/state/session/storage/repository.ts b/src/state/session/storage/repository.ts index df10ddf9c7..d007ef623c 100644 --- a/src/state/session/storage/repository.ts +++ b/src/state/session/storage/repository.ts @@ -1,167 +1,109 @@ -import * as SecureStore from 'expo-secure-store' -import {z} from 'zod' - import {onAppStateChange} from '#/lib/appState' -import {logger} from '#/logger' -import {accountKeys, SESSION_INDEX_KEY} from './keys' import { - type SessionAccount, - sessionAccountSchema, - type SessionSnapshot, -} from './schema' + InvalidSessionStorageDataError, + logStorageError, + storageError, +} from './errors' +import {type SessionSnapshot} from './schema' import { - type SessionRepository, - type SessionStorageCommitResult, - type SessionStorageError, - type SessionStorageLoadResult, -} from './types' - -const indexSchema = z.object({ - version: z.literal(1), - currentDid: z.string().optional(), - dids: z.array(z.string()), - retiredDids: z.array(z.string()).optional(), - revokedDids: z.array(z.string()).optional(), -}) -const descriptorSchema = sessionAccountSchema.omit({ - accessJwt: true, - refreshJwt: true, -}) - -type StoredIndex = z.infer -type AccountDescriptor = Omit + eraseSessions, + indexExists, + readSessions, + writeSessions, +} from './secureStore' +import {type SessionRepository, type SessionStorageError} from './types' const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} const RETRY_DELAY = 5_000 +/** + * Native session repository. Owns only the in-memory lifecycle - the current + * snapshot, the last durable snapshot, the pending work, the maybe-orphaned + * did set, and the retry timer. All keychain layout and crash recovery live in + * secureStore.ts. + * + * Pending work is a union so the sticky-clear rule reads as one guard: while a + * clear is pending, write() drops its input rather than risk resurrecting a + * session after a requested wipe. + */ +type PendingWork = + | {type: 'write'; snapshot: SessionSnapshot} + | {type: 'clear'; dids: string[]} + export class NativeSessionRepository implements SessionRepository { private snapshot: SessionSnapshot = EMPTY_SNAPSHOT - private persistedSnapshot: SessionSnapshot = EMPTY_SNAPSHOT - private hasPersistedIndex = false - private pendingSnapshot: SessionSnapshot | undefined - private pendingClearDids: string[] | undefined - private possiblyWrittenDids = new Set() + private durableSnapshot: SessionSnapshot = EMPTY_SNAPSHOT + private pending: PendingWork | undefined + private maybeOrphanedDids = new Set() private retryTimer: ReturnType | undefined - private listeners = new Set<(snapshot: SessionSnapshot) => void>() - - constructor() { - onAppStateChange(state => { - if ( - state === 'active' && - (this.pendingSnapshot || this.pendingClearDids) - ) { - this.retryPending() - } - }) - } + private subscribers = new Set<(snapshot: SessionSnapshot) => void>() + private writeFailureListeners = new Set< + (error: SessionStorageError) => void + >() + private retryTriggersAttached = false // async to keep one repository contract across native and web. // eslint-disable-next-line @typescript-eslint/require-await - async open(legacy?: SessionSnapshot): Promise { + async init( + legacy: SessionSnapshot, + onDurable: () => void, + ): Promise { try { - const rawIndex = SecureStore.getItem(SESSION_INDEX_KEY) - if (rawIndex !== null) { - let snapshot: SessionSnapshot + if (indexExists()) { try { - snapshot = this.readSnapshot(rawIndex) + this.setDurable(readSessions()) } catch (cause) { if (!(cause instanceof InvalidSessionStorageDataError)) throw cause - logStorageError({kind: 'invalid-data', operation: 'open'}) - this.hasPersistedIndex = false - this.snapshot = EMPTY_SNAPSHOT - this.persistedSnapshot = EMPTY_SNAPSHOT - // Index presence proves migration previously reached its commit - // point. Never resurrect possibly stale credentials from the legacy - // blob when repairing corrupt new-format data. - return this.initializeSnapshot( - EMPTY_SNAPSHOT, - Boolean(legacy?.accounts.length), - ) - } - this.hasPersistedIndex = true - this.snapshot = snapshot - this.persistedSnapshot = snapshot - this.pendingSnapshot = undefined - this.cancelRetry() - return { - status: 'ready', - snapshot, - shouldScrubLegacy: Boolean(legacy?.accounts.length), + logStorageError({kind: 'invalid-data', operation: 'init'}) + // The index proves migration previously reached its commit point. + // Repair to a clean empty state rather than resurrect possibly stale + // credentials from the legacy blob. + this.setDurable(this.migrate(EMPTY_SNAPSHOT)) } + } else { + this.setDurable(this.migrate(legacy)) } - - return this.initializeSnapshot(legacy ?? EMPTY_SNAPSHOT) } catch (cause) { - const error = storageError('open', cause) + const error = storageError('init', cause) logStorageError(error) - return {status: 'unavailable', error} + throw new Error(`session storage unavailable: ${error.kind}`) } + + // Attach retry triggers only after a successful init, so a failed init + // that the app-level bootstrap retries never leaves a live listener. + this.attachRetryTriggers() + // The store is durable now: either an index already existed or the + // migration was written and read back successfully. + onDurable() + return this.snapshot } getSnapshot(): SessionSnapshot { return this.snapshot } - commit( - _previous: SessionSnapshot, - next: SessionSnapshot, - ): SessionStorageCommitResult { - if (this.pendingClearDids) { + write(next: SessionSnapshot): void { + if (this.pending?.type === 'clear') { + // A requested wipe is still settling. Drop writes so we never resurrect + // a session after clear(); the pending clear stays authoritative. this.snapshot = EMPTY_SNAPSHOT - return this.retryPending() + return } this.snapshot = next - this.trackPossiblyWrittenAccounts(next) - try { - this.writeSnapshot(this.persistedSnapshot, next) - this.persistedSnapshot = next - this.hasPersistedIndex = true - this.pendingSnapshot = undefined - this.possiblyWrittenDids.clear() - this.cancelRetry() - return {status: 'committed'} - } catch (cause) { - this.pendingSnapshot = next - const error = storageError('commit', cause) - logStorageError(error) - this.scheduleRetry() - return {status: 'pending', error} - } - } - - retryPending(): SessionStorageCommitResult { - if (!this.pendingSnapshot && !this.pendingClearDids) { - return {status: 'committed'} - } - try { - if (this.pendingClearDids) { - this.writeClear(this.pendingClearDids) - this.snapshot = EMPTY_SNAPSHOT - this.persistedSnapshot = EMPTY_SNAPSHOT - this.pendingClearDids = undefined - } else { - const next = this.pendingSnapshot! - this.trackPossiblyWrittenAccounts(next) - this.writeSnapshot(this.persistedSnapshot, next) - this.persistedSnapshot = next - } - this.hasPersistedIndex = true - this.pendingSnapshot = undefined - this.possiblyWrittenDids.clear() - this.cancelRetry() - return {status: 'committed'} - } catch (cause) { - const error = storageError('retry', cause) - logStorageError(error) - this.scheduleRetry() - return {status: 'pending', error} - } + this.persist(next, 'write') } subscribe(listener: (snapshot: SessionSnapshot) => void): () => void { - this.listeners.add(listener) + this.subscribers.add(listener) return () => { - this.listeners.delete(listener) + this.subscribers.delete(listener) + } + } + + onWriteFailure(listener: (error: SessionStorageError) => void): () => void { + this.writeFailureListeners.add(listener) + return () => { + this.writeFailureListeners.delete(listener) } } @@ -169,182 +111,123 @@ export class NativeSessionRepository implements SessionRepository { async clear(): Promise { const dids = [ ...new Set([ - ...this.persistedSnapshot.accounts.map(account => account.did), + ...this.durableSnapshot.accounts.map(account => account.did), ...this.snapshot.accounts.map(account => account.did), - ...this.possiblyWrittenDids, + ...this.maybeOrphanedDids, ]), ] this.snapshot = EMPTY_SNAPSHOT - this.pendingSnapshot = undefined - this.pendingClearDids = dids + this.pending = {type: 'clear', dids} this.cancelRetry() try { - this.writeClear(dids) - this.persistedSnapshot = EMPTY_SNAPSHOT - this.hasPersistedIndex = true - this.pendingClearDids = undefined - this.possiblyWrittenDids.clear() - this.cancelRetry() + eraseSessions(dids) + this.durableSnapshot = EMPTY_SNAPSHOT + this.maybeOrphanedDids.clear() + this.pending = undefined } catch (cause) { const error = storageError('clear', cause) logStorageError(error) + this.emitFailure(error) this.scheduleRetry() throw cause } } - private readSnapshot(rawIndex: string): SessionSnapshot { - let index: StoredIndex + /** + * Write the legacy snapshot to the new store and read it back so migration + * is only considered durable once every item validates. `forceIndex` + * guarantees the first-ever write publishes an index even when empty. + */ + private migrate(legacy: SessionSnapshot): SessionSnapshot { + writeSessions(EMPTY_SNAPSHOT, legacy, {forceIndex: true}) + return readSessions() + } + + private persist( + next: SessionSnapshot, + operation: SessionStorageError['operation'], + ) { + this.trackMaybeOrphaned(next) try { - index = indexSchema.parse(JSON.parse(rawIndex)) - } catch { - throw new InvalidSessionStorageDataError() - } - if (index.revokedDids?.length) { - const activeDids = new Set(index.dids) - index.revokedDids - .filter(did => activeDids.has(did)) - .forEach(tombstoneCredentials) - } - if (index.retiredDids?.length) { - const activeDids = new Set(index.dids) - index.retiredDids - .filter(did => !activeDids.has(did)) - .forEach(tombstoneAccount) - } - if (index.revokedDids?.length || index.retiredDids?.length) { - const cleanedIndex = { - version: index.version, - currentDid: index.currentDid, - dids: index.dids, - } satisfies StoredIndex - SecureStore.setItem(SESSION_INDEX_KEY, JSON.stringify(cleanedIndex)) - index = cleanedIndex - } - if (index.currentDid && !index.dids.includes(index.currentDid)) { - throw new InvalidSessionStorageDataError() - } - const accounts = index.dids.map(did => { - const keys = accountKeys(did) - const rawDescriptor = SecureStore.getItem(keys.descriptor) - if (rawDescriptor === null) { - throw new InvalidSessionStorageDataError() - } - let descriptor: AccountDescriptor - try { - descriptor = descriptorSchema.parse(JSON.parse(rawDescriptor)) - } catch { - throw new InvalidSessionStorageDataError() - } - if (descriptor.did !== did) { - throw new InvalidSessionStorageDataError() - } - return { - ...descriptor, - refreshJwt: SecureStore.getItem(keys.refresh) || undefined, - accessJwt: SecureStore.getItem(keys.access) || undefined, - } - }) - return {accounts, currentDid: index.currentDid} - } - - private writeSnapshot(previous: SessionSnapshot, next: SessionSnapshot) { - const previousByDid = new Map(previous.accounts.map(a => [a.did, a])) - const nextDids = new Set(next.accounts.map(a => a.did)) - const retiredDids = [ - ...new Set([ - ...previous.accounts - .filter(account => !nextDids.has(account.did)) - .map(account => account.did), - ...[...this.possiblyWrittenDids].filter(did => !nextDids.has(did)), - ]), - ] - const revokedDids = next.accounts - .filter(account => { - const prior = previousByDid.get(account.did) - return ( - (Boolean(prior?.refreshJwt) && !account.refreshJwt) || - (Boolean(prior?.accessJwt) && !account.accessJwt) - ) + writeSessions(this.durableSnapshot, next, { + alsoRetire: [...this.maybeOrphanedDids], }) - .map(account => account.did) - - if (revokedDids.length) { - // Journal retained-account logout before clearing either credential. - // On interruption, open() finishes the tombstoning before loading. - SecureStore.setItem( - SESSION_INDEX_KEY, - JSON.stringify(toStoredIndex(next, retiredDids, revokedDids)), - ) - } - - // Credentials go first. AtpAgent does not await its persistence callback, - // so these must complete synchronously before the app can be suspended. - for (const account of next.accounts) { - const prior = previousByDid.get(account.did) - const keys = accountKeys(account.did) - if (prior?.refreshJwt !== account.refreshJwt) { - SecureStore.setItem(keys.refresh, account.refreshJwt ?? '') - } - if (prior?.accessJwt !== account.accessJwt) { - SecureStore.setItem(keys.access, account.accessJwt ?? '') - } - const descriptor = toDescriptor(account) - if (JSON.stringify(toDescriptor(prior)) !== JSON.stringify(descriptor)) { - SecureStore.setItem(keys.descriptor, JSON.stringify(descriptor)) - } - } - - if ( - !this.hasPersistedIndex || - retiredDids.length > 0 || - JSON.stringify(previous) !== JSON.stringify(next) - ) { - // Publishing the index is the commit point. `retiredDids` makes token - // cleanup recoverable if the process stops between these sync writes. - SecureStore.setItem( - SESSION_INDEX_KEY, - JSON.stringify(toStoredIndex(next, retiredDids)), - ) - } - - if (retiredDids.length) { - retiredDids.forEach(tombstoneAccount) - SecureStore.setItem( - SESSION_INDEX_KEY, - JSON.stringify(toStoredIndex(next)), - ) + this.durableSnapshot = next + this.maybeOrphanedDids.clear() + this.pending = undefined + this.cancelRetry() + } catch (cause) { + this.pending = {type: 'write', snapshot: next} + const error = storageError(operation, cause) + logStorageError(error) + this.emitFailure(error) + this.scheduleRetry() } } - private writeClear(dids: string[]) { - SecureStore.setItem( - SESSION_INDEX_KEY, - JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)), - ) - dids.forEach(tombstoneAccount) - SecureStore.setItem( - SESSION_INDEX_KEY, - JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)), - ) + private retry() { + if (!this.pending) return + if (this.pending.type === 'clear') { + const {dids} = this.pending + try { + eraseSessions(dids) + this.durableSnapshot = EMPTY_SNAPSHOT + this.snapshot = EMPTY_SNAPSHOT + this.maybeOrphanedDids.clear() + this.pending = undefined + this.cancelRetry() + } catch (cause) { + const error = storageError('retry', cause) + logStorageError(error) + this.emitFailure(error) + this.scheduleRetry() + } + } else { + this.persist(this.pending.snapshot, 'retry') + } } - private trackPossiblyWrittenAccounts(next: SessionSnapshot) { - const persistedDids = new Set( - this.persistedSnapshot.accounts.map(account => account.did), + /** + * Remember dids we may have partially written under a did the durable index + * never named, so a later retire or clear still tombstones their keys even + * if the failing commit never reached its index. + */ + private trackMaybeOrphaned(next: SessionSnapshot) { + const durableDids = new Set( + this.durableSnapshot.accounts.map(account => account.did), ) for (const account of next.accounts) { - if (!persistedDids.has(account.did)) { - this.possiblyWrittenDids.add(account.did) + if (!durableDids.has(account.did)) { + this.maybeOrphanedDids.add(account.did) } } } + private setDurable(snapshot: SessionSnapshot) { + this.snapshot = snapshot + this.durableSnapshot = snapshot + this.maybeOrphanedDids.clear() + this.pending = undefined + this.cancelRetry() + } + + private emitFailure(error: SessionStorageError) { + this.writeFailureListeners.forEach(listener => listener(error)) + } + + private attachRetryTriggers() { + if (this.retryTriggersAttached) return + this.retryTriggersAttached = true + onAppStateChange(state => { + if (state === 'active' && this.pending) this.retry() + }) + } + private scheduleRetry() { if (this.retryTimer) return this.retryTimer = setTimeout(() => { this.retryTimer = undefined - this.retryPending() + this.retry() }, RETRY_DELAY) } @@ -352,104 +235,6 @@ export class NativeSessionRepository implements SessionRepository { if (this.retryTimer) clearTimeout(this.retryTimer) this.retryTimer = undefined } - - private initializeSnapshot( - snapshot: SessionSnapshot, - shouldScrubLegacy = Boolean(snapshot.accounts.length), - ): SessionStorageLoadResult { - const result = this.commit(EMPTY_SNAPSHOT, snapshot) - if (result.status === 'pending') { - return {status: 'unavailable', error: result.error} - } - - // Migration/recovery is complete only after every item reads back and - // validates. The index also marks an intentionally empty session. - const storedIndex = SecureStore.getItem(SESSION_INDEX_KEY) - if (storedIndex === null) { - return { - status: 'unavailable', - error: {kind: 'unavailable', operation: 'open'}, - } - } - const verified = this.readSnapshot(storedIndex) - this.snapshot = verified - this.persistedSnapshot = verified - this.hasPersistedIndex = true - return { - status: 'ready', - snapshot: verified, - shouldScrubLegacy, - } - } -} - -class InvalidSessionStorageDataError extends Error {} - -function toDescriptor( - account: SessionAccount | undefined, -): AccountDescriptor | undefined { - if (!account) return undefined - const { - accessJwt: _accessJwt, - refreshJwt: _refreshJwt, - ...descriptor - } = account - return descriptor -} - -function toStoredIndex( - snapshot: SessionSnapshot, - retiredDids: string[] = [], - revokedDids: string[] = [], -): StoredIndex { - return { - version: 1, - currentDid: snapshot.currentDid, - dids: snapshot.accounts.map(account => account.did), - ...(retiredDids.length ? {retiredDids} : {}), - ...(revokedDids.length ? {revokedDids} : {}), - } -} - -function tombstoneAccount(did: string) { - tombstoneCredentials(did) - const keys = accountKeys(did) - SecureStore.setItem(keys.descriptor, '') -} - -function tombstoneCredentials(did: string) { - const keys = accountKeys(did) - SecureStore.setItem(keys.refresh, '') - SecureStore.setItem(keys.access, '') -} - -function storageError( - operation: SessionStorageError['operation'], - cause: unknown, -): SessionStorageError { - const message = cause instanceof Error ? cause.message : String(cause) - const kind = - cause instanceof InvalidSessionStorageDataError - ? 'invalid-data' - : /quota|disk.*full|storage.*full|no space/i.test(message) - ? 'storage-full' - : operation === 'open' - ? 'unavailable' - : 'write-failed' - return {kind, operation} -} - -function logStorageError(error: SessionStorageError) { - // Never attach the underlying native error: some platforms include the key - // in it. Keys are hashed, but keeping telemetry credential-agnostic is safer. - logger.error('session storage operation failed', { - kind: error.kind, - operation: error.operation, - tags: { - session_storage_kind: error.kind, - session_storage_operation: error.operation, - }, - }) } export function createSessionRepository(): SessionRepository { diff --git a/src/state/session/storage/repository.web.ts b/src/state/session/storage/repository.web.ts index cdc8310f6e..73709d2a72 100644 --- a/src/state/session/storage/repository.web.ts +++ b/src/state/session/storage/repository.web.ts @@ -1,19 +1,14 @@ import {createStore, update} from 'idb-keyval' import BroadcastChannel from '#/lib/broadcast' -import {logger} from '#/logger' import { - type SessionAccount, - sessionAccountSchema, - type SessionSnapshot, - sessionSnapshotSchema, -} from './schema' -import { - type SessionRepository, - type SessionStorageCommitResult, - type SessionStorageError, - type SessionStorageLoadResult, -} from './types' + InvalidSessionStorageDataError, + logStorageError, + storageError, +} from './errors' +import {mergeSnapshots} from './merge' +import {type SessionSnapshot, sessionSnapshotSchema} from './schema' +import {type SessionRepository, type SessionStorageError} from './types' const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1' const STORAGE_LOCK_NAME = `bsky-session:${STORAGE_KEY}` @@ -23,110 +18,210 @@ const UPDATE_EVENT = 'session-update-v1' const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} const RETRY_DELAY = 5_000 -type PendingCommit = { - previous: SessionSnapshot - next: SessionSnapshot - replace?: boolean -} +/** + * A unit of pending work. A write carries its merge base so a retry rebases + * against the same base even after other tabs have moved storage. A clear is a + * replacement that unconditionally writes the empty snapshot. + */ +type PendingWork = + | {type: 'write'; base: SessionSnapshot; next: SessionSnapshot} + | {type: 'clear'} +/** + * Web session repository. Persists to a dedicated localStorage key, serializes + * writes across tabs under navigator.locks (falling back to an idb-keyval + * mutex), and folds concurrent changes together with a three-way merge. Other + * tabs are notified over a BroadcastChannel and the native storage event. + */ export class WebSessionRepository implements SessionRepository { private snapshot: SessionSnapshot = EMPTY_SNAPSHOT - private pendingCommit: PendingCommit | undefined - private activeOperations = new Map< - PendingCommit, - Promise + private base: SessionSnapshot = EMPTY_SNAPSHOT + private pending: PendingWork | undefined + private active = new Map< + PendingWork, + Promise + >() + private subscribers = new Set<(snapshot: SessionSnapshot) => void>() + private writeFailureListeners = new Set< + (error: SessionStorageError) => void >() - private listeners = new Set<(snapshot: SessionSnapshot) => void>() private retryTimer: ReturnType | undefined - private opened = false - private legacyMigrationPending = false - private onLegacyMigrationComplete: (() => void) | undefined + private listenersAttached = false + private onDurable: (() => void) | undefined + private durableSignaled = false private broadcast = new BroadcastChannel(CHANNEL_NAME) - async open( - legacy?: SessionSnapshot, - onLegacyMigrationComplete?: () => void, - ): Promise { - this.onLegacyMigrationComplete = onLegacyMigrationComplete - let shouldScrubLegacy = false + async init( + legacy: SessionSnapshot, + onDurable: () => void, + ): Promise { + this.onDurable = onDurable + let stored: SessionSnapshot | undefined + let corrupt = false try { - const stored = readFromStorage() - if (stored) { - this.snapshot = stored - shouldScrubLegacy = Boolean(legacy?.accounts.length) - } else { - this.snapshot = legacy ?? EMPTY_SNAPSHOT - shouldScrubLegacy = - (await this.persistInitialSnapshot(this.snapshot)) && - Boolean(legacy?.accounts.length) - this.legacyMigrationPending = - !shouldScrubLegacy && Boolean(legacy?.accounts.length) - } + stored = readFromStorage() } catch (cause) { - logStorageError(storageError('open', cause)) - // A corrupt dedicated session key proves migration already committed; + logStorageError(storageError('init', cause)) + // A corrupt dedicated session key proves migration already committed, so // do not resurrect legacy credentials. If localStorage itself is // unavailable, keep the legacy snapshot alive in memory for this tab. - this.snapshot = - cause instanceof InvalidWebSessionStorageDataError - ? EMPTY_SNAPSHOT - : (legacy ?? EMPTY_SNAPSHOT) - shouldScrubLegacy = - (await this.persistInitialSnapshot(this.snapshot)) && - Boolean(legacy?.accounts.length) - this.legacyMigrationPending = - !shouldScrubLegacy && Boolean(legacy?.accounts.length) + corrupt = cause instanceof InvalidSessionStorageDataError + stored = undefined + } + if (stored) { + this.snapshot = stored + this.base = stored + this.signalDurable() + } else { + this.snapshot = corrupt ? EMPTY_SNAPSHOT : legacy + this.base = this.snapshot + // Persist the initial snapshot and only signal durability once it lands. + // On an unavailable store this fails and keeps retrying in the background. + await this.beginInitialPersist() } this.attachListeners() - return {status: 'ready', snapshot: this.snapshot, shouldScrubLegacy} + return this.snapshot } getSnapshot(): SessionSnapshot { return this.snapshot } - async commit( - previous: SessionSnapshot, - next: SessionSnapshot, - ): Promise { - if (this.pendingCommit?.replace) { - this.snapshot = this.pendingCommit.next - return this.retryPending() + write(next: SessionSnapshot): void { + if (this.pending?.type === 'clear') { + // A requested wipe is still settling. Drop writes so we never resurrect a + // session; retry the clear instead. + this.snapshot = EMPTY_SNAPSHOT + void this.retry() + return } this.snapshot = next - const base = this.pendingCommit?.previous ?? previous - const operation = {previous: base, next} - this.pendingCommit = operation + // Keep the original base across a superseded pending write (latest-wins). + const base = this.pending ? this.pending.base : this.base + const op: PendingWork = {type: 'write', base, next} + this.pending = op this.cancelRetry() - return this.persistOperation(operation, 'commit') - } - - async retryPending(): Promise { - if (!this.pendingCommit) return {status: 'committed'} - return this.persistOperation(this.pendingCommit, 'retry') + void this.persistOperation(op, 'write') } subscribe(listener: (snapshot: SessionSnapshot) => void): () => void { - this.listeners.add(listener) + this.subscribers.add(listener) return () => { - this.listeners.delete(listener) + this.subscribers.delete(listener) + } + } + + onWriteFailure(listener: (error: SessionStorageError) => void): () => void { + this.writeFailureListeners.add(listener) + return () => { + this.writeFailureListeners.delete(listener) } } async clear(): Promise { this.snapshot = EMPTY_SNAPSHOT - this.pendingCommit = { - previous: EMPTY_SNAPSHOT, - next: EMPTY_SNAPSHOT, - replace: true, - } + this.pending = {type: 'clear'} this.cancelRetry() - const result = await this.persistOperation(this.pendingCommit, 'clear') - if (result.status === 'pending') { - throw new Error(`session storage clear failed: ${result.error.kind}`) + const error = await this.persistOperation(this.pending, 'clear') + if (error) { + throw new Error(`session storage clear failed: ${error.kind}`) } } + /** + * Await the settling of all in-flight persist operations. Test-only helper; + * the SessionRepository contract is fire-and-forget. + */ + async whenSettled(): Promise { + while (this.active.size) { + await Promise.allSettled([...this.active.values()]) + } + } + + private async beginInitialPersist(): Promise { + const op: PendingWork = { + type: 'write', + base: this.snapshot, + next: this.snapshot, + } + this.pending = op + await this.persistOperation(op, 'init') + } + + private persistOperation( + op: PendingWork, + operation: SessionStorageError['operation'], + ): Promise { + const active = this.active.get(op) + if (active) return active + + const promise = (async (): Promise => { + try { + const committed = + op.type === 'clear' + ? await this.persistReplacement() + : await this.persistCommit(op, operation) + this.broadcast.postMessage({event: UPDATE_EVENT}) + if (this.pending === op) { + this.snapshot = committed + this.base = committed + this.pending = undefined + this.cancelRetry() + if ( + op.type === 'write' && + JSON.stringify(committed) !== JSON.stringify(op.next) + ) { + // The merge changed what we asked to write; converge the caller. + this.notify(committed) + } + this.signalDurable() + } + return undefined + } catch (cause) { + const error = storageError(operation, cause) + logStorageError(error) + this.emitFailure(error) + if (this.pending === op) this.scheduleRetry() + return error + } finally { + this.active.delete(op) + } + })() + this.active.set(op, promise) + return promise + } + + private persistCommit( + op: {base: SessionSnapshot; next: SessionSnapshot}, + operation: SessionStorageError['operation'], + ): Promise { + return withStorageLock(() => { + let theirs: SessionSnapshot + try { + theirs = readFromStorage() ?? op.base + } catch (cause) { + if (!(cause instanceof InvalidSessionStorageDataError)) throw cause + logStorageError(storageError(operation, cause)) + theirs = op.base + } + const committed = mergeSnapshots(op.base, op.next, theirs) + writeToStorage(committed) + return committed + }) + } + + private persistReplacement(): Promise { + return withStorageLock(() => { + writeToStorage(EMPTY_SNAPSHOT) + return EMPTY_SNAPSHOT + }) + } + + private async retry(): Promise { + if (!this.pending) return + await this.persistOperation(this.pending, 'retry') + } + private onStorage = (event: StorageEvent) => { if (event.key === STORAGE_KEY) this.receiveExternalUpdate() } @@ -144,29 +239,50 @@ export class WebSessionRepository implements SessionRepository { private receiveExternalUpdate() { try { - const next = readFromStorage() - if (this.pendingCommit) { - if (!this.activeOperations.has(this.pendingCommit)) { - void this.retryPending() - } + const theirs = readFromStorage() + if (this.pending) { + // Fold the external change into our pending write via a retry. + if (!this.active.has(this.pending)) void this.retry() return } - if (!next || JSON.stringify(next) === JSON.stringify(this.snapshot)) { + if (!theirs || JSON.stringify(theirs) === JSON.stringify(this.snapshot)) { return } - this.snapshot = next + this.snapshot = theirs + this.base = theirs this.cancelRetry() - this.listeners.forEach(listener => listener(next)) + this.notify(theirs) } catch (cause) { - logStorageError(storageError('open', cause)) + logStorageError(storageError('init', cause)) } } + private notify(snapshot: SessionSnapshot) { + this.subscribers.forEach(listener => listener(snapshot)) + } + + private emitFailure(error: SessionStorageError) { + this.writeFailureListeners.forEach(listener => listener(error)) + } + + private signalDurable() { + if (this.durableSignaled) return + this.durableSignaled = true + this.onDurable?.() + } + + private attachListeners() { + if (this.listenersAttached) return + this.listenersAttached = true + this.broadcast.onmessage = this.onBroadcastMessage + window.addEventListener('storage', this.onStorage) + } + private scheduleRetry() { if (this.retryTimer) return this.retryTimer = setTimeout(() => { this.retryTimer = undefined - void this.retryPending() + void this.retry() }, RETRY_DELAY) } @@ -174,92 +290,6 @@ export class WebSessionRepository implements SessionRepository { if (this.retryTimer) clearTimeout(this.retryTimer) this.retryTimer = undefined } - - private attachListeners() { - if (this.opened) return - this.opened = true - this.broadcast.onmessage = this.onBroadcastMessage - window.addEventListener('storage', this.onStorage) - } - - private async persistInitialSnapshot( - snapshot: SessionSnapshot, - ): Promise { - const operation = {previous: snapshot, next: snapshot} - this.pendingCommit = operation - const result = await this.persistOperation(operation, 'open') - return result.status === 'committed' - } - - private async persistOperation( - operation: PendingCommit, - errorOperation: SessionStorageError['operation'], - ): Promise { - const active = this.activeOperations.get(operation) - if (active) return active - - const promise = (async (): Promise => { - try { - const committed = operation.replace - ? await this.persistReplacement(operation.next) - : await this.persistCommit( - operation.previous, - operation.next, - errorOperation, - ) - this.broadcast.postMessage({event: UPDATE_EVENT}) - if (this.pendingCommit === operation) { - this.snapshot = committed - this.pendingCommit = undefined - this.cancelRetry() - if (JSON.stringify(committed) !== JSON.stringify(operation.next)) { - this.listeners.forEach(listener => listener(committed)) - } - if (this.legacyMigrationPending) { - this.legacyMigrationPending = false - this.onLegacyMigrationComplete?.() - } - } - return {status: 'committed'} - } catch (cause) { - const error = storageError(errorOperation, cause) - logStorageError(error) - if (this.pendingCommit === operation) this.scheduleRetry() - return {status: 'pending', error} - } finally { - this.activeOperations.delete(operation) - } - })() - this.activeOperations.set(operation, promise) - return promise - } - - private persistCommit( - previous: SessionSnapshot, - next: SessionSnapshot, - errorOperation: SessionStorageError['operation'], - ): Promise { - return withStorageLock(() => { - let latest: SessionSnapshot - try { - latest = readFromStorage() ?? previous - } catch (cause) { - if (!(cause instanceof InvalidWebSessionStorageDataError)) throw cause - logStorageError(storageError(errorOperation, cause)) - latest = previous - } - const committed = rebaseSessionSnapshot(previous, next, latest) - writeToStorage(committed) - return committed - }) - } - - private persistReplacement(next: SessionSnapshot): Promise { - return withStorageLock(() => { - writeToStorage(next) - return next - }) - } } function withStorageLock(callback: () => T): Promise { @@ -283,7 +313,7 @@ function readFromStorage(): SessionSnapshot | undefined { try { return sessionSnapshotSchema.parse(JSON.parse(raw)) } catch { - throw new InvalidWebSessionStorageDataError() + throw new InvalidSessionStorageDataError() } } @@ -291,134 +321,6 @@ function writeToStorage(snapshot: SessionSnapshot) { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)) } -class InvalidWebSessionStorageDataError extends Error {} - -const ACCOUNT_KEYS = [ - 'service', - 'did', - 'handle', - 'email', - 'emailConfirmed', - 'emailAuthFactor', - 'refreshJwt', - 'accessJwt', - 'signupQueued', - 'active', - 'status', - 'pdsUrl', - 'isSelfHosted', -] as const satisfies readonly (keyof SessionAccount)[] - -export function rebaseSessionSnapshot( - previous: SessionSnapshot, - next: SessionSnapshot, - latest: SessionSnapshot, -): SessionSnapshot { - const previousByDid = new Map( - previous.accounts.map(account => [account.did, account]), - ) - const nextByDid = new Map( - next.accounts.map(account => [account.did, account]), - ) - const resultByDid = new Map( - latest.accounts.map(account => [account.did, account]), - ) - - for (const did of previousByDid.keys()) { - if (!nextByDid.has(did)) resultByDid.delete(did) - } - for (const account of next.accounts) { - const prior = previousByDid.get(account.did) - if (!prior) { - const latestAccount = resultByDid.get(account.did) - const mergedAccount = latestAccount - ? { - ...latestAccount, - ...account, - refreshJwt: - latestAccount.refreshJwt && account.refreshJwt - ? account.refreshJwt - : undefined, - accessJwt: - latestAccount.accessJwt && account.accessJwt - ? account.accessJwt - : undefined, - } - : account - resultByDid.set(account.did, sessionAccountSchema.parse(mergedAccount)) - continue - } - if (JSON.stringify(prior) === JSON.stringify(account)) continue - const latestAccount = resultByDid.get(account.did) - if (!latestAccount) { - // A concurrent removal wins over an edit based on the removed account. - continue - } - let rebasedAccount = latestAccount - for (const key of ACCOUNT_KEYS) { - if (prior[key] !== account[key]) { - const isCredential = key === 'refreshJwt' || key === 'accessJwt' - const remotelyRevoked = - isCredential && - prior[key] !== latestAccount[key] && - !latestAccount[key] - if (!remotelyRevoked) { - rebasedAccount = {...rebasedAccount, [key]: account[key]} - } - } - } - resultByDid.set(account.did, sessionAccountSchema.parse(rebasedAccount)) - } - - const previousOrder = previous.accounts.map(account => account.did) - const nextOrder = next.accounts.map(account => account.did) - const latestOrder = latest.accounts.map(account => account.did) - const latestDids = new Set(latestOrder) - const orderChanged = - JSON.stringify(previousOrder) !== JSON.stringify(nextOrder) - const order = orderChanged - ? [...nextOrder, ...latestOrder.filter(did => !nextByDid.has(did))] - : [...latestOrder, ...nextOrder.filter(did => !latestDids.has(did))] - const accounts = order.flatMap(did => { - const account = resultByDid.get(did) - return account ? [account] : [] - }) - - let currentDid = - previous.currentDid === next.currentDid - ? latest.currentDid - : next.currentDid - if (currentDid && !resultByDid.has(currentDid)) currentDid = undefined - return sessionSnapshotSchema.parse({accounts, currentDid}) -} - -function storageError( - operation: SessionStorageError['operation'], - cause: unknown, -): SessionStorageError { - const message = cause instanceof Error ? cause.message : String(cause) - const kind = - cause instanceof InvalidWebSessionStorageDataError - ? 'invalid-data' - : /quota|disk.*full|storage.*full|no space/i.test(message) - ? 'storage-full' - : operation === 'open' - ? 'unavailable' - : 'write-failed' - return {kind, operation} -} - -function logStorageError(error: SessionStorageError) { - logger.error('session storage operation failed', { - kind: error.kind, - operation: error.operation, - tags: { - session_storage_kind: error.kind, - session_storage_operation: error.operation, - }, - }) -} - export function createSessionRepository(): SessionRepository { return new WebSessionRepository() } diff --git a/src/state/session/storage/secureStore.ts b/src/state/session/storage/secureStore.ts new file mode 100644 index 0000000000..159c55aafe --- /dev/null +++ b/src/state/session/storage/secureStore.ts @@ -0,0 +1,284 @@ +/** + * Native session storage layout over expo-secure-store. + * + * All reads and writes use the synchronous SecureStore variants. AtpAgent does + * not await its persist callback, so a token refresh must land in the keychain + * before the OS can suspend the app; async writes could be dropped. + * + * Storage layout + * - One index key (`SESSION_INDEX_KEY`) holds a JSON object listing the stored + * dids, the current did, and optional recovery journals. + * - Each account stores three keys derived from sha256(did): a descriptor (the + * account minus its tokens), a refresh token, and an access token. + * + * The index is the commit point. An index that names a did is only valid if + * that did's descriptor is present. Publishing the index last means an + * interrupted write is either fully visible or invisible. + * + * Tombstoning writes an empty string rather than deleting a key. This keeps + * every write a uniform synchronous `setItem` and never touches the async + * delete path. + * + * Crash-recovery protocol (readSessions): + * - `revokedDids` still present in `dids` had a credential cleared while the + * account was kept; finish clearing their tokens. + * - `retiredDids` no longer in `dids` were removed entirely; finish tombstoning + * the whole account. + * - Then rewrite a clean index with the journals stripped. + * + * Write ordering (writeSessions), designed so a crash between any two steps + * recovers to a valid state: + * 1. If any credentials are being revoked, journal against the PREVIOUS index + * (its descriptors are all durably present) annotated with the retired and + * revoked dids. A crash here recovers to "previous state minus the revoked + * credentials", which is correct because the commit never reached its + * commit point. Journaling against `next` instead would let a same-commit + * account addition leave a did with no descriptor, which recovery reads as + * invalid-data and resets everything - the mass-logout bug this avoids. + * 2. Write the changed credentials, then the changed descriptors, for every + * account in `next`. + * 3. Publish the commit index (`next` plus the retired-did journal). This is + * the commit point. It is skipped only when nothing changed and an index + * already exists; the first-ever write must still create the index. + * 4. If any accounts were retired, tombstone them, then publish a clean index. + * + * Accepted limitation: credentials written by a commit that fails at step 3 + * (before its index write) are orphaned across a process restart, because the + * durable index never named them and the in-memory maybe-orphaned set is lost. + * They are overwritten on the next login for that did and erased by clear(). + */ +import * as SecureStore from 'expo-secure-store' +import {z} from 'zod' + +import {InvalidSessionStorageDataError} from './errors' +import {accountKeys, SESSION_INDEX_KEY} from './keys' +import { + type SessionAccount, + sessionAccountSchema, + type SessionSnapshot, +} from './schema' + +const indexSchema = z.object({ + version: z.literal(1), + currentDid: z.string().optional(), + dids: z.array(z.string()), + retiredDids: z.array(z.string()).optional(), + revokedDids: z.array(z.string()).optional(), +}) +const descriptorSchema = sessionAccountSchema.omit({ + accessJwt: true, + refreshJwt: true, +}) + +type StoredIndex = z.infer +type AccountDescriptor = Omit + +const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} + +/** Whether a durable index has been committed. */ +export function indexExists(): boolean { + return SecureStore.getItem(SESSION_INDEX_KEY) !== null +} + +/** + * Read the stored snapshot, first completing any journaled tombstoning left by + * an interrupted write. Throws InvalidSessionStorageDataError if the index is + * missing, unparseable, or references a did whose descriptor is absent. + */ +export function readSessions(): SessionSnapshot { + const rawIndex = SecureStore.getItem(SESSION_INDEX_KEY) + if (rawIndex === null) { + throw new InvalidSessionStorageDataError() + } + let index: StoredIndex + try { + index = indexSchema.parse(JSON.parse(rawIndex)) + } catch { + throw new InvalidSessionStorageDataError() + } + if (index.revokedDids?.length) { + const activeDids = new Set(index.dids) + index.revokedDids + .filter(did => activeDids.has(did)) + .forEach(tombstoneCredentials) + } + if (index.retiredDids?.length) { + const activeDids = new Set(index.dids) + index.retiredDids + .filter(did => !activeDids.has(did)) + .forEach(tombstoneAccount) + } + if (index.revokedDids?.length || index.retiredDids?.length) { + const cleanedIndex = { + version: index.version, + currentDid: index.currentDid, + dids: index.dids, + } satisfies StoredIndex + SecureStore.setItem(SESSION_INDEX_KEY, JSON.stringify(cleanedIndex)) + index = cleanedIndex + } + if (index.currentDid && !index.dids.includes(index.currentDid)) { + throw new InvalidSessionStorageDataError() + } + const accounts = index.dids.map(did => { + const keys = accountKeys(did) + const rawDescriptor = SecureStore.getItem(keys.descriptor) + if (rawDescriptor === null) { + throw new InvalidSessionStorageDataError() + } + let descriptor: AccountDescriptor + try { + descriptor = descriptorSchema.parse(JSON.parse(rawDescriptor)) + } catch { + throw new InvalidSessionStorageDataError() + } + if (descriptor.did !== did) { + throw new InvalidSessionStorageDataError() + } + return { + ...descriptor, + refreshJwt: SecureStore.getItem(keys.refresh) || undefined, + accessJwt: SecureStore.getItem(keys.access) || undefined, + } + }) + return {accounts, currentDid: index.currentDid} +} + +/** + * Persist the transition from `previous` to `next` following the four-step + * ordering documented in the file header. `alsoRetire` names dids that a prior + * failed write may have partially persisted, so they are tombstoned too when + * absent from `next`. `forceIndex` publishes the index even when nothing + * changed, used for the first-ever write. + */ +export function writeSessions( + previous: SessionSnapshot, + next: SessionSnapshot, + { + alsoRetire = [], + forceIndex = false, + }: {alsoRetire?: string[]; forceIndex?: boolean} = {}, +) { + const previousByDid = new Map(previous.accounts.map(a => [a.did, a])) + const nextDids = new Set(next.accounts.map(a => a.did)) + const retiredDids = [ + ...new Set([ + ...previous.accounts + .filter(account => !nextDids.has(account.did)) + .map(account => account.did), + ...alsoRetire.filter(did => !nextDids.has(did)), + ]), + ] + const revokedDids = next.accounts + .filter(account => { + const prior = previousByDid.get(account.did) + return ( + (Boolean(prior?.refreshJwt) && !account.refreshJwt) || + (Boolean(prior?.accessJwt) && !account.accessJwt) + ) + }) + .map(account => account.did) + + if (revokedDids.length) { + /* + * Journal against the previous index, whose descriptors are all durably + * present. On interruption, readSessions finishes the tombstoning before + * loading, recovering to the previous state minus the revoked credentials. + */ + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(previous, retiredDids, revokedDids)), + ) + } + + /* + * Credentials go first. AtpAgent does not await its persistence callback, so + * these must complete synchronously before the app can be suspended. + */ + for (const account of next.accounts) { + const prior = previousByDid.get(account.did) + const keys = accountKeys(account.did) + if (prior?.refreshJwt !== account.refreshJwt) { + SecureStore.setItem(keys.refresh, account.refreshJwt ?? '') + } + if (prior?.accessJwt !== account.accessJwt) { + SecureStore.setItem(keys.access, account.accessJwt ?? '') + } + const descriptor = toDescriptor(account) + if (JSON.stringify(toDescriptor(prior)) !== JSON.stringify(descriptor)) { + SecureStore.setItem(keys.descriptor, JSON.stringify(descriptor)) + } + } + + const changed = + retiredDids.length > 0 || + revokedDids.length > 0 || + JSON.stringify(previous) !== JSON.stringify(next) + if (forceIndex || changed) { + // Publishing the index is the commit point. `retiredDids` keeps the token + // cleanup recoverable if the process stops between these sync writes. + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(next, retiredDids)), + ) + } + + if (retiredDids.length) { + retiredDids.forEach(tombstoneAccount) + SecureStore.setItem(SESSION_INDEX_KEY, JSON.stringify(toStoredIndex(next))) + } +} + +/** + * Erase every named did and reset the index to empty. Journals the removal + * first so an interrupted erase is finished by the next readSessions. + */ +export function eraseSessions(dids: string[]) { + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)), + ) + dids.forEach(tombstoneAccount) + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)), + ) +} + +function toDescriptor( + account: SessionAccount | undefined, +): AccountDescriptor | undefined { + if (!account) return undefined + const { + accessJwt: _accessJwt, + refreshJwt: _refreshJwt, + ...descriptor + } = account + return descriptor +} + +function toStoredIndex( + snapshot: SessionSnapshot, + retiredDids: string[] = [], + revokedDids: string[] = [], +): StoredIndex { + return { + version: 1, + currentDid: snapshot.currentDid, + dids: snapshot.accounts.map(account => account.did), + ...(retiredDids.length ? {retiredDids} : {}), + ...(revokedDids.length ? {revokedDids} : {}), + } +} + +function tombstoneAccount(did: string) { + tombstoneCredentials(did) + const keys = accountKeys(did) + SecureStore.setItem(keys.descriptor, '') +} + +function tombstoneCredentials(did: string) { + const keys = accountKeys(did) + SecureStore.setItem(keys.refresh, '') + SecureStore.setItem(keys.access, '') +} diff --git a/src/state/session/storage/types.ts b/src/state/session/storage/types.ts index ff51751661..c7d950a212 100644 --- a/src/state/session/storage/types.ts +++ b/src/state/session/storage/types.ts @@ -8,37 +8,28 @@ export type SessionStorageErrorKind = export type SessionStorageError = { kind: SessionStorageErrorKind - operation: 'open' | 'commit' | 'retry' | 'clear' + operation: 'init' | 'write' | 'retry' | 'clear' } -export type SessionStorageLoadResult = - | { - status: 'ready' - snapshot: SessionSnapshot - shouldScrubLegacy: boolean - } - | { - status: 'unavailable' - error: SessionStorageError - } - -export type SessionStorageCommitResult = - | {status: 'committed'} - | {status: 'pending'; error: SessionStorageError} - -export type MaybePromise = T | Promise - export interface SessionRepository { - open( - legacy?: SessionSnapshot, - onLegacyMigrationComplete?: () => void, - ): Promise + /** + * Load sessions from storage, performing crash recovery and one-time + * migration from `legacy`. `onDurable` fires exactly once, when data is + * known durably stored in the new format (possibly after a later retry) - + * the caller uses it to scrub the legacy location. Rejects if storage is + * unavailable; safe to call again to retry. + */ + init(legacy: SessionSnapshot, onDurable: () => void): Promise getSnapshot(): SessionSnapshot - commit( - previous: SessionSnapshot, - next: SessionSnapshot, - ): MaybePromise - retryPending(): MaybePromise + /** + * Persist a snapshot. Fire-and-forget: failures surface via onWriteFailure + * and retry automatically. The latest write wins. + */ + write(next: SessionSnapshot): void + /** The stored snapshot changed externally (another tab). Never fires on native. */ subscribe(listener: (snapshot: SessionSnapshot) => void): () => void + /** A persist attempt failed; a retry is scheduled. */ + onWriteFailure(listener: (error: SessionStorageError) => void): () => void + /** Erase all session data, including leftovers from interrupted writes. */ clear(): Promise }