fable rewrite

This commit is contained in:
Samuel Newman
2026-07-28 15:39:43 +03:00
parent 767e9f1f51
commit 8d859370e0
14 changed files with 1341 additions and 992 deletions
+4 -45
View File
@@ -13,6 +13,7 @@ import * as SystemUI from 'expo-system-ui'
import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native'
import {useAppBootstrap} from '#/lib/hooks/useAppBootstrap'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext'
@@ -31,7 +32,6 @@ import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -44,10 +44,7 @@ import {
useSession,
useSessionApi,
} from '#/state/session'
import {
getSessionRepository,
initSessionRepository,
} from '#/state/session/storage'
import {getSessionRepository} from '#/state/session/storage'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
@@ -74,12 +71,7 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {AnalyticsContext, AnalyticsFeaturesContext, features} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {
prefetchLiveEvents,
@@ -219,40 +211,7 @@ function InnerApp() {
}
function App() {
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)
}
}, [])
const isReady = useAppBootstrap()
if (!isReady) {
return null
+4 -45
View File
@@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -37,10 +36,7 @@ import {
useSession,
useSessionApi,
} from '#/state/session'
import {
getSessionRepository,
initSessionRepository,
} from '#/state/session/storage'
import {getSessionRepository} from '#/state/session/storage'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
@@ -67,12 +63,7 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {AnalyticsContext, AnalyticsFeaturesContext, features} from '#/analytics'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -80,6 +71,7 @@ import {
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {useAppBootstrap} from './lib/hooks/useAppBootstrap'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
/**
@@ -198,40 +190,7 @@ function InnerApp() {
}
function App() {
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)
}
}, [])
const isReady = useAppBootstrap()
if (!isReady) {
return null
+53
View File
@@ -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
}
+4 -27
View File
@@ -31,7 +31,6 @@ import {
type SessionRepository,
type SessionSnapshot,
} from './storage'
import {type SessionStorageErrorKind} from './storage/types'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
@@ -71,9 +70,6 @@ ApiContext.displayName = 'SessionApiContext'
class SessionStore {
private state: State
private listeners = new Set<() => void>()
private storageErrorListeners = new Set<
(kind: SessionStorageErrorKind) => void
>()
constructor(private repository: SessionRepository) {
const initialState = getInitialState(repository.getSnapshot().accounts)
@@ -102,17 +98,7 @@ class SessionStore {
currentDid: nextState.currentAgentState.did,
}
addSessionDebugLog({type: 'persisted:broadcast', data: nextSnapshot})
const result = this.repository.commit(
this.repository.getSnapshot(),
nextSnapshot,
)
void Promise.resolve(result).then(commitResult => {
if (commitResult.status === 'pending') {
this.storageErrorListeners.forEach(listener =>
listener(commitResult.error.kind),
)
}
})
this.repository.write(nextSnapshot)
this.listeners.forEach(listener => listener())
}
@@ -124,15 +110,6 @@ class SessionStore {
})
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<{}>) {
@@ -148,8 +125,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const showedStorageFullWarning = useRef(false)
useEffect(() => {
return store.subscribeStorageErrors(kind => {
if (kind === 'storage-full' && !showedStorageFullWarning.current) {
return repository.onWriteFailure(error => {
if (error.kind === 'storage-full' && !showedStorageFullWarning.current) {
showedStorageFullWarning.current = true
Toast.show(
l`We couldn't save your login. Free up some device storage and keep the app open while we retry.`,
@@ -157,7 +134,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
}
})
}, [l, store])
}, [l, repository])
const onAgentSessionChange = useCallback(
(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 {NativeSessionRepository} from '../repository'
import {type SessionAccount, type SessionSnapshot} from '../schema'
import {type SessionStorageError} from '../types'
const mockValues = new Map<string, string>()
let mockFailKey: string | undefined
@@ -20,6 +21,8 @@ jest.mock('#/lib/appState', () => ({
onAppStateChange: jest.fn(() => ({remove: jest.fn()})),
}))
const RETRY_DELAY = 5_000
const alice: SessionAccount = {
service: 'https://bsky.social',
did: 'did:plc:alice',
@@ -28,26 +31,42 @@ const alice: SessionAccount = {
accessJwt: 'alice-access',
}
const EMPTY: SessionSnapshot = {accounts: [], currentDid: undefined}
beforeEach(() => {
jest.useFakeTimers()
mockValues.clear()
mockFailKey = undefined
mockSetItem.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', () => {
it('migrates credentials synchronously and publishes the index last', async () => {
const repository = new NativeSessionRepository()
const onDurable = jest.fn()
const legacy: SessionSnapshot = {
accounts: [alice],
currentDid: alice.did,
}
await expect(repository.open(legacy)).resolves.toMatchObject({
status: 'ready',
shouldScrubLegacy: true,
})
await expect(repository.init(legacy, onDurable)).resolves.toEqual(legacy)
expect(onDurable).toHaveBeenCalledTimes(1)
const keys = accountKeys(alice.did)
expect(mockSetItem.mock.calls.map(([key]) => key)).toEqual([
@@ -66,38 +85,49 @@ describe('NativeSessionRepository', () => {
JSON.stringify({version: 1, dids: [], currentDid: undefined}),
)
const repository = new NativeSessionRepository()
const onDurable = jest.fn()
await expect(
repository.open({accounts: [alice], currentDid: alice.did}),
).resolves.toEqual({
status: 'ready',
snapshot: {accounts: [], currentDid: undefined},
shouldScrubLegacy: true,
})
repository.init({accounts: [alice], currentDid: alice.did}, onDurable),
).resolves.toEqual(EMPTY)
expect(onDurable).toHaveBeenCalledTimes(1)
expect(mockSetItem).not.toHaveBeenCalled()
})
it('repairs invalid data without resurrecting a legacy snapshot', async () => {
mockValues.set(SESSION_INDEX_KEY, '{invalid json')
const repository = new NativeSessionRepository()
const onDurable = jest.fn()
await expect(
repository.open({accounts: [alice], currentDid: alice.did}),
).resolves.toMatchObject({
status: 'ready',
snapshot: {accounts: [], currentDid: undefined},
shouldScrubLegacy: true,
})
repository.init({accounts: [alice], currentDid: alice.did}, onDurable),
).resolves.toEqual(EMPTY)
expect(onDurable).toHaveBeenCalledTimes(1)
})
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()
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)
mockFailKey = keys.access
const first = {accounts: [alice], currentDid: alice.did}
expect(repository.commit({accounts: []}, first).status).toBe('pending')
repository.write({accounts: [alice], currentDid: alice.did})
expect(failures.length).toBe(1)
const refreshed = {
accounts: [
@@ -105,11 +135,12 @@ describe('NativeSessionRepository', () => {
],
currentDid: alice.did,
}
expect(repository.commit(first, refreshed).status).toBe('pending')
repository.write(refreshed)
expect(failures.length).toBe(2)
expect(repository.getSnapshot()).toEqual(refreshed)
mockFailKey = undefined
expect(repository.retryPending()).toEqual({status: 'committed'})
jest.advanceTimersByTime(RETRY_DELAY)
expect(mockValues.get(keys.refresh)).toBe('new-refresh')
expect(mockValues.get(keys.access)).toBe('new-access')
expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({
@@ -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 active = {accounts: [alice], currentDid: alice.did}
await repository.open(active)
await repository.init(active, jest.fn())
mockSetItem.mockClear()
const loggedOut = {
accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}],
currentDid: undefined,
}
expect(repository.commit(active, loggedOut)).toEqual({status: 'committed'})
repository.write(loggedOut)
const keys = accountKeys(alice.did)
expect(mockSetItem.mock.calls).toEqual([
@@ -137,7 +168,7 @@ describe('NativeSessionRepository', () => {
SESSION_INDEX_KEY,
JSON.stringify({
version: 1,
currentDid: undefined,
currentDid: alice.did,
dids: [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 {
accessJwt: _accessJwt,
@@ -171,17 +202,14 @@ describe('NativeSessionRepository', () => {
)
const repository = new NativeSessionRepository()
await expect(repository.open()).resolves.toMatchObject({
status: 'ready',
snapshot: {
accounts: [
expect.objectContaining({
did: alice.did,
refreshJwt: undefined,
accessJwt: undefined,
}),
],
},
await expect(repository.init(EMPTY, jest.fn())).resolves.toMatchObject({
accounts: [
expect.objectContaining({
did: alice.did,
refreshJwt: undefined,
accessJwt: undefined,
}),
],
})
expect(mockValues.get(keys.access)).toBe('')
expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({
@@ -193,11 +221,10 @@ describe('NativeSessionRepository', () => {
it('tombstones a removed account without racing a later re-add', async () => {
const repository = new NativeSessionRepository()
const active = {accounts: [alice], currentDid: alice.did}
await repository.open(active)
await repository.init(active, jest.fn())
mockSetItem.mockClear()
const removed = {accounts: [], currentDid: undefined}
expect(repository.commit(active, removed)).toEqual({status: 'committed'})
repository.write(EMPTY)
const keys = accountKeys(alice.did)
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.access)).toBe(alice.accessJwt)
expect(JSON.parse(mockValues.get(keys.descriptor)!)).toMatchObject({
@@ -242,7 +269,7 @@ describe('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.access)).toBe('')
@@ -256,12 +283,10 @@ describe('NativeSessionRepository', () => {
it('clears accounts known only to the last durable snapshot', async () => {
const repository = new NativeSessionRepository()
const active = {accounts: [alice], currentDid: alice.did}
await repository.open(active)
await repository.init(active, jest.fn())
mockFailKey = SESSION_INDEX_KEY
expect(
repository.commit(active, {accounts: [], currentDid: undefined}).status,
).toBe('pending')
repository.write(EMPTY)
mockFailKey = undefined
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 active = {accounts: [alice], currentDid: alice.did}
await repository.open(active)
await repository.init(active, jest.fn())
const keys = accountKeys(alice.did)
mockFailKey = keys.access
expect(
repository.commit(active, {
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
}).status,
).toBe('pending')
repository.write({
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
})
mockFailKey = SESSION_INDEX_KEY
await expect(repository.clear()).rejects.toThrow('disk full')
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
expect(repository.getSnapshot()).toEqual(EMPTY)
expect(repository.commit(active, active).status).toBe('pending')
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
repository.write(active)
expect(repository.getSnapshot()).toEqual(EMPTY)
mockFailKey = undefined
expect(repository.retryPending()).toEqual({status: 'committed'})
jest.advanceTimersByTime(RETRY_DELAY)
expect(mockValues.get(keys.refresh)).toBe('')
expect(mockValues.get(keys.access)).toBe('')
expect(mockValues.get(keys.descriptor)).toBe('')
@@ -316,26 +333,16 @@ describe('NativeSessionRepository', () => {
it('tombstones credentials from a superseded partial account write', async () => {
const repository = new NativeSessionRepository()
await repository.open()
await repository.init(EMPTY, jest.fn())
const keys = accountKeys(alice.did)
mockFailKey = SESSION_INDEX_KEY
expect(
repository.commit(
{accounts: []},
{accounts: [alice], currentDid: alice.did},
).status,
).toBe('pending')
repository.write({accounts: [alice], currentDid: alice.did})
expect(mockValues.get(keys.refresh)).toBe(alice.refreshJwt)
expect(mockValues.get(keys.access)).toBe(alice.accessJwt)
mockFailKey = undefined
expect(
repository.commit(
{accounts: [alice], currentDid: alice.did},
{accounts: [], currentDid: undefined},
),
).toEqual({status: 'committed'})
repository.write(EMPTY)
expect(mockValues.get(keys.refresh)).toBe('')
expect(mockValues.get(keys.access)).toBe('')
expect(mockValues.get(keys.descriptor)).toBe('')
@@ -4,6 +4,7 @@ import {WebSessionRepository} from '../repository.web'
import {type SessionAccount, type SessionSnapshot} from '../schema'
const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1'
const RETRY_DELAY = 5_000
const values = new Map<string, string>()
let failRead = false
let failWrite = false
@@ -54,6 +55,8 @@ const charlie: SessionAccount = {
accessJwt: 'charlie-access',
}
const EMPTY: SessionSnapshot = {accounts: [], currentDid: undefined}
beforeEach(() => {
jest.useFakeTimers()
values.clear()
@@ -92,15 +95,14 @@ describe('WebSessionRepository', () => {
it('repairs corrupt storage and starts logged out', async () => {
values.set(STORAGE_KEY, '{invalid json')
const repository = new WebSessionRepository()
const onDurable = jest.fn()
await expect(
repository.open({accounts: [alice], currentDid: alice.did}),
).resolves.toEqual({
status: 'ready',
snapshot: {accounts: [], currentDid: undefined},
shouldScrubLegacy: true,
})
expect(readStoredSnapshot()).toEqual({accounts: []})
repository.init({accounts: [alice], currentDid: alice.did}, onDurable),
).resolves.toEqual(EMPTY)
await repository.whenSettled()
expect(onDurable).toHaveBeenCalledTimes(1)
expect(readStoredSnapshot()).toEqual(EMPTY)
})
it('keeps a legacy session in memory when localStorage is unavailable', async () => {
@@ -108,12 +110,11 @@ describe('WebSessionRepository', () => {
failWrite = true
const repository = new WebSessionRepository()
const legacy = {accounts: [alice], currentDid: alice.did}
const onDurable = jest.fn()
await expect(repository.open(legacy)).resolves.toEqual({
status: 'ready',
snapshot: legacy,
shouldScrubLegacy: false,
})
await expect(repository.init(legacy, onDurable)).resolves.toEqual(legacy)
await repository.whenSettled()
expect(onDurable).not.toHaveBeenCalled()
expect(repository.getSnapshot()).toEqual(legacy)
})
@@ -123,7 +124,7 @@ describe('WebSessionRepository', () => {
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
await repository.init(previous, jest.fn())
const latest: SessionSnapshot = {
accounts: [
@@ -135,13 +136,11 @@ describe('WebSessionRepository', () => {
}
values.set(STORAGE_KEY, JSON.stringify(latest))
const next: SessionSnapshot = {
repository.write({
accounts: [{...alice, emailConfirmed: true}],
currentDid: alice.did,
}
await expect(repository.commit(previous, next)).resolves.toEqual({
status: 'committed',
})
await repository.whenSettled()
expect(readStoredSnapshot()).toEqual({
accounts: [
@@ -164,19 +163,18 @@ describe('WebSessionRepository', () => {
}
const firstTab = new WebSessionRepository()
const secondTab = new WebSessionRepository()
await firstTab.open(previous)
await secondTab.open(previous)
await firstTab.init(previous, jest.fn())
await secondTab.init(previous, jest.fn())
await Promise.all([
firstTab.commit(previous, {
accounts: [{...alice, emailConfirmed: true}, bob],
currentDid: alice.did,
}),
secondTab.commit(previous, {
accounts: [alice, {...bob, handle: 'new-bob.test'}],
currentDid: alice.did,
}),
])
firstTab.write({
accounts: [{...alice, emailConfirmed: true}, bob],
currentDid: alice.did,
})
secondTab.write({
accounts: [alice, {...bob, handle: 'new-bob.test'}],
currentDid: alice.did,
})
await Promise.all([firstTab.whenSettled(), secondTab.whenSettled()])
expect(readStoredSnapshot()).toEqual({
accounts: [
@@ -197,7 +195,7 @@ describe('WebSessionRepository', () => {
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
await repository.init(previous, jest.fn())
values.set(
STORAGE_KEY,
@@ -206,12 +204,13 @@ describe('WebSessionRepository', () => {
currentDid: undefined,
}),
)
await repository.commit(previous, {
repository.write({
accounts: [
{...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'},
],
currentDid: alice.did,
})
await repository.whenSettled()
const {
refreshJwt: _refreshJwt,
@@ -225,8 +224,7 @@ describe('WebSessionRepository', () => {
it('preserves credential revocation during concurrent account adds', async () => {
const repository = new WebSessionRepository()
const previous: SessionSnapshot = {accounts: [], currentDid: undefined}
await repository.open(previous)
await repository.init(EMPTY, jest.fn())
const {
refreshJwt: _refreshJwt,
@@ -238,10 +236,11 @@ describe('WebSessionRepository', () => {
JSON.stringify({accounts: [loggedOutAlice], currentDid: undefined}),
)
await repository.commit(previous, {
repository.write({
accounts: [alice],
currentDid: alice.did,
})
await repository.whenSettled()
expect(readStoredSnapshot()).toEqual({
accounts: [loggedOutAlice],
currentDid: alice.did,
@@ -254,74 +253,96 @@ describe('WebSessionRepository', () => {
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
await repository.init(previous, jest.fn())
values.set(
STORAGE_KEY,
JSON.stringify({accounts: [bob], currentDid: bob.did}),
)
const editedAlice = {...alice, emailConfirmed: true}
const next: SessionSnapshot = {
accounts: [editedAlice, bob],
repository.write({
accounts: [{...alice, emailConfirmed: true}, bob],
currentDid: alice.did,
}
await expect(repository.commit(previous, next)).resolves.toEqual({
status: 'committed',
})
await repository.whenSettled()
expect(readStoredSnapshot()).toEqual({
accounts: [bob],
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 = {
accounts: [alice],
currentDid: alice.did,
}
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
await expect(
repository.commit(previous, {
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
}),
).resolves.toMatchObject({status: 'pending'})
repository.write({
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
})
await repository.whenSettled()
await expect(repository.clear()).rejects.toThrow(
'session storage clear failed: write-failed',
)
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
expect(repository.getSnapshot()).toEqual(EMPTY)
await expect(repository.commit(previous, previous)).resolves.toMatchObject({
status: 'pending',
})
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
repository.write(previous)
await repository.whenSettled()
expect(repository.getSnapshot()).toEqual(EMPTY)
failWrite = false
await expect(repository.retryPending()).resolves.toEqual({
status: 'committed',
})
expect(readStoredSnapshot()).toEqual({accounts: []})
expect(repository.getSnapshot()).toEqual({accounts: []})
jest.advanceTimersByTime(RETRY_DELAY)
await repository.whenSettled()
expect(readStoredSnapshot()).toEqual(EMPTY)
expect(repository.getSnapshot()).toEqual(EMPTY)
})
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
const repository = new WebSessionRepository()
const legacy = {accounts: [alice], currentDid: alice.did}
const onLegacyMigrationComplete = jest.fn()
await repository.open(legacy, onLegacyMigrationComplete)
expect(onLegacyMigrationComplete).not.toHaveBeenCalled()
const onDurable = jest.fn()
await repository.init(legacy, onDurable)
await repository.whenSettled()
expect(onDurable).not.toHaveBeenCalled()
const refreshed = {
accounts: [{...alice, accessJwt: 'fresh-access'}],
@@ -330,12 +351,11 @@ describe('WebSessionRepository', () => {
failWrite = false
values.set(STORAGE_KEY, JSON.stringify(refreshed))
await expect(repository.retryPending()).resolves.toEqual({
status: 'committed',
})
jest.advanceTimersByTime(RETRY_DELAY)
await repository.whenSettled()
expect(readStoredSnapshot()).toEqual(refreshed)
expect(repository.getSnapshot()).toEqual(refreshed)
expect(onLegacyMigrationComplete).toHaveBeenCalledTimes(1)
expect(onDurable).toHaveBeenCalledTimes(1)
})
})
+44
View File
@@ -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,
},
})
}
+17 -16
View File
@@ -5,6 +5,11 @@ import {type SessionSnapshot} from './schema'
const repository = createSessionRepository()
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() {
if (initialized) return repository
@@ -18,23 +23,19 @@ export async function initSessionRepository() {
? legacyCurrentDid
: 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) {
// The new repository has been read back successfully. Scrub the old blob
// so future preference writes cannot keep rewriting bearer credentials.
await scrubLegacy()
}
await repository.init(legacySnapshot, () => {
// Fires once the new store is known durable. Scrub the old blob so future
// preference writes cannot keep rewriting bearer credentials. Only needed
// when the legacy location actually held accounts.
if (legacySnapshot.accounts.length > 0) {
void persisted.write('session', {
accounts: [],
currentAccount: undefined,
})
}
})
initialized = true
return repository
}
+165
View File
@@ -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))
}
+157 -372
View File
@@ -1,167 +1,109 @@
import * as SecureStore from 'expo-secure-store'
import {z} from 'zod'
import {onAppStateChange} from '#/lib/appState'
import {logger} from '#/logger'
import {accountKeys, SESSION_INDEX_KEY} from './keys'
import {
type SessionAccount,
sessionAccountSchema,
type SessionSnapshot,
} from './schema'
InvalidSessionStorageDataError,
logStorageError,
storageError,
} from './errors'
import {type SessionSnapshot} from './schema'
import {
type SessionRepository,
type SessionStorageCommitResult,
type SessionStorageError,
type SessionStorageLoadResult,
} from './types'
const indexSchema = z.object({
version: z.literal(1),
currentDid: z.string().optional(),
dids: z.array(z.string()),
retiredDids: z.array(z.string()).optional(),
revokedDids: z.array(z.string()).optional(),
})
const descriptorSchema = sessionAccountSchema.omit({
accessJwt: true,
refreshJwt: true,
})
type StoredIndex = z.infer<typeof indexSchema>
type AccountDescriptor = Omit<SessionAccount, 'accessJwt' | 'refreshJwt'>
eraseSessions,
indexExists,
readSessions,
writeSessions,
} from './secureStore'
import {type SessionRepository, type SessionStorageError} from './types'
const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined}
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 {
private snapshot: SessionSnapshot = EMPTY_SNAPSHOT
private persistedSnapshot: SessionSnapshot = EMPTY_SNAPSHOT
private hasPersistedIndex = false
private pendingSnapshot: SessionSnapshot | undefined
private pendingClearDids: string[] | undefined
private possiblyWrittenDids = new Set<string>()
private durableSnapshot: SessionSnapshot = EMPTY_SNAPSHOT
private pending: PendingWork | undefined
private maybeOrphanedDids = new Set<string>()
private retryTimer: ReturnType<typeof setTimeout> | undefined
private listeners = new Set<(snapshot: SessionSnapshot) => void>()
constructor() {
onAppStateChange(state => {
if (
state === 'active' &&
(this.pendingSnapshot || this.pendingClearDids)
) {
this.retryPending()
}
})
}
private subscribers = new Set<(snapshot: SessionSnapshot) => void>()
private writeFailureListeners = new Set<
(error: SessionStorageError) => void
>()
private retryTriggersAttached = false
// async to keep one repository contract across native and web.
// eslint-disable-next-line @typescript-eslint/require-await
async open(legacy?: SessionSnapshot): Promise<SessionStorageLoadResult> {
async init(
legacy: SessionSnapshot,
onDurable: () => void,
): Promise<SessionSnapshot> {
try {
const rawIndex = SecureStore.getItem(SESSION_INDEX_KEY)
if (rawIndex !== null) {
let snapshot: SessionSnapshot
if (indexExists()) {
try {
snapshot = this.readSnapshot(rawIndex)
this.setDurable(readSessions())
} catch (cause) {
if (!(cause instanceof InvalidSessionStorageDataError)) throw cause
logStorageError({kind: 'invalid-data', operation: 'open'})
this.hasPersistedIndex = false
this.snapshot = EMPTY_SNAPSHOT
this.persistedSnapshot = EMPTY_SNAPSHOT
// Index presence proves migration previously reached its commit
// point. Never resurrect possibly stale credentials from the legacy
// blob when repairing corrupt new-format data.
return this.initializeSnapshot(
EMPTY_SNAPSHOT,
Boolean(legacy?.accounts.length),
)
}
this.hasPersistedIndex = true
this.snapshot = snapshot
this.persistedSnapshot = snapshot
this.pendingSnapshot = undefined
this.cancelRetry()
return {
status: 'ready',
snapshot,
shouldScrubLegacy: Boolean(legacy?.accounts.length),
logStorageError({kind: 'invalid-data', operation: 'init'})
// The index proves migration previously reached its commit point.
// Repair to a clean empty state rather than resurrect possibly stale
// credentials from the legacy blob.
this.setDurable(this.migrate(EMPTY_SNAPSHOT))
}
} else {
this.setDurable(this.migrate(legacy))
}
return this.initializeSnapshot(legacy ?? EMPTY_SNAPSHOT)
} catch (cause) {
const error = storageError('open', cause)
const error = storageError('init', cause)
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 {
return this.snapshot
}
commit(
_previous: SessionSnapshot,
next: SessionSnapshot,
): SessionStorageCommitResult {
if (this.pendingClearDids) {
write(next: SessionSnapshot): void {
if (this.pending?.type === 'clear') {
// A requested wipe is still settling. Drop writes so we never resurrect
// a session after clear(); the pending clear stays authoritative.
this.snapshot = EMPTY_SNAPSHOT
return this.retryPending()
return
}
this.snapshot = next
this.trackPossiblyWrittenAccounts(next)
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}
}
this.persist(next, 'write')
}
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void {
this.listeners.add(listener)
this.subscribers.add(listener)
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> {
const dids = [
...new Set([
...this.persistedSnapshot.accounts.map(account => account.did),
...this.durableSnapshot.accounts.map(account => account.did),
...this.snapshot.accounts.map(account => account.did),
...this.possiblyWrittenDids,
...this.maybeOrphanedDids,
]),
]
this.snapshot = EMPTY_SNAPSHOT
this.pendingSnapshot = undefined
this.pendingClearDids = dids
this.pending = {type: 'clear', dids}
this.cancelRetry()
try {
this.writeClear(dids)
this.persistedSnapshot = EMPTY_SNAPSHOT
this.hasPersistedIndex = true
this.pendingClearDids = undefined
this.possiblyWrittenDids.clear()
this.cancelRetry()
eraseSessions(dids)
this.durableSnapshot = EMPTY_SNAPSHOT
this.maybeOrphanedDids.clear()
this.pending = undefined
} catch (cause) {
const error = storageError('clear', cause)
logStorageError(error)
this.emitFailure(error)
this.scheduleRetry()
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 {
index = indexSchema.parse(JSON.parse(rawIndex))
} catch {
throw new InvalidSessionStorageDataError()
}
if (index.revokedDids?.length) {
const activeDids = new Set(index.dids)
index.revokedDids
.filter(did => activeDids.has(did))
.forEach(tombstoneCredentials)
}
if (index.retiredDids?.length) {
const activeDids = new Set(index.dids)
index.retiredDids
.filter(did => !activeDids.has(did))
.forEach(tombstoneAccount)
}
if (index.revokedDids?.length || index.retiredDids?.length) {
const cleanedIndex = {
version: index.version,
currentDid: index.currentDid,
dids: index.dids,
} satisfies StoredIndex
SecureStore.setItem(SESSION_INDEX_KEY, JSON.stringify(cleanedIndex))
index = cleanedIndex
}
if (index.currentDid && !index.dids.includes(index.currentDid)) {
throw new InvalidSessionStorageDataError()
}
const accounts = index.dids.map(did => {
const keys = accountKeys(did)
const rawDescriptor = SecureStore.getItem(keys.descriptor)
if (rawDescriptor === null) {
throw new InvalidSessionStorageDataError()
}
let descriptor: AccountDescriptor
try {
descriptor = descriptorSchema.parse(JSON.parse(rawDescriptor))
} catch {
throw new InvalidSessionStorageDataError()
}
if (descriptor.did !== did) {
throw new InvalidSessionStorageDataError()
}
return {
...descriptor,
refreshJwt: SecureStore.getItem(keys.refresh) || undefined,
accessJwt: SecureStore.getItem(keys.access) || undefined,
}
})
return {accounts, currentDid: index.currentDid}
}
private writeSnapshot(previous: SessionSnapshot, next: SessionSnapshot) {
const previousByDid = new Map(previous.accounts.map(a => [a.did, a]))
const nextDids = new Set(next.accounts.map(a => a.did))
const retiredDids = [
...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)
)
writeSessions(this.durableSnapshot, next, {
alsoRetire: [...this.maybeOrphanedDids],
})
.map(account => account.did)
if (revokedDids.length) {
// Journal retained-account logout before clearing either credential.
// On interruption, open() finishes the tombstoning before loading.
SecureStore.setItem(
SESSION_INDEX_KEY,
JSON.stringify(toStoredIndex(next, retiredDids, revokedDids)),
)
}
// Credentials go first. AtpAgent does not await its persistence callback,
// so these must complete synchronously before the app can be suspended.
for (const account of next.accounts) {
const prior = previousByDid.get(account.did)
const keys = accountKeys(account.did)
if (prior?.refreshJwt !== account.refreshJwt) {
SecureStore.setItem(keys.refresh, account.refreshJwt ?? '')
}
if (prior?.accessJwt !== account.accessJwt) {
SecureStore.setItem(keys.access, account.accessJwt ?? '')
}
const descriptor = toDescriptor(account)
if (JSON.stringify(toDescriptor(prior)) !== JSON.stringify(descriptor)) {
SecureStore.setItem(keys.descriptor, JSON.stringify(descriptor))
}
}
if (
!this.hasPersistedIndex ||
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)),
)
this.durableSnapshot = next
this.maybeOrphanedDids.clear()
this.pending = undefined
this.cancelRetry()
} catch (cause) {
this.pending = {type: 'write', snapshot: next}
const error = storageError(operation, cause)
logStorageError(error)
this.emitFailure(error)
this.scheduleRetry()
}
}
private writeClear(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)),
)
private retry() {
if (!this.pending) return
if (this.pending.type === 'clear') {
const {dids} = this.pending
try {
eraseSessions(dids)
this.durableSnapshot = EMPTY_SNAPSHOT
this.snapshot = 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(
this.persistedSnapshot.accounts.map(account => account.did),
/**
* Remember dids we may have partially written under a did the durable index
* 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) {
if (!persistedDids.has(account.did)) {
this.possiblyWrittenDids.add(account.did)
if (!durableDids.has(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() {
if (this.retryTimer) return
this.retryTimer = setTimeout(() => {
this.retryTimer = undefined
this.retryPending()
this.retry()
}, RETRY_DELAY)
}
@@ -352,104 +235,6 @@ export class NativeSessionRepository implements SessionRepository {
if (this.retryTimer) clearTimeout(this.retryTimer)
this.retryTimer = undefined
}
private initializeSnapshot(
snapshot: SessionSnapshot,
shouldScrubLegacy = Boolean(snapshot.accounts.length),
): SessionStorageLoadResult {
const result = this.commit(EMPTY_SNAPSHOT, snapshot)
if (result.status === 'pending') {
return {status: 'unavailable', error: result.error}
}
// Migration/recovery is complete only after every item reads back and
// validates. The index also marks an intentionally empty session.
const storedIndex = SecureStore.getItem(SESSION_INDEX_KEY)
if (storedIndex === null) {
return {
status: 'unavailable',
error: {kind: 'unavailable', operation: 'open'},
}
}
const verified = this.readSnapshot(storedIndex)
this.snapshot = verified
this.persistedSnapshot = verified
this.hasPersistedIndex = true
return {
status: 'ready',
snapshot: verified,
shouldScrubLegacy,
}
}
}
class InvalidSessionStorageDataError extends Error {}
function toDescriptor(
account: SessionAccount | undefined,
): AccountDescriptor | undefined {
if (!account) return undefined
const {
accessJwt: _accessJwt,
refreshJwt: _refreshJwt,
...descriptor
} = account
return descriptor
}
function toStoredIndex(
snapshot: SessionSnapshot,
retiredDids: string[] = [],
revokedDids: string[] = [],
): StoredIndex {
return {
version: 1,
currentDid: snapshot.currentDid,
dids: snapshot.accounts.map(account => account.did),
...(retiredDids.length ? {retiredDids} : {}),
...(revokedDids.length ? {revokedDids} : {}),
}
}
function tombstoneAccount(did: string) {
tombstoneCredentials(did)
const keys = accountKeys(did)
SecureStore.setItem(keys.descriptor, '')
}
function tombstoneCredentials(did: string) {
const keys = accountKeys(did)
SecureStore.setItem(keys.refresh, '')
SecureStore.setItem(keys.access, '')
}
function storageError(
operation: SessionStorageError['operation'],
cause: unknown,
): SessionStorageError {
const message = cause instanceof Error ? cause.message : String(cause)
const kind =
cause instanceof InvalidSessionStorageDataError
? 'invalid-data'
: /quota|disk.*full|storage.*full|no space/i.test(message)
? 'storage-full'
: operation === 'open'
? 'unavailable'
: 'write-failed'
return {kind, operation}
}
function logStorageError(error: SessionStorageError) {
// Never attach the underlying native error: some platforms include the key
// in it. Keys are hashed, but keeping telemetry credential-agnostic is safer.
logger.error('session storage operation failed', {
kind: error.kind,
operation: error.operation,
tags: {
session_storage_kind: error.kind,
session_storage_operation: error.operation,
},
})
}
export function createSessionRepository(): SessionRepository {
+208 -306
View File
@@ -1,19 +1,14 @@
import {createStore, update} from 'idb-keyval'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {
type SessionAccount,
sessionAccountSchema,
type SessionSnapshot,
sessionSnapshotSchema,
} from './schema'
import {
type SessionRepository,
type SessionStorageCommitResult,
type SessionStorageError,
type SessionStorageLoadResult,
} from './types'
InvalidSessionStorageDataError,
logStorageError,
storageError,
} from './errors'
import {mergeSnapshots} from './merge'
import {type SessionSnapshot, sessionSnapshotSchema} from './schema'
import {type SessionRepository, type SessionStorageError} from './types'
const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1'
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 RETRY_DELAY = 5_000
type PendingCommit = {
previous: SessionSnapshot
next: SessionSnapshot
replace?: boolean
}
/**
* A unit of pending work. A write carries its merge base so a retry rebases
* against the same base even after other tabs have moved storage. A clear is a
* 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 {
private snapshot: SessionSnapshot = EMPTY_SNAPSHOT
private pendingCommit: PendingCommit | undefined
private activeOperations = new Map<
PendingCommit,
Promise<SessionStorageCommitResult>
private base: SessionSnapshot = EMPTY_SNAPSHOT
private pending: PendingWork | undefined
private active = new Map<
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 opened = false
private legacyMigrationPending = false
private onLegacyMigrationComplete: (() => void) | undefined
private listenersAttached = false
private onDurable: (() => void) | undefined
private durableSignaled = false
private broadcast = new BroadcastChannel(CHANNEL_NAME)
async open(
legacy?: SessionSnapshot,
onLegacyMigrationComplete?: () => void,
): Promise<SessionStorageLoadResult> {
this.onLegacyMigrationComplete = onLegacyMigrationComplete
let shouldScrubLegacy = false
async init(
legacy: SessionSnapshot,
onDurable: () => void,
): Promise<SessionSnapshot> {
this.onDurable = onDurable
let stored: SessionSnapshot | undefined
let corrupt = false
try {
const 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)
}
stored = readFromStorage()
} catch (cause) {
logStorageError(storageError('open', cause))
// A corrupt dedicated session key proves migration already committed;
logStorageError(storageError('init', cause))
// A corrupt dedicated session key proves migration already committed, so
// do not resurrect legacy credentials. If localStorage itself is
// unavailable, keep the legacy snapshot alive in memory for this tab.
this.snapshot =
cause instanceof InvalidWebSessionStorageDataError
? EMPTY_SNAPSHOT
: (legacy ?? EMPTY_SNAPSHOT)
shouldScrubLegacy =
(await this.persistInitialSnapshot(this.snapshot)) &&
Boolean(legacy?.accounts.length)
this.legacyMigrationPending =
!shouldScrubLegacy && Boolean(legacy?.accounts.length)
corrupt = cause instanceof InvalidSessionStorageDataError
stored = undefined
}
if (stored) {
this.snapshot = stored
this.base = stored
this.signalDurable()
} else {
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()
return {status: 'ready', snapshot: this.snapshot, shouldScrubLegacy}
return this.snapshot
}
getSnapshot(): SessionSnapshot {
return this.snapshot
}
async commit(
previous: SessionSnapshot,
next: SessionSnapshot,
): Promise<SessionStorageCommitResult> {
if (this.pendingCommit?.replace) {
this.snapshot = this.pendingCommit.next
return this.retryPending()
write(next: SessionSnapshot): void {
if (this.pending?.type === 'clear') {
// A requested wipe is still settling. Drop writes so we never resurrect a
// session; retry the clear instead.
this.snapshot = EMPTY_SNAPSHOT
void this.retry()
return
}
this.snapshot = next
const base = this.pendingCommit?.previous ?? previous
const operation = {previous: base, next}
this.pendingCommit = operation
// Keep the original base across a superseded pending write (latest-wins).
const base = this.pending ? this.pending.base : this.base
const op: PendingWork = {type: 'write', base, next}
this.pending = op
this.cancelRetry()
return this.persistOperation(operation, 'commit')
}
async retryPending(): Promise<SessionStorageCommitResult> {
if (!this.pendingCommit) return {status: 'committed'}
return this.persistOperation(this.pendingCommit, 'retry')
void this.persistOperation(op, 'write')
}
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void {
this.listeners.add(listener)
this.subscribers.add(listener)
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> {
this.snapshot = EMPTY_SNAPSHOT
this.pendingCommit = {
previous: EMPTY_SNAPSHOT,
next: EMPTY_SNAPSHOT,
replace: true,
}
this.pending = {type: 'clear'}
this.cancelRetry()
const result = await this.persistOperation(this.pendingCommit, 'clear')
if (result.status === 'pending') {
throw new Error(`session storage clear failed: ${result.error.kind}`)
const error = await this.persistOperation(this.pending, 'clear')
if (error) {
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) => {
if (event.key === STORAGE_KEY) this.receiveExternalUpdate()
}
@@ -144,29 +239,50 @@ export class WebSessionRepository implements SessionRepository {
private receiveExternalUpdate() {
try {
const next = readFromStorage()
if (this.pendingCommit) {
if (!this.activeOperations.has(this.pendingCommit)) {
void this.retryPending()
}
const theirs = readFromStorage()
if (this.pending) {
// Fold the external change into our pending write via a retry.
if (!this.active.has(this.pending)) void this.retry()
return
}
if (!next || JSON.stringify(next) === JSON.stringify(this.snapshot)) {
if (!theirs || JSON.stringify(theirs) === JSON.stringify(this.snapshot)) {
return
}
this.snapshot = next
this.snapshot = theirs
this.base = theirs
this.cancelRetry()
this.listeners.forEach(listener => listener(next))
this.notify(theirs)
} 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() {
if (this.retryTimer) return
this.retryTimer = setTimeout(() => {
this.retryTimer = undefined
void this.retryPending()
void this.retry()
}, RETRY_DELAY)
}
@@ -174,92 +290,6 @@ export class WebSessionRepository implements SessionRepository {
if (this.retryTimer) clearTimeout(this.retryTimer)
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> {
@@ -283,7 +313,7 @@ function readFromStorage(): SessionSnapshot | undefined {
try {
return sessionSnapshotSchema.parse(JSON.parse(raw))
} catch {
throw new InvalidWebSessionStorageDataError()
throw new InvalidSessionStorageDataError()
}
}
@@ -291,134 +321,6 @@ function writeToStorage(snapshot: SessionSnapshot) {
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 {
return new WebSessionRepository()
}
+284
View File
@@ -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, '')
}
+18 -27
View File
@@ -8,37 +8,28 @@ export type SessionStorageErrorKind =
export type SessionStorageError = {
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 {
open(
legacy?: SessionSnapshot,
onLegacyMigrationComplete?: () => void,
): Promise<SessionStorageLoadResult>
/**
* Load sessions from storage, performing crash recovery and one-time
* migration from `legacy`. `onDurable` fires exactly once, when data is
* 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
commit(
previous: SessionSnapshot,
next: SessionSnapshot,
): MaybePromise<SessionStorageCommitResult>
retryPending(): MaybePromise<SessionStorageCommitResult>
/**
* Persist a snapshot. Fire-and-forget: failures surface via onWriteFailure
* and retry automatically. The latest write wins.
*/
write(next: SessionSnapshot): void
/** The stored snapshot changed externally (another tab). Never fires on native. */
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>
}