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