move sessions to secure storage
This commit is contained in:
@@ -240,6 +240,7 @@ module.exports = function (_config) {
|
|||||||
plugins: [
|
plugins: [
|
||||||
'expo-video',
|
'expo-video',
|
||||||
'expo-localization',
|
'expo-localization',
|
||||||
|
'expo-secure-store',
|
||||||
'expo-web-browser',
|
'expo-web-browser',
|
||||||
[
|
[
|
||||||
'react-native-edge-to-edge',
|
'react-native-edge-to-edge',
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
"expo-paste-input": "^0.2.1",
|
"expo-paste-input": "^0.2.1",
|
||||||
"expo-privacy-sensitive": "^0.2.0",
|
"expo-privacy-sensitive": "^0.2.0",
|
||||||
"expo-screen-orientation": "~9.0.8",
|
"expo-screen-orientation": "~9.0.8",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sharing": "~14.0.8",
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-sms": "^14.0.7",
|
"expo-sms": "^14.0.7",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
|||||||
Generated
+12
@@ -514,6 +514,9 @@ importers:
|
|||||||
expo-screen-orientation:
|
expo-screen-orientation:
|
||||||
specifier: ~9.0.8
|
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))
|
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:
|
expo-sharing:
|
||||||
specifier: ~14.0.8
|
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))
|
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: '*'
|
expo: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-secure-store@15.0.8:
|
||||||
|
resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-server@1.0.7:
|
expo-server@1.0.7:
|
||||||
resolution: {integrity: sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==}
|
resolution: {integrity: sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==}
|
||||||
engines: {node: '>=20.16.0'}
|
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)
|
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)
|
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-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)):
|
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,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from '#/state/session'
|
} from '#/state/session'
|
||||||
|
import {
|
||||||
|
getSessionRepository,
|
||||||
|
initSessionRepository,
|
||||||
|
} from '#/state/session/storage'
|
||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
@@ -130,7 +134,7 @@ function InnerApp() {
|
|||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount(getSessionRepository().getSnapshot())
|
||||||
void onLaunch(account)
|
void onLaunch(account)
|
||||||
}, [resumeSession])
|
}, [resumeSession])
|
||||||
|
|
||||||
@@ -218,9 +222,36 @@ function App() {
|
|||||||
const [isReady, setIsReady] = useState(false)
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
let cancelled = false
|
||||||
() => setIsReady(true),
|
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) {
|
if (!isReady) {
|
||||||
|
|||||||
+34
-3
@@ -37,6 +37,10 @@ import {
|
|||||||
useSession,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from '#/state/session'
|
} from '#/state/session'
|
||||||
|
import {
|
||||||
|
getSessionRepository,
|
||||||
|
initSessionRepository,
|
||||||
|
} from '#/state/session/storage'
|
||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
@@ -109,7 +113,7 @@ function InnerApp() {
|
|||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount(getSessionRepository().getSnapshot())
|
||||||
void onLaunch(account)
|
void onLaunch(account)
|
||||||
}, [resumeSession])
|
}, [resumeSession])
|
||||||
|
|
||||||
@@ -197,9 +201,36 @@ function App() {
|
|||||||
const [isReady, setIsReady] = useState(false)
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
let cancelled = false
|
||||||
() => setIsReady(true),
|
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) {
|
if (!isReady) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declarat
|
|||||||
import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
|
import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
|
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||||
|
import {getSessionRepository} from '#/state/session/storage'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -406,7 +407,7 @@ function DevOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearAllStorage = async () => {
|
const clearAllStorage = async () => {
|
||||||
await clearStorage()
|
await Promise.all([clearStorage(), getSessionRepository().clear()])
|
||||||
Toast.show(l`Storage cleared, you need to restart the app now.`)
|
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 {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
|
||||||
import {findSupportedAppLanguage} from '#/locale/helpers'
|
import {findSupportedAppLanguage} from '#/locale/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {sessionAccountSchema} from '#/state/session/storage/schema'
|
||||||
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
const externalEmbedOptions = ['show', 'hide'] as const
|
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
|
* A account persisted to storage. Stored in the `accounts[]` array. Contains
|
||||||
* base account info and access tokens.
|
* base account info and access tokens.
|
||||||
*/
|
*/
|
||||||
const accountSchema = z.object({
|
const accountSchema = sessionAccountSchema
|
||||||
service: z.string(),
|
export type PersistedAccount = z.infer<typeof sessionAccountSchema>
|
||||||
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>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current account. Stored in the `currentAccount` field.
|
* The current account. Stored in the `currentAccount` field.
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": false,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -122,7 +120,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -175,7 +172,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -241,7 +237,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "bob-did",
|
"did": "bob-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -307,7 +302,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -386,7 +380,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "jay-did",
|
"did": "jay-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -460,7 +453,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -522,7 +514,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -570,7 +561,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -615,7 +605,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -687,7 +676,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "bob-did",
|
"did": "bob-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -795,7 +783,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -871,7 +858,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -925,7 +911,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -979,7 +964,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -1159,7 +1143,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "bob-did",
|
"did": "bob-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -1226,7 +1209,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "bob-did",
|
"did": "bob-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -1378,7 +1360,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "alice-did",
|
"did": "alice-did",
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -1444,7 +1425,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -1510,7 +1490,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": true,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -1582,7 +1561,6 @@ describe('session', () => {
|
|||||||
// Keep Bob logged in.
|
// Keep Bob logged in.
|
||||||
// (We patch up agent.session outside the reducer for this to work.)
|
// (We patch up agent.session outside the reducer for this to work.)
|
||||||
expect(state.currentAgentState.did).toBe('bob-did')
|
expect(state.currentAgentState.did).toBe('bob-did')
|
||||||
expect(state.needsPersist).toBe(false)
|
|
||||||
expect(printState(state)).toMatchInlineSnapshot(`
|
expect(printState(state)).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"accounts": [
|
"accounts": [
|
||||||
@@ -1623,7 +1601,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": "bob-did",
|
"did": "bob-did",
|
||||||
},
|
},
|
||||||
"needsPersist": false,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -1647,7 +1624,6 @@ describe('session', () => {
|
|||||||
// Log out because we have no matching user.
|
// Log out because we have no matching user.
|
||||||
// (In practice, we'll resume this session outside the reducer.)
|
// (In practice, we'll resume this session outside the reducer.)
|
||||||
expect(state.currentAgentState.did).toBe(undefined)
|
expect(state.currentAgentState.did).toBe(undefined)
|
||||||
expect(state.needsPersist).toBe(false)
|
|
||||||
expect(printState(state)).toMatchInlineSnapshot(`
|
expect(printState(state)).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"accounts": [
|
"accounts": [
|
||||||
@@ -1673,7 +1649,6 @@ describe('session', () => {
|
|||||||
},
|
},
|
||||||
"did": undefined,
|
"did": undefined,
|
||||||
},
|
},
|
||||||
"needsPersist": false,
|
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
@@ -1694,6 +1669,5 @@ function printState(state: State) {
|
|||||||
agent: {service: state.currentAgentState.agent.service},
|
agent: {service: state.currentAgentState.agent.service},
|
||||||
did: state.currentAgentState.did,
|
did: state.currentAgentState.did,
|
||||||
},
|
},
|
||||||
needsPersist: state.needsPersist,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-24
@@ -9,10 +9,11 @@ import {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
|
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 {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
@@ -25,6 +26,12 @@ import {
|
|||||||
sessionAccountToSession,
|
sessionAccountToSession,
|
||||||
} from './agent'
|
} from './agent'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
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'
|
export {isSignupQueued} from './util'
|
||||||
import {addSessionDebugLog} from './logging'
|
import {addSessionDebugLog} from './logging'
|
||||||
export type {SessionAccount} from '#/state/session/types'
|
export type {SessionAccount} from '#/state/session/types'
|
||||||
@@ -64,10 +71,12 @@ ApiContext.displayName = 'SessionApiContext'
|
|||||||
class SessionStore {
|
class SessionStore {
|
||||||
private state: State
|
private state: State
|
||||||
private listeners = new Set<() => void>()
|
private listeners = new Set<() => void>()
|
||||||
|
private storageErrorListeners = new Set<
|
||||||
|
(kind: SessionStorageErrorKind) => void
|
||||||
|
>()
|
||||||
|
|
||||||
constructor() {
|
constructor(private repository: SessionRepository) {
|
||||||
// Careful: By the time this runs, `persisted` needs to already be filled.
|
const initialState = getInitialState(repository.getSnapshot().accounts)
|
||||||
const initialState = getInitialState(persisted.get('session').accounts)
|
|
||||||
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
||||||
this.state = initialState
|
this.state = initialState
|
||||||
}
|
}
|
||||||
@@ -84,31 +93,69 @@ class SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch = (action: Action) => {
|
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
|
this.state = nextState
|
||||||
// Persist synchronously without waiting for the React render cycle.
|
const nextSnapshot: SessionSnapshot = {
|
||||||
if (nextState.needsPersist) {
|
|
||||||
nextState.needsPersist = false
|
|
||||||
const persistedData = {
|
|
||||||
accounts: nextState.accounts,
|
accounts: nextState.accounts,
|
||||||
currentAccount: nextState.accounts.find(
|
currentDid: nextState.currentAgentState.did,
|
||||||
a => a.did === nextState.currentAgentState.did,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
addSessionDebugLog({type: 'persisted:broadcast', data: nextSnapshot})
|
||||||
void persisted.write('session', persistedData)
|
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())
|
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<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
const ax = useAnalyticsBase()
|
const ax = useAnalyticsBase()
|
||||||
const cancelPendingTask = useOneTaskAtATime()
|
const cancelPendingTask = useOneTaskAtATime()
|
||||||
// eslint-disable-next-line react/hook-use-state
|
// 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 state = useSyncExternalStore(store.subscribe, store.getState)
|
||||||
const onboardingDispatch = useOnboardingDispatch()
|
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(
|
const onAgentSessionChange = useCallback(
|
||||||
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||||
@@ -311,16 +358,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask],
|
||||||
)
|
)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return persisted.onUpdate('session', nextSession => {
|
return repository.subscribe(synced => {
|
||||||
const synced = nextSession
|
|
||||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||||
store.dispatch({
|
store.sync(synced)
|
||||||
type: 'synced-accounts',
|
|
||||||
syncedAccounts: synced.accounts,
|
|
||||||
syncedCurrentDid: synced.currentAccount?.did,
|
|
||||||
})
|
|
||||||
const syncedAccount = synced.accounts.find(
|
const syncedAccount = synced.accounts.find(
|
||||||
a => a.did === synced.currentAccount?.did,
|
a => a.did === synced.currentDid,
|
||||||
)
|
)
|
||||||
if (syncedAccount && syncedAccount.refreshJwt) {
|
if (syncedAccount && syncedAccount.refreshJwt) {
|
||||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
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(
|
const stateContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
|
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
|
||||||
|
|
||||||
import {type Schema} from '../persisted'
|
|
||||||
import {type Action, type State} from './reducer'
|
import {type Action, type State} from './reducer'
|
||||||
|
import {type SessionSnapshot} from './storage'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
|
|
||||||
type Reducer = (state: State, action: Action) => State
|
type Reducer = (state: State, action: Action) => State
|
||||||
@@ -39,11 +39,11 @@ type Log =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:broadcast'
|
type: 'persisted:broadcast'
|
||||||
data: Schema['session']
|
data: SessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:receive'
|
type: 'persisted:receive'
|
||||||
data: Schema['session']
|
data: SessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent:switch'
|
type: 'agent:switch'
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type AgentState = {
|
|||||||
export type State = {
|
export type State = {
|
||||||
readonly accounts: SessionAccount[]
|
readonly accounts: SessionAccount[]
|
||||||
readonly currentAgentState: AgentState
|
readonly currentAgentState: AgentState
|
||||||
needsPersist: boolean // Mutated in an effect.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -72,7 +71,6 @@ export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
|||||||
return {
|
return {
|
||||||
accounts: persistedAccounts,
|
accounts: persistedAccounts,
|
||||||
currentAgentState: createPublicAgentState(),
|
currentAgentState: createPublicAgentState(),
|
||||||
needsPersist: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +119,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
currentAgentState: refreshedAccount
|
currentAgentState: refreshedAccount
|
||||||
? state.currentAgentState
|
? state.currentAgentState
|
||||||
: createPublicAgentState(), // Log out if expired.
|
: createPublicAgentState(), // Log out if expired.
|
||||||
needsPersist: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'switched-to-account': {
|
case 'switched-to-account': {
|
||||||
@@ -135,7 +132,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
did: newAccount.did,
|
did: newAccount.did,
|
||||||
agent: newAgent,
|
agent: newAgent,
|
||||||
},
|
},
|
||||||
needsPersist: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'removed-account': {
|
case 'removed-account': {
|
||||||
@@ -163,7 +159,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
state.currentAgentState.did === accountDid
|
state.currentAgentState.did === accountDid
|
||||||
? createPublicAgentState() // Log out if removing the current one.
|
? createPublicAgentState() // Log out if removing the current one.
|
||||||
: state.currentAgentState,
|
: state.currentAgentState,
|
||||||
needsPersist: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'logged-out-current-account': {
|
case 'logged-out-current-account': {
|
||||||
@@ -196,7 +191,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
: a,
|
: a,
|
||||||
),
|
),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentAgentState: createPublicAgentState(),
|
||||||
needsPersist: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'logged-out-every-account': {
|
case 'logged-out-every-account': {
|
||||||
@@ -217,7 +211,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
accessJwt: undefined,
|
accessJwt: undefined,
|
||||||
})),
|
})),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentAgentState: createPublicAgentState(),
|
||||||
needsPersist: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'synced-accounts': {
|
case 'synced-accounts': {
|
||||||
@@ -228,7 +221,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
syncedCurrentDid === state.currentAgentState.did
|
syncedCurrentDid === state.currentAgentState.did
|
||||||
? state.currentAgentState
|
? state.currentAgentState
|
||||||
: createPublicAgentState(), // Log out if different user.
|
: createPublicAgentState(), // Log out if different user.
|
||||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'partial-refresh-session': {
|
case 'partial-refresh-session': {
|
||||||
@@ -261,7 +253,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
}
|
}
|
||||||
return a
|
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 Metrics} from '#/analytics/metrics'
|
||||||
|
import {type SessionAccount} from './storage/schema'
|
||||||
|
|
||||||
export type SessionAccount = PersistedAccount
|
export type {SessionAccount} from './storage/schema'
|
||||||
|
|
||||||
export type SessionStateContext = {
|
export type SessionStateContext = {
|
||||||
accounts: SessionAccount[]
|
accounts: SessionAccount[]
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import {jwtDecode} from 'jwt-decode'
|
|||||||
|
|
||||||
import {isJwtExpired} from '#/lib/jwt'
|
import {isJwtExpired} from '#/lib/jwt'
|
||||||
import {hasProp} from '#/lib/type-guards'
|
import {hasProp} from '#/lib/type-guards'
|
||||||
import * as persisted from '#/state/persisted'
|
|
||||||
import {sessionAccountToSession} from './agent'
|
import {sessionAccountToSession} from './agent'
|
||||||
|
import {type SessionSnapshot} from './storage'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
|
|
||||||
export function readLastActiveAccount() {
|
export function readLastActiveAccount(snapshot: SessionSnapshot) {
|
||||||
const {currentAccount, accounts} = persisted.get('session')
|
return snapshot.accounts.find(a => a.did === snapshot.currentDid)
|
||||||
return accounts.find(a => a.did === currentAccount?.did)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSignupQueued(accessJwt: string | undefined) {
|
export function isSignupQueued(accessJwt: string | undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user