From 54a12c1740ff34743c2c086b027fe7a2df12dbc3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 26 Jul 2026 11:20:32 +0300 Subject: [PATCH] move sessions to secure storage --- app.config.js | 1 + package.json | 1 + pnpm-lock.yaml | 12 + src/App.tsx | 37 +- src/App.web.tsx | 37 +- src/screens/Settings/Settings.tsx | 3 +- src/state/persisted/schema.ts | 23 +- src/state/session/__tests__/session-test.ts | 26 -- src/state/session/index.tsx | 94 ++-- src/state/session/logging.ts | 6 +- src/state/session/reducer.ts | 9 - .../storage/__tests__/repository-test.ts | 278 ++++++++++++ src/state/session/storage/index.ts | 46 ++ src/state/session/storage/keys.ts | 19 + src/state/session/storage/repository.ts | 410 ++++++++++++++++++ src/state/session/storage/repository.web.ts | 195 +++++++++ src/state/session/storage/schema.ts | 32 ++ src/state/session/storage/types.ts | 39 ++ src/state/session/types.ts | 4 +- src/state/session/util.ts | 7 +- 20 files changed, 1182 insertions(+), 97 deletions(-) create mode 100644 src/state/session/storage/__tests__/repository-test.ts create mode 100644 src/state/session/storage/index.ts create mode 100644 src/state/session/storage/keys.ts create mode 100644 src/state/session/storage/repository.ts create mode 100644 src/state/session/storage/repository.web.ts create mode 100644 src/state/session/storage/schema.ts create mode 100644 src/state/session/storage/types.ts diff --git a/app.config.js b/app.config.js index 79dd1dc619..98d6931830 100644 --- a/app.config.js +++ b/app.config.js @@ -240,6 +240,7 @@ module.exports = function (_config) { plugins: [ 'expo-video', 'expo-localization', + 'expo-secure-store', 'expo-web-browser', [ 'react-native-edge-to-edge', diff --git a/package.json b/package.json index 4a5ff7a6d3..0e0e00205b 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "expo-paste-input": "^0.2.1", "expo-privacy-sensitive": "^0.2.0", "expo-screen-orientation": "~9.0.8", + "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-sms": "^14.0.7", "expo-splash-screen": "~31.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dffc47fe3..46f7ff5f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,6 +514,9 @@ importers: expo-screen-orientation: specifier: ~9.0.8 version: 9.0.9(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-secure-store: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) expo-sharing: specifier: ~14.0.8 version: 14.0.8(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) @@ -5444,6 +5447,11 @@ packages: expo: '*' react-native: '*' + expo-secure-store@15.0.8: + resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==} + peerDependencies: + expo: '*' + expo-server@1.0.7: resolution: {integrity: sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==} engines: {node: '>=20.16.0'} @@ -14718,6 +14726,10 @@ snapshots: expo: 54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native: 0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + expo-secure-store@15.0.8(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + dependencies: + expo: 54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server@1.0.7: {} expo-sharing@14.0.8(expo@54.0.35(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=91fd85363059530dea649a5ca6012b933f79c8491737fbfdc685abc727e48403)(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): diff --git a/src/App.tsx b/src/App.tsx index 2af2146d92..37a0f404d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,10 @@ import { useSession, useSessionApi, } from '#/state/session' +import { + getSessionRepository, + initSessionRepository, +} 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' @@ -130,7 +134,7 @@ function InnerApp() { setIsReady(true) } } - const account = readLastActiveAccount() + const account = readLastActiveAccount(getSessionRepository().getSnapshot()) void onLaunch(account) }, [resumeSession]) @@ -218,9 +222,36 @@ function App() { const [isReady, setIsReady] = useState(false) useEffect(() => { - void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( - () => setIsReady(true), + 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) + } }, []) if (!isReady) { diff --git a/src/App.web.tsx b/src/App.web.tsx index 8070c4d333..c4ba54da05 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -37,6 +37,10 @@ import { useSession, useSessionApi, } from '#/state/session' +import { + getSessionRepository, + initSessionRepository, +} 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' @@ -109,7 +113,7 @@ function InnerApp() { setIsReady(true) } } - const account = readLastActiveAccount() + const account = readLastActiveAccount(getSessionRepository().getSnapshot()) void onLaunch(account) }, [resumeSession]) @@ -197,9 +201,36 @@ function App() { const [isReady, setIsReady] = useState(false) useEffect(() => { - void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( - () => setIsReady(true), + 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) + } }, []) if (!isReady) { diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index a1234a27ec..c7af1b0fda 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -23,6 +23,7 @@ import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declarat import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' import {useAgent} from '#/state/session' import {type SessionAccount, useSession, useSessionApi} from '#/state/session' +import {getSessionRepository} from '#/state/session/storage' import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' @@ -406,7 +407,7 @@ function DevOptions() { } const clearAllStorage = async () => { - await clearStorage() + await Promise.all([clearStorage(), getSessionRepository().clear()]) Toast.show(l`Storage cleared, you need to restart the app now.`) } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 9b86360765..5940132387 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -3,6 +3,7 @@ import {z} from 'zod' import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' import {findSupportedAppLanguage} from '#/locale/helpers' import {logger} from '#/logger' +import {sessionAccountSchema} from '#/state/session/storage/schema' import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' const externalEmbedOptions = ['show', 'hide'] as const @@ -11,26 +12,8 @@ const externalEmbedOptions = ['show', 'hide'] as const * A account persisted to storage. Stored in the `accounts[]` array. Contains * base account info and access tokens. */ -const accountSchema = z.object({ - service: z.string(), - did: z.string(), - handle: z.string(), - email: z.string().optional(), - emailConfirmed: z.boolean().optional(), - emailAuthFactor: z.boolean().optional(), - refreshJwt: z.string().optional(), // optional because it can expire - accessJwt: z.string().optional(), // optional because it can expire - signupQueued: z.boolean().optional(), - active: z.boolean().optional(), // optional for backwards compat - /** - * Known values: takendown, suspended, deactivated - * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json - */ - status: z.string().optional(), - pdsUrl: z.string().optional(), - isSelfHosted: z.boolean().optional(), -}) -export type PersistedAccount = z.infer +const accountSchema = sessionAccountSchema +export type PersistedAccount = z.infer /** * The current account. Stored in the `currentAccount` field. diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index eebcfcf8d2..68770f043d 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -33,7 +33,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": false, } `) @@ -82,7 +81,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) @@ -122,7 +120,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -175,7 +172,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) @@ -241,7 +237,6 @@ describe('session', () => { }, "did": "bob-did", }, - "needsPersist": true, } `) @@ -307,7 +302,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) @@ -386,7 +380,6 @@ describe('session', () => { }, "did": "jay-did", }, - "needsPersist": true, } `) @@ -460,7 +453,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -522,7 +514,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) @@ -570,7 +561,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) }) @@ -615,7 +605,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -687,7 +676,6 @@ describe('session', () => { }, "did": "bob-did", }, - "needsPersist": true, } `) @@ -795,7 +783,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -871,7 +858,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) @@ -925,7 +911,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) @@ -979,7 +964,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) }) @@ -1159,7 +1143,6 @@ describe('session', () => { }, "did": "bob-did", }, - "needsPersist": true, } `) @@ -1226,7 +1209,6 @@ describe('session', () => { }, "did": "bob-did", }, - "needsPersist": true, } `) @@ -1378,7 +1360,6 @@ describe('session', () => { }, "did": "alice-did", }, - "needsPersist": true, } `) }) @@ -1444,7 +1425,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -1510,7 +1490,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": true, } `) }) @@ -1582,7 +1561,6 @@ describe('session', () => { // Keep Bob logged in. // (We patch up agent.session outside the reducer for this to work.) expect(state.currentAgentState.did).toBe('bob-did') - expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1623,7 +1601,6 @@ describe('session', () => { }, "did": "bob-did", }, - "needsPersist": false, } `) @@ -1647,7 +1624,6 @@ describe('session', () => { // Log out because we have no matching user. // (In practice, we'll resume this session outside the reducer.) expect(state.currentAgentState.did).toBe(undefined) - expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1673,7 +1649,6 @@ describe('session', () => { }, "did": undefined, }, - "needsPersist": false, } `) }) @@ -1694,6 +1669,5 @@ function printState(state: State) { agent: {service: state.currentAgentState.agent.service}, did: state.currentAgentState.did, }, - needsPersist: state.needsPersist, } } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index fd31261a9d..00879ea0b3 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -9,10 +9,11 @@ import { useSyncExternalStore, } from 'react' import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' -import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import * as Toast from '#/components/Toast' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' import {emitSessionDropped} from '../events' @@ -25,6 +26,12 @@ import { sessionAccountToSession, } from './agent' import {type Action, getInitialState, reducer, type State} from './reducer' +import { + getSessionRepository, + 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' @@ -64,10 +71,12 @@ ApiContext.displayName = 'SessionApiContext' class SessionStore { private state: State private listeners = new Set<() => void>() + private storageErrorListeners = new Set< + (kind: SessionStorageErrorKind) => void + >() - constructor() { - // Careful: By the time this runs, `persisted` needs to already be filled. - const initialState = getInitialState(persisted.get('session').accounts) + constructor(private repository: SessionRepository) { + const initialState = getInitialState(repository.getSnapshot().accounts) addSessionDebugLog({type: 'reducer:init', state: initialState}) this.state = initialState } @@ -84,31 +93,69 @@ class SessionStore { } dispatch = (action: Action) => { - const nextState = reducer(this.state, action) + const previousState = this.state + const nextState = reducer(previousState, action) + if (nextState === previousState) return this.state = nextState - // Persist synchronously without waiting for the React render cycle. - if (nextState.needsPersist) { - nextState.needsPersist = false - const persistedData = { - accounts: nextState.accounts, - currentAccount: nextState.accounts.find( - a => a.did === nextState.currentAgentState.did, - ), - } - addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) - void persisted.write('session', persistedData) + const nextSnapshot: SessionSnapshot = { + accounts: nextState.accounts, + currentDid: nextState.currentAgentState.did, + } + addSessionDebugLog({type: 'persisted:broadcast', data: nextSnapshot}) + const result = this.repository.commit( + this.repository.getSnapshot(), + nextSnapshot, + ) + if (result.status === 'pending') { + this.storageErrorListeners.forEach(listener => + listener(result.error.kind), + ) } this.listeners.forEach(listener => listener()) } + + sync = (snapshot: SessionSnapshot) => { + this.state = reducer(this.state, { + type: 'synced-accounts', + syncedAccounts: snapshot.accounts, + syncedCurrentDid: snapshot.currentDid, + }) + 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<{}>) { + const {t: l} = useLingui() const ax = useAnalyticsBase() const cancelPendingTask = useOneTaskAtATime() // eslint-disable-next-line react/hook-use-state - const [store] = useState(() => new SessionStore()) + const [repository] = useState(getSessionRepository) + // eslint-disable-next-line react/hook-use-state + const [store] = useState(() => new SessionStore(repository)) const state = useSyncExternalStore(store.subscribe, store.getState) const onboardingDispatch = useOnboardingDispatch() + const showedStorageFullWarning = useRef(false) + + useEffect(() => { + return store.subscribeStorageErrors(kind => { + if (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.`, + {type: 'error'}, + ) + } + }) + }, [l, store]) const onAgentSessionChange = useCallback( (agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { @@ -311,16 +358,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [store, cancelPendingTask], ) useEffect(() => { - return persisted.onUpdate('session', nextSession => { - const synced = nextSession + return repository.subscribe(synced => { addSessionDebugLog({type: 'persisted:receive', data: synced}) - store.dispatch({ - type: 'synced-accounts', - syncedAccounts: synced.accounts, - syncedCurrentDid: synced.currentAccount?.did, - }) + store.sync(synced) const syncedAccount = synced.accounts.find( - a => a.did === synced.currentAccount?.did, + a => a.did === synced.currentDid, ) if (syncedAccount && syncedAccount.refreshJwt) { if (syncedAccount.did !== state.currentAgentState.did) { @@ -345,7 +387,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } } }) - }, [store, state, resumeSession]) + }, [repository, store, state, resumeSession]) const stateContext = useMemo( () => ({ diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts index da017d823f..60879c9125 100644 --- a/src/state/session/logging.ts +++ b/src/state/session/logging.ts @@ -1,7 +1,7 @@ import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api' -import {type Schema} from '../persisted' import {type Action, type State} from './reducer' +import {type SessionSnapshot} from './storage' import {type SessionAccount} from './types' type Reducer = (state: State, action: Action) => State @@ -39,11 +39,11 @@ type Log = } | { type: 'persisted:broadcast' - data: Schema['session'] + data: SessionSnapshot } | { type: 'persisted:receive' - data: Schema['session'] + data: SessionSnapshot } | { type: 'agent:switch' diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index d22dd4a021..ae05e6b180 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -24,7 +24,6 @@ type AgentState = { export type State = { readonly accounts: SessionAccount[] readonly currentAgentState: AgentState - needsPersist: boolean // Mutated in an effect. } export type Action = @@ -72,7 +71,6 @@ export function getInitialState(persistedAccounts: SessionAccount[]): State { return { accounts: persistedAccounts, currentAgentState: createPublicAgentState(), - needsPersist: false, } } @@ -121,7 +119,6 @@ let reducer = (state: State, action: Action): State => { currentAgentState: refreshedAccount ? state.currentAgentState : createPublicAgentState(), // Log out if expired. - needsPersist: true, } } case 'switched-to-account': { @@ -135,7 +132,6 @@ let reducer = (state: State, action: Action): State => { did: newAccount.did, agent: newAgent, }, - needsPersist: true, } } case 'removed-account': { @@ -163,7 +159,6 @@ let reducer = (state: State, action: Action): State => { state.currentAgentState.did === accountDid ? createPublicAgentState() // Log out if removing the current one. : state.currentAgentState, - needsPersist: true, } } case 'logged-out-current-account': { @@ -196,7 +191,6 @@ let reducer = (state: State, action: Action): State => { : a, ), currentAgentState: createPublicAgentState(), - needsPersist: true, } } case 'logged-out-every-account': { @@ -217,7 +211,6 @@ let reducer = (state: State, action: Action): State => { accessJwt: undefined, })), currentAgentState: createPublicAgentState(), - needsPersist: true, } } case 'synced-accounts': { @@ -228,7 +221,6 @@ let reducer = (state: State, action: Action): State => { syncedCurrentDid === state.currentAgentState.did ? state.currentAgentState : createPublicAgentState(), // Log out if different user. - needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. } } case 'partial-refresh-session': { @@ -261,7 +253,6 @@ let reducer = (state: State, action: Action): State => { } return a }), - needsPersist: true, } } } diff --git a/src/state/session/storage/__tests__/repository-test.ts b/src/state/session/storage/__tests__/repository-test.ts new file mode 100644 index 0000000000..37a9bc0be7 --- /dev/null +++ b/src/state/session/storage/__tests__/repository-test.ts @@ -0,0 +1,278 @@ +import {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' + +const mockValues = new Map() +let mockFailKey: string | undefined +const mockSetItem = jest.fn((key: string, value: string) => { + if (key === mockFailKey) throw new Error('disk full') + mockValues.set(key, value) +}) +const mockGetItem = jest.fn((key: string) => mockValues.get(key) ?? null) +jest.mock('expo-secure-store', () => ({ + setItem: mockSetItem, + getItem: mockGetItem, +})) + +jest.mock('#/lib/appState', () => ({ + onAppStateChange: jest.fn(() => ({remove: jest.fn()})), +})) + +const alice: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:alice', + handle: 'alice.test', + refreshJwt: 'alice-refresh', + accessJwt: 'alice-access', +} + +beforeEach(() => { + mockValues.clear() + mockFailKey = undefined + mockSetItem.mockClear() + mockGetItem.mockClear() + jest.clearAllTimers() +}) + +describe('NativeSessionRepository', () => { + it('migrates credentials synchronously and publishes the index last', async () => { + const repository = new NativeSessionRepository() + const legacy: SessionSnapshot = { + accounts: [alice], + currentDid: alice.did, + } + + await expect(repository.open(legacy)).resolves.toMatchObject({ + status: 'ready', + shouldScrubLegacy: true, + }) + + const keys = accountKeys(alice.did) + expect(mockSetItem.mock.calls.map(([key]) => key)).toEqual([ + keys.refresh, + keys.access, + keys.descriptor, + SESSION_INDEX_KEY, + ]) + expect(keys.refresh).not.toContain(alice.did) + expect(repository.getSnapshot()).toEqual(legacy) + }) + + it('uses an existing index as the migration marker', async () => { + mockValues.set( + SESSION_INDEX_KEY, + JSON.stringify({version: 1, dids: [], currentDid: undefined}), + ) + const repository = new NativeSessionRepository() + + await expect( + repository.open({accounts: [alice], currentDid: alice.did}), + ).resolves.toEqual({ + status: 'ready', + snapshot: {accounts: [], currentDid: undefined}, + shouldScrubLegacy: true, + }) + 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() + + await expect( + repository.open({accounts: [alice], currentDid: alice.did}), + ).resolves.toMatchObject({ + status: 'ready', + snapshot: {accounts: [], currentDid: undefined}, + shouldScrubLegacy: true, + }) + }) + + it('keeps the newest snapshot in memory and retries a complete commit', async () => { + const repository = new NativeSessionRepository() + await repository.open() + const keys = accountKeys(alice.did) + mockFailKey = keys.access + + const first = {accounts: [alice], currentDid: alice.did} + expect(repository.commit({accounts: []}, first).status).toBe('pending') + + const refreshed = { + accounts: [ + {...alice, refreshJwt: 'new-refresh', accessJwt: 'new-access'}, + ], + currentDid: alice.did, + } + expect(repository.commit(first, refreshed).status).toBe('pending') + expect(repository.getSnapshot()).toEqual(refreshed) + + mockFailKey = undefined + expect(repository.retryPending()).toEqual({status: 'committed'}) + 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({ + version: 1, + currentDid: alice.did, + dids: [alice.did], + }) + }) + + it('tombstones credentials before publishing logout', async () => { + const repository = new NativeSessionRepository() + const active = {accounts: [alice], currentDid: alice.did} + await repository.open(active) + mockSetItem.mockClear() + + const loggedOut = { + accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}], + currentDid: undefined, + } + expect(repository.commit(active, loggedOut)).toEqual({status: 'committed'}) + + const keys = accountKeys(alice.did) + expect(mockSetItem.mock.calls).toEqual([ + [ + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + currentDid: undefined, + dids: [alice.did], + revokedDids: [alice.did], + }), + ], + [keys.refresh, ''], + [keys.access, ''], + [ + SESSION_INDEX_KEY, + JSON.stringify({version: 1, currentDid: undefined, dids: [alice.did]}), + ], + ]) + }) + + it('finishes an interrupted retained-account logout on open', async () => { + const keys = accountKeys(alice.did) + const { + accessJwt: _accessJwt, + refreshJwt: _refreshJwt, + ...descriptor + } = alice + mockValues.set(keys.refresh, '') + mockValues.set(keys.access, alice.accessJwt!) + mockValues.set(keys.descriptor, JSON.stringify(descriptor)) + mockValues.set( + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + dids: [alice.did], + revokedDids: [alice.did], + }), + ) + + const repository = new NativeSessionRepository() + await expect(repository.open()).resolves.toMatchObject({ + status: 'ready', + snapshot: { + 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({ + version: 1, + dids: [alice.did], + }) + }) + + 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) + mockSetItem.mockClear() + + const removed = {accounts: [], currentDid: undefined} + expect(repository.commit(active, removed)).toEqual({status: 'committed'}) + + const keys = accountKeys(alice.did) + expect(mockSetItem.mock.calls).toEqual([ + [ + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + currentDid: undefined, + dids: [], + retiredDids: [alice.did], + }), + ], + [keys.refresh, ''], + [keys.access, ''], + [keys.descriptor, ''], + [ + SESSION_INDEX_KEY, + JSON.stringify({version: 1, currentDid: undefined, dids: []}), + ], + ]) + + expect(repository.commit(removed, active)).toEqual({status: 'committed'}) + expect(mockValues.get(keys.refresh)).toBe(alice.refreshJwt) + expect(mockValues.get(keys.access)).toBe(alice.accessJwt) + expect(JSON.parse(mockValues.get(keys.descriptor)!)).toMatchObject({ + did: alice.did, + }) + }) + + it('finishes interrupted tombstoning before loading a session', async () => { + const keys = accountKeys(alice.did) + mockValues.set(keys.refresh, alice.refreshJwt!) + mockValues.set(keys.access, alice.accessJwt!) + mockValues.set(keys.descriptor, JSON.stringify(alice)) + mockValues.set( + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + dids: [], + retiredDids: [alice.did], + }), + ) + + const repository = new NativeSessionRepository() + await expect(repository.open()).resolves.toMatchObject({status: 'ready'}) + + expect(mockValues.get(keys.refresh)).toBe('') + expect(mockValues.get(keys.access)).toBe('') + expect(mockValues.get(keys.descriptor)).toBe('') + expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({ + version: 1, + dids: [], + }) + }) + + 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) + + mockFailKey = SESSION_INDEX_KEY + expect( + repository.commit(active, {accounts: [], currentDid: undefined}).status, + ).toBe('pending') + mockFailKey = undefined + + await repository.clear() + + const keys = accountKeys(alice.did) + expect(mockValues.get(keys.refresh)).toBe('') + expect(mockValues.get(keys.access)).toBe('') + expect(mockValues.get(keys.descriptor)).toBe('') + expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({ + version: 1, + dids: [], + }) + }) +}) diff --git a/src/state/session/storage/index.ts b/src/state/session/storage/index.ts new file mode 100644 index 0000000000..ff42885945 --- /dev/null +++ b/src/state/session/storage/index.ts @@ -0,0 +1,46 @@ +import * as persisted from '#/state/persisted' +import {createSessionRepository} from './repository' +import {type SessionSnapshot} from './schema' + +const repository = createSessionRepository() +let initialized = false + +export async function initSessionRepository() { + if (initialized) return repository + + const legacy = persisted.get('session') + const legacyCurrentDid = legacy.currentAccount?.did + const legacySnapshot: SessionSnapshot = { + accounts: legacy.accounts, + currentDid: legacy.accounts.some( + account => account.did === legacyCurrentDid, + ) + ? legacyCurrentDid + : undefined, + } + const result = await repository.open(legacySnapshot) + 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 persisted.write('session', { + accounts: [], + currentAccount: undefined, + }) + } + initialized = true + return repository +} + +export function getSessionRepository() { + if (!initialized) { + throw new Error('session repository used before initialization') + } + return repository +} + +export type {SessionSnapshot} from './schema' +export type {SessionRepository} from './types' diff --git a/src/state/session/storage/keys.ts b/src/state/session/storage/keys.ts new file mode 100644 index 0000000000..bf4c9ba2a2 --- /dev/null +++ b/src/state/session/storage/keys.ts @@ -0,0 +1,19 @@ +import {sha256} from 'js-sha256' + +export const SESSION_INDEX_KEY = 'bsky.session.index.v1' + +export function accountKeys(did: string) { + // DIDs often fit SecureStore's key grammar, but did:web can be arbitrarily + // long and include disallowed characters. One fixed derivation keeps the + // storage layout uniform for every DID method. + // `.create()` forces the library's portable implementation. Calling the + // convenience function directly selects Node crypto under Jest, while the + // app's `crypto` alias intentionally exposes only Web Crypto. + const id = sha256.create().update(did).hex() + const prefix = `bsky.session.${id}` + return { + descriptor: `${prefix}.descriptor`, + refresh: `${prefix}.refresh`, + access: `${prefix}.access`, + } +} diff --git a/src/state/session/storage/repository.ts b/src/state/session/storage/repository.ts new file mode 100644 index 0000000000..1d7d6af383 --- /dev/null +++ b/src/state/session/storage/repository.ts @@ -0,0 +1,410 @@ +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' +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 + +const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} +const RETRY_DELAY = 5_000 + +export class NativeSessionRepository implements SessionRepository { + private snapshot: SessionSnapshot = EMPTY_SNAPSHOT + private persistedSnapshot: SessionSnapshot = EMPTY_SNAPSHOT + private hasPersistedIndex = false + private pendingSnapshot: SessionSnapshot | undefined + private retryTimer: ReturnType | undefined + private listeners = new Set<(snapshot: SessionSnapshot) => void>() + + constructor() { + onAppStateChange(state => { + if (state === 'active' && this.pendingSnapshot) { + this.retryPending() + } + }) + } + + // async to keep one repository contract across native and web. + // eslint-disable-next-line @typescript-eslint/require-await + async open(legacy?: SessionSnapshot): Promise { + try { + const rawIndex = SecureStore.getItem(SESSION_INDEX_KEY) + if (rawIndex !== null) { + let snapshot: SessionSnapshot + try { + snapshot = this.readSnapshot(rawIndex) + } 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), + } + } + + return this.initializeSnapshot(legacy ?? EMPTY_SNAPSHOT) + } catch (cause) { + const error = storageError('open', cause) + logStorageError(error) + return {status: 'unavailable', error} + } + } + + getSnapshot(): SessionSnapshot { + return this.snapshot + } + + commit( + _previous: SessionSnapshot, + next: SessionSnapshot, + ): SessionStorageCommitResult { + this.snapshot = next + try { + this.writeSnapshot(this.persistedSnapshot, next) + this.persistedSnapshot = next + this.hasPersistedIndex = true + this.pendingSnapshot = undefined + 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) { + return {status: 'committed'} + } + const next = this.pendingSnapshot + try { + this.writeSnapshot(this.persistedSnapshot, next) + this.persistedSnapshot = next + this.hasPersistedIndex = true + this.pendingSnapshot = undefined + this.cancelRetry() + return {status: 'committed'} + } catch (cause) { + const error = storageError('retry', cause) + logStorageError(error) + this.scheduleRetry() + return {status: 'pending', error} + } + } + + subscribe(listener: (snapshot: SessionSnapshot) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + // eslint-disable-next-line @typescript-eslint/require-await + async clear(): Promise { + const dids = [ + ...new Set([ + ...this.persistedSnapshot.accounts.map(account => account.did), + ...this.snapshot.accounts.map(account => account.did), + ]), + ] + try { + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)), + ) + dids.forEach(tombstoneAccount) + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)), + ) + this.snapshot = EMPTY_SNAPSHOT + this.persistedSnapshot = EMPTY_SNAPSHOT + this.hasPersistedIndex = true + this.pendingSnapshot = undefined + this.cancelRetry() + } catch (cause) { + const error = storageError('clear', cause) + logStorageError(error) + throw cause + } + } + + private readSnapshot(rawIndex: string): SessionSnapshot { + 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} + } + + 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 = previous.accounts + .filter(account => !nextDids.has(account.did)) + .map(account => account.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 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 || + 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)), + ) + } + } + + private scheduleRetry() { + if (this.retryTimer) return + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined + this.retryPending() + }, RETRY_DELAY) + } + + private cancelRetry() { + 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 { + return new NativeSessionRepository() +} diff --git a/src/state/session/storage/repository.web.ts b/src/state/session/storage/repository.web.ts new file mode 100644 index 0000000000..3c11147689 --- /dev/null +++ b/src/state/session/storage/repository.web.ts @@ -0,0 +1,195 @@ +import BroadcastChannel from '#/lib/broadcast' +import {logger} from '#/logger' +import {type SessionSnapshot, sessionSnapshotSchema} from './schema' +import { + type SessionRepository, + type SessionStorageCommitResult, + type SessionStorageError, + type SessionStorageLoadResult, +} from './types' + +const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1' +const CHANNEL_NAME = 'BSKY_SESSION_BROADCAST_CHANNEL' +const UPDATE_EVENT = 'session-update-v1' +const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} +const RETRY_DELAY = 5_000 + +export class WebSessionRepository implements SessionRepository { + private snapshot: SessionSnapshot = EMPTY_SNAPSHOT + private pendingSnapshot: SessionSnapshot | undefined + private listeners = new Set<(snapshot: SessionSnapshot) => void>() + private retryTimer: ReturnType | undefined + private opened = false + private broadcast = new BroadcastChannel(CHANNEL_NAME) + + // async to keep one repository contract across native and web. + // eslint-disable-next-line @typescript-eslint/require-await + async open(legacy?: SessionSnapshot): Promise { + try { + const stored = readFromStorage() + if (stored) { + this.snapshot = stored + } else { + this.snapshot = legacy ?? EMPTY_SNAPSHOT + writeToStorage(this.snapshot) + } + if (!this.opened) { + this.opened = true + this.broadcast.onmessage = this.onBroadcastMessage + window.addEventListener('storage', this.onStorage) + } + return { + status: 'ready', + snapshot: this.snapshot, + shouldScrubLegacy: Boolean(legacy?.accounts.length), + } + } catch (cause) { + const error = storageError('open', cause) + logStorageError(error) + return {status: 'unavailable', error} + } + } + + getSnapshot(): SessionSnapshot { + return this.snapshot + } + + commit( + _previous: SessionSnapshot, + next: SessionSnapshot, + ): SessionStorageCommitResult { + this.snapshot = next + try { + writeToStorage(next) + this.pendingSnapshot = undefined + this.cancelRetry() + this.broadcast.postMessage({event: UPDATE_EVENT}) + 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) return {status: 'committed'} + const next = this.pendingSnapshot + try { + writeToStorage(next) + this.pendingSnapshot = undefined + this.cancelRetry() + this.broadcast.postMessage({event: UPDATE_EVENT}) + return {status: 'committed'} + } catch (cause) { + const error = storageError('retry', cause) + logStorageError(error) + this.scheduleRetry() + return {status: 'pending', error} + } + } + + subscribe(listener: (snapshot: SessionSnapshot) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + // eslint-disable-next-line @typescript-eslint/require-await + async clear(): Promise { + try { + writeToStorage(EMPTY_SNAPSHOT) + this.snapshot = EMPTY_SNAPSHOT + this.pendingSnapshot = undefined + this.cancelRetry() + this.broadcast.postMessage({event: UPDATE_EVENT}) + } catch (cause) { + const error = storageError('clear', cause) + logStorageError(error) + throw cause + } + } + + private onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) this.receiveExternalUpdate() + } + + private onBroadcastMessage = ({data}: MessageEvent) => { + if ( + typeof data === 'object' && + data !== null && + 'event' in data && + data.event === UPDATE_EVENT + ) { + this.receiveExternalUpdate() + } + } + + private receiveExternalUpdate() { + try { + const next = readFromStorage() + if (!next || JSON.stringify(next) === JSON.stringify(this.snapshot)) { + return + } + this.snapshot = next + this.pendingSnapshot = undefined + this.cancelRetry() + this.listeners.forEach(listener => listener(next)) + } catch (cause) { + logStorageError(storageError('open', cause)) + } + } + + private scheduleRetry() { + if (this.retryTimer) return + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined + this.retryPending() + }, RETRY_DELAY) + } + + private cancelRetry() { + if (this.retryTimer) clearTimeout(this.retryTimer) + this.retryTimer = undefined + } +} + +function readFromStorage(): SessionSnapshot | undefined { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? sessionSnapshotSchema.parse(JSON.parse(raw)) : undefined +} + +function writeToStorage(snapshot: SessionSnapshot) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)) +} + +function storageError( + operation: SessionStorageError['operation'], + cause: unknown, +): SessionStorageError { + const message = cause instanceof Error ? cause.message : String(cause) + const kind = /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/schema.ts b/src/state/session/storage/schema.ts new file mode 100644 index 0000000000..67406aad8f --- /dev/null +++ b/src/state/session/storage/schema.ts @@ -0,0 +1,32 @@ +import {z} from 'zod' + +/** + * The durable, serializable portion of an account session. + * + * Keep this schema independent from the global persisted-state schema: session + * storage owns it, while the old persisted field imports it only for migration. + */ +export const sessionAccountSchema = z.object({ + service: z.string(), + did: z.string(), + handle: z.string(), + email: z.string().optional(), + emailConfirmed: z.boolean().optional(), + emailAuthFactor: z.boolean().optional(), + refreshJwt: z.string().optional(), + accessJwt: z.string().optional(), + signupQueued: z.boolean().optional(), + active: z.boolean().optional(), + status: z.string().optional(), + pdsUrl: z.string().optional(), + isSelfHosted: z.boolean().optional(), +}) + +export type SessionAccount = z.infer + +export const sessionSnapshotSchema = z.object({ + accounts: z.array(sessionAccountSchema), + currentDid: z.string().optional(), +}) + +export type SessionSnapshot = z.infer diff --git a/src/state/session/storage/types.ts b/src/state/session/storage/types.ts new file mode 100644 index 0000000000..6192a711c5 --- /dev/null +++ b/src/state/session/storage/types.ts @@ -0,0 +1,39 @@ +import {type SessionSnapshot} from './schema' + +export type SessionStorageErrorKind = + | 'unavailable' + | 'invalid-data' + | 'storage-full' + | 'write-failed' + +export type SessionStorageError = { + kind: SessionStorageErrorKind + operation: 'open' | 'commit' | '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 interface SessionRepository { + open(legacy?: SessionSnapshot): Promise + getSnapshot(): SessionSnapshot + commit( + previous: SessionSnapshot, + next: SessionSnapshot, + ): SessionStorageCommitResult + retryPending(): SessionStorageCommitResult + subscribe(listener: (snapshot: SessionSnapshot) => void): () => void + clear(): Promise +} diff --git a/src/state/session/types.ts b/src/state/session/types.ts index 8a9afba42c..949dc5a50d 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -1,7 +1,7 @@ -import {type PersistedAccount} from '#/state/persisted' import {type Metrics} from '#/analytics/metrics' +import {type SessionAccount} from './storage/schema' -export type SessionAccount = PersistedAccount +export type {SessionAccount} from './storage/schema' export type SessionStateContext = { accounts: SessionAccount[] diff --git a/src/state/session/util.ts b/src/state/session/util.ts index ea6d817f36..4b2e5d6cc1 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -3,13 +3,12 @@ import {jwtDecode} from 'jwt-decode' import {isJwtExpired} from '#/lib/jwt' import {hasProp} from '#/lib/type-guards' -import * as persisted from '#/state/persisted' import {sessionAccountToSession} from './agent' +import {type SessionSnapshot} from './storage' import {type SessionAccount} from './types' -export function readLastActiveAccount() { - const {currentAccount, accounts} = persisted.get('session') - return accounts.find(a => a.did === currentAccount?.did) +export function readLastActiveAccount(snapshot: SessionSnapshot) { + return snapshot.accounts.find(a => a.did === snapshot.currentDid) } export function isSignupQueued(accessJwt: string | undefined) {