move sessions to secure storage

This commit is contained in:
Samuel Newman
2026-07-26 11:20:32 +03:00
parent 82c505f286
commit 54a12c1740
20 changed files with 1182 additions and 97 deletions
+1
View File
@@ -240,6 +240,7 @@ module.exports = function (_config) {
plugins: [
'expo-video',
'expo-localization',
'expo-secure-store',
'expo-web-browser',
[
'react-native-edge-to-edge',
+1
View File
@@ -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",
+12
View File
@@ -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)):
+34 -3
View File
@@ -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<typeof setTimeout> | 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) {
+34 -3
View File
@@ -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<typeof setTimeout> | 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) {
+2 -1
View File
@@ -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.`)
}
+3 -20
View File
@@ -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<typeof accountSchema>
const accountSchema = sessionAccountSchema
export type PersistedAccount = z.infer<typeof sessionAccountSchema>
/**
* The current account. Stored in the `currentAccount` field.
@@ -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,
}
}
+68 -26
View File
@@ -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(
() => ({
+3 -3
View File
@@ -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'
-9
View File
@@ -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,
}
}
}
@@ -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<string, string>()
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: [],
})
})
})
+46
View File
@@ -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'
+19
View File
@@ -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`,
}
}
+410
View File
@@ -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<typeof indexSchema>
type AccountDescriptor = Omit<SessionAccount, 'accessJwt' | 'refreshJwt'>
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<typeof setTimeout> | 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<SessionStorageLoadResult> {
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<void> {
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()
}
+195
View File
@@ -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<typeof setTimeout> | 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<SessionStorageLoadResult> {
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<void> {
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<unknown>) => {
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()
}
+32
View File
@@ -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<typeof sessionAccountSchema>
export const sessionSnapshotSchema = z.object({
accounts: z.array(sessionAccountSchema),
currentDid: z.string().optional(),
})
export type SessionSnapshot = z.infer<typeof sessionSnapshotSchema>
+39
View File
@@ -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<SessionStorageLoadResult>
getSnapshot(): SessionSnapshot
commit(
previous: SessionSnapshot,
next: SessionSnapshot,
): SessionStorageCommitResult
retryPending(): SessionStorageCommitResult
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void
clear(): Promise<void>
}
+2 -2
View File
@@ -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[]
+3 -4
View File
@@ -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) {