move sessions to secure storage
This commit is contained in:
@@ -240,6 +240,7 @@ module.exports = function (_config) {
|
||||
plugins: [
|
||||
'expo-video',
|
||||
'expo-localization',
|
||||
'expo-secure-store',
|
||||
'expo-web-browser',
|
||||
[
|
||||
'react-native-edge-to-edge',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+12
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,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
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user