Move session locking into persistence
This commit is contained in:
@@ -77,31 +77,21 @@ No action. This whole-root localStorage read-modify-write race predates the vers
|
||||
|
||||
Compatibility reference: [MDN `Navigator.locks`](https://developer.mozilla.org/docs/Web/API/Navigator/locks).
|
||||
|
||||
## 3. Enforce the session lock invariant
|
||||
## 3. Keep lock ownership inside persistence
|
||||
|
||||
### Problem
|
||||
|
||||
`writeSession()` performs the conditional session read-modify-write but does not acquire the persisted-storage lock itself. Current session persistence callsites run it inside `runWithCredentialLock()`, which aliases the root storage lock, but this is a convention rather than an enforced API invariant.
|
||||
`writeSession()` previously relied on every caller to enter the persisted-storage lock before dispatching a session action. The lock ownership was transitive and invisible at the write API, so a future direct caller could silently bypass serialization.
|
||||
|
||||
The lock cannot simply be added inside `writeSession()` while callers retain the outer lock because Web Locks are not reentrant. A nested request for the same exclusive lock would deadlock.
|
||||
### Resolution
|
||||
|
||||
A future caller could accidentally do this:
|
||||
`writeSession()` now owns the persisted-storage lock on web. Its lock callback contains exactly the shared-state commit:
|
||||
|
||||
```ts
|
||||
await persisted.writeSession({
|
||||
nextSession,
|
||||
credentialMutations,
|
||||
})
|
||||
```
|
||||
1. read the authoritative root from localStorage;
|
||||
2. conditionally merge the session mutation;
|
||||
3. write the updated root; and
|
||||
4. broadcast the committed update.
|
||||
|
||||
without first entering the root lock.
|
||||
Session callsites no longer acquire the lock. Network requests, reducer work, expiry rescue, and local bundle reconciliation remain outside it. Native retains its existing serialized AsyncStorage queue inside `writeSession()`.
|
||||
|
||||
### Possible direction
|
||||
|
||||
Potential enforcement options include:
|
||||
|
||||
1. Add a development assertion tracking whether the current realm is inside `runWithPersistedStorageLock()`.
|
||||
2. Expose the unlocked commit only through a capability passed to the lock callback.
|
||||
3. Move lock ownership into a higher-level session transaction API that performs the authoritative read, reconciliation, and write together.
|
||||
|
||||
Any enforcement should preserve the existing requirement that network refreshes happen outside the lock.
|
||||
This makes every call to `writeSession()` safe by construction, avoids non-reentrant nested Web Locks, and keeps the lock scoped to the operation that actually needs cross-tab exclusion.
|
||||
|
||||
@@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS
|
||||
Tab A receives successor generation B
|
||||
```
|
||||
|
||||
Before committing the response, Tab A acquires the credential lock and synchronously rereads localStorage. The source of truth may have changed while its network request was in flight.
|
||||
Before committing the response, `writeSession()` acquires the persisted-storage lock and synchronously rereads localStorage. The source of truth may have changed while the network request was in flight.
|
||||
|
||||
### Case 1: nothing else changed
|
||||
|
||||
@@ -276,16 +276,14 @@ Tab A plans: write version 9
|
||||
Tab B plans: write version 9
|
||||
```
|
||||
|
||||
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses, performs the network request, and acquires the lock only to reconcile and commit its result:
|
||||
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses and performs the network request first. The resulting session mutation is then passed to `writeSession()`, which owns the lock around reconciliation and persistence:
|
||||
|
||||
```ts
|
||||
const baseRefreshJti = getRefreshJti(session.refreshJwt)
|
||||
const refreshed = await refreshSession()
|
||||
|
||||
await navigator.locks.request('bsky-persisted-storage', async () => {
|
||||
const latest = readAccountFromLocalStorage(did)
|
||||
// Commit only if latest is active and still has baseRefreshJti.
|
||||
})
|
||||
```text
|
||||
Capture refresh generation A
|
||||
Perform network refresh A -> B
|
||||
Call writeSession with base A and result B
|
||||
writeSession acquires the persisted-storage lock
|
||||
writeSession rereads, conditionally merges, and persists
|
||||
```
|
||||
|
||||
This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe.
|
||||
|
||||
@@ -16,10 +16,6 @@ import {type PersistedApi} from './types'
|
||||
import {normalizeData} from './util'
|
||||
|
||||
export type {SessionCredentialMutation} from './session-merge'
|
||||
export {
|
||||
runWithPersistedStorageLock as runWithCredentialLock,
|
||||
runWithPersistedStorageLock,
|
||||
} from './storage-lock'
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
@@ -78,6 +74,7 @@ export function write<K extends keyof Schema>(
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
/** Queue a conditional session merge with other root storage writes. */
|
||||
export function writeSession({
|
||||
nextSession,
|
||||
credentialMutations,
|
||||
|
||||
@@ -17,10 +17,6 @@ import {type PersistedApi} from './types'
|
||||
import {normalizeData} from './util'
|
||||
|
||||
export type {SessionCredentialMutation} from './session-merge'
|
||||
export {
|
||||
runWithPersistedStorageLock as runWithCredentialLock,
|
||||
runWithPersistedStorageLock,
|
||||
} from './storage-lock'
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
@@ -112,8 +108,8 @@ export function write<K extends keyof Schema>(
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export async function writeSession({
|
||||
/** Commit a conditional session merge while holding the root storage lock. */
|
||||
export function writeSession({
|
||||
nextSession,
|
||||
credentialMutations,
|
||||
currentAccountDid,
|
||||
@@ -122,19 +118,23 @@ export async function writeSession({
|
||||
credentialMutations: SessionCredentialMutation[]
|
||||
currentAccountDid?: string
|
||||
}): Promise<Schema['session']> {
|
||||
const stored = readFromStorage() ?? _state
|
||||
const session = applySessionUpdate({
|
||||
storedSession: stored.session,
|
||||
nextSession,
|
||||
credentialMutations,
|
||||
currentAccountDid,
|
||||
})
|
||||
const updated = normalizeData({...stored, session})
|
||||
writeToStorage(updated)
|
||||
_state = updated
|
||||
return runWithPersistedStorageLock({
|
||||
operation: () => {
|
||||
const stored = readFromStorage() ?? _state
|
||||
const session = applySessionUpdate({
|
||||
storedSession: stored.session,
|
||||
nextSession,
|
||||
credentialMutations,
|
||||
currentAccountDid,
|
||||
})
|
||||
const updated = normalizeData({...stored, session})
|
||||
writeToStorage(updated)
|
||||
_state = updated
|
||||
|
||||
broadcastUpdate({key: 'session'})
|
||||
return session
|
||||
broadcastUpdate({key: 'session'})
|
||||
return session
|
||||
},
|
||||
})
|
||||
}
|
||||
writeSession satisfies PersistedApi['writeSession']
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {type PersistedApi} from './types'
|
||||
|
||||
export function runWithPersistedStorageLock<T>({
|
||||
operation,
|
||||
}: {
|
||||
@@ -15,4 +13,3 @@ export function runWithPersistedStorageLock<T>({
|
||||
)
|
||||
}
|
||||
}
|
||||
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {type PersistedApi} from './types'
|
||||
|
||||
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
|
||||
|
||||
export function runWithPersistedStorageLock<T>({
|
||||
@@ -22,8 +20,6 @@ export function runWithPersistedStorageLock<T>({
|
||||
|
||||
return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
|
||||
}
|
||||
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
|
||||
|
||||
function getLockManager(): LockManager | undefined {
|
||||
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
|
||||
return undefined
|
||||
|
||||
@@ -21,9 +21,6 @@ export type PersistedApi = {
|
||||
/** Omit to preserve the current account read from persisted storage. */
|
||||
currentAccountDid?: string
|
||||
}): Promise<Schema['session']>
|
||||
runWithPersistedStorageLock<T>(args: {
|
||||
operation: () => T | Promise<T>
|
||||
}): Promise<T>
|
||||
onUpdate<K extends keyof Schema>(
|
||||
key: K,
|
||||
cb: (v: Schema[K]) => void,
|
||||
|
||||
@@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => {
|
||||
readLatest: () => defaults.session,
|
||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||
Promise.resolve(nextSession),
|
||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
||||
Promise.resolve(operation()),
|
||||
onUpdate: () => () => {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => {
|
||||
readLatest: () => defaults.session,
|
||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||
Promise.resolve(nextSession),
|
||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
||||
Promise.resolve(operation()),
|
||||
onUpdate: () => () => {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => {
|
||||
readLatest: () => defaults.session,
|
||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||
Promise.resolve(nextSession),
|
||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
||||
Promise.resolve(operation()),
|
||||
onUpdate: () => () => {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({
|
||||
mockPersisted.latest = committed
|
||||
return Promise.resolve(committed)
|
||||
},
|
||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
||||
Promise.resolve(operation()),
|
||||
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
|
||||
mockPersistedListeners.push(callback)
|
||||
return () => {}
|
||||
|
||||
+222
-248
@@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const onSessionChangeRef = useRef<OnSessionChange | null>(null)
|
||||
|
||||
const onSessionChange = useCallback(
|
||||
(
|
||||
async (
|
||||
bundle: SessionBundle,
|
||||
accountDid: string,
|
||||
sessionEvent: AtpSessionEvent,
|
||||
@@ -172,164 +172,159 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
? bundle.session.session.refreshJwt
|
||||
: sessionData?.refreshJwt
|
||||
|
||||
return persistedSession.runWithCredentialLock({
|
||||
operation: async () => {
|
||||
/*
|
||||
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
|
||||
* bundle's late update would otherwise clear the failed-generation set
|
||||
* that bounds the rescue loop. (Its dispatch below is separately dropped
|
||||
* by the reducer's identity guard.)
|
||||
*/
|
||||
if (
|
||||
sessionEvent === 'update' &&
|
||||
sessionData &&
|
||||
(store.getState().currentBundleState.bundle as unknown as
|
||||
SessionBundle | PublicSessionBundle) === bundle
|
||||
) {
|
||||
failedExpiryTokensRef.current.get(accountDid)?.clear()
|
||||
}
|
||||
/*
|
||||
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
|
||||
* bundle's late update would otherwise clear the failed-generation set
|
||||
* that bounds the rescue loop. (Its dispatch below is separately dropped
|
||||
* by the reducer's identity guard.)
|
||||
*/
|
||||
if (
|
||||
sessionEvent === 'update' &&
|
||||
sessionData &&
|
||||
(store.getState().currentBundleState.bundle as unknown as
|
||||
SessionBundle | PublicSessionBundle) === bundle
|
||||
) {
|
||||
failedExpiryTokensRef.current.get(accountDid)?.clear()
|
||||
}
|
||||
|
||||
/*
|
||||
* PasswordSession invokes its hooks before updating its live getter. Use
|
||||
* the delivered payload so a refresh persists the newly rotated tokens.
|
||||
*
|
||||
* A refresh payload carries no didDoc unless the server sends one, so the
|
||||
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
|
||||
* refresh would persist `pdsUrl: undefined` and the next cold start would
|
||||
* route pre-refresh requests to the entryway instead of the PDS.
|
||||
*/
|
||||
const refreshedAccount =
|
||||
sessionEvent === 'update' && sessionData
|
||||
? sessionDataToSessionAccount(
|
||||
sessionData,
|
||||
sessionData.service,
|
||||
store.getState().accounts.find(a => a.did === accountDid)
|
||||
?.pdsUrl,
|
||||
)
|
||||
: undefined
|
||||
|
||||
/*
|
||||
* A stale tab may expire a token after another tab has already rotated it.
|
||||
* Prefer a newer persisted or reducer generation over logging every tab
|
||||
* out. Failed generations are recorded and bounded to guarantee that a
|
||||
* repeatedly expiring session eventually falls through to logout.
|
||||
*/
|
||||
if (sessionEvent === 'expired') {
|
||||
const current = store.getState()
|
||||
const currentBundle = current.currentBundleState
|
||||
.bundle as unknown as SessionBundle | PublicSessionBundle
|
||||
const dyingRefreshJwt = sessionData?.refreshJwt
|
||||
// Stale bundle events are handled by the reducer's identity guard.
|
||||
if (
|
||||
currentBundle === bundle &&
|
||||
current.currentBundleState.did === accountDid &&
|
||||
dyingRefreshJwt
|
||||
) {
|
||||
let failedSet = failedExpiryTokensRef.current.get(accountDid)
|
||||
if (!failedSet) {
|
||||
failedSet = new Set()
|
||||
failedExpiryTokensRef.current.set(accountDid, failedSet)
|
||||
}
|
||||
failedSet.add(dyingRefreshJwt)
|
||||
|
||||
const persistedCandidate = persistedSession
|
||||
.readLatest('session')
|
||||
.accounts.find(a => a.did === accountDid)
|
||||
const reducerCandidate = current.accounts.find(
|
||||
a => a.did === accountDid,
|
||||
)
|
||||
const candidate = pickExpiryRescueCandidate({
|
||||
dyingRefreshJwt,
|
||||
candidates: [persistedCandidate, reducerCandidate],
|
||||
failedRefreshJwts: failedSet,
|
||||
})
|
||||
|
||||
if (candidate) {
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
candidate,
|
||||
onSessionChangeRef.current!,
|
||||
)
|
||||
if (rebuilt) {
|
||||
await store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle: rebuilt.bundle,
|
||||
newAccount: rebuilt.account,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the current bundle may report that its session was dropped.
|
||||
if (
|
||||
sessionEvent === 'expired' &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
emitSessionDropped()
|
||||
}
|
||||
|
||||
const credentialMutations: SessionCredentialMutation[] = []
|
||||
if (sessionEvent === 'update' && refreshedAccount) {
|
||||
credentialMutations.push({
|
||||
type: 'refresh',
|
||||
accountDid,
|
||||
baseRefreshJwt,
|
||||
resultRefreshJwt: refreshedAccount.refreshJwt,
|
||||
})
|
||||
} else if (sessionEvent === 'expired') {
|
||||
credentialMutations.push({
|
||||
type: 'expire',
|
||||
accountDid,
|
||||
baseRefreshJwt,
|
||||
})
|
||||
}
|
||||
|
||||
// Bundle identity prevents stale sessions from changing the active account.
|
||||
const committedSession = await store.dispatch(
|
||||
{
|
||||
type: 'received-session-event',
|
||||
bundle,
|
||||
refreshedAccount,
|
||||
accountDid,
|
||||
sessionEvent,
|
||||
},
|
||||
credentialMutations,
|
||||
)
|
||||
|
||||
if (sessionEvent === 'update' && committedSession) {
|
||||
const committedAccount = committedSession.accounts.find(
|
||||
account => account.did === accountDid,
|
||||
/*
|
||||
* PasswordSession invokes its hooks before updating its live getter. Use
|
||||
* the delivered payload so a refresh persists the newly rotated tokens.
|
||||
*
|
||||
* A refresh payload carries no didDoc unless the server sends one, so the
|
||||
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
|
||||
* refresh would persist `pdsUrl: undefined` and the next cold start would
|
||||
* route pre-refresh requests to the entryway instead of the PDS.
|
||||
*/
|
||||
const refreshedAccount =
|
||||
sessionEvent === 'update' && sessionData
|
||||
? sessionDataToSessionAccount(
|
||||
sessionData,
|
||||
sessionData.service,
|
||||
store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
|
||||
)
|
||||
if (
|
||||
committedAccount?.refreshJwt &&
|
||||
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
committedAccount,
|
||||
onSessionChangeRef.current!,
|
||||
)
|
||||
if (rebuilt) {
|
||||
await store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle: rebuilt.bundle,
|
||||
newAccount: rebuilt.account,
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
!committedAccount?.refreshJwt &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
: undefined
|
||||
|
||||
/*
|
||||
* A stale tab may expire a token after another tab has already rotated it.
|
||||
* Prefer a newer persisted or reducer generation over logging every tab
|
||||
* out. Failed generations are recorded and bounded to guarantee that a
|
||||
* repeatedly expiring session eventually falls through to logout.
|
||||
*/
|
||||
if (sessionEvent === 'expired') {
|
||||
const current = store.getState()
|
||||
const currentBundle = current.currentBundleState.bundle as unknown as
|
||||
SessionBundle | PublicSessionBundle
|
||||
const dyingRefreshJwt = sessionData?.refreshJwt
|
||||
// Stale bundle events are handled by the reducer's identity guard.
|
||||
if (
|
||||
currentBundle === bundle &&
|
||||
current.currentBundleState.did === accountDid &&
|
||||
dyingRefreshJwt
|
||||
) {
|
||||
let failedSet = failedExpiryTokensRef.current.get(accountDid)
|
||||
if (!failedSet) {
|
||||
failedSet = new Set()
|
||||
failedExpiryTokensRef.current.set(accountDid, failedSet)
|
||||
}
|
||||
failedSet.add(dyingRefreshJwt)
|
||||
|
||||
const persistedCandidate = persistedSession
|
||||
.readLatest('session')
|
||||
.accounts.find(a => a.did === accountDid)
|
||||
const reducerCandidate = current.accounts.find(
|
||||
a => a.did === accountDid,
|
||||
)
|
||||
const candidate = pickExpiryRescueCandidate({
|
||||
dyingRefreshJwt,
|
||||
candidates: [persistedCandidate, reducerCandidate],
|
||||
failedRefreshJwts: failedSet,
|
||||
})
|
||||
|
||||
if (candidate) {
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
candidate,
|
||||
onSessionChangeRef.current!,
|
||||
)
|
||||
if (rebuilt) {
|
||||
await store.dispatch({
|
||||
type: 'synced-accounts',
|
||||
syncedAccounts: committedSession.accounts,
|
||||
syncedCurrentDid: committedSession.currentAccount?.did,
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle: rebuilt.bundle,
|
||||
newAccount: rebuilt.account,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the current bundle may report that its session was dropped.
|
||||
if (
|
||||
sessionEvent === 'expired' &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
emitSessionDropped()
|
||||
}
|
||||
|
||||
const credentialMutations: SessionCredentialMutation[] = []
|
||||
if (sessionEvent === 'update' && refreshedAccount) {
|
||||
credentialMutations.push({
|
||||
type: 'refresh',
|
||||
accountDid,
|
||||
baseRefreshJwt,
|
||||
resultRefreshJwt: refreshedAccount.refreshJwt,
|
||||
})
|
||||
} else if (sessionEvent === 'expired') {
|
||||
credentialMutations.push({
|
||||
type: 'expire',
|
||||
accountDid,
|
||||
baseRefreshJwt,
|
||||
})
|
||||
}
|
||||
|
||||
// Bundle identity prevents stale sessions from changing the active account.
|
||||
const committedSession = await store.dispatch(
|
||||
{
|
||||
type: 'received-session-event',
|
||||
bundle,
|
||||
refreshedAccount,
|
||||
accountDid,
|
||||
sessionEvent,
|
||||
},
|
||||
})
|
||||
credentialMutations,
|
||||
)
|
||||
|
||||
if (sessionEvent === 'update' && committedSession) {
|
||||
const committedAccount = committedSession.accounts.find(
|
||||
account => account.did === accountDid,
|
||||
)
|
||||
if (
|
||||
committedAccount?.refreshJwt &&
|
||||
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
committedAccount,
|
||||
onSessionChangeRef.current!,
|
||||
)
|
||||
if (rebuilt) {
|
||||
await store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle: rebuilt.bundle,
|
||||
newAccount: rebuilt.account,
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
!committedAccount?.refreshJwt &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
await store.dispatch({
|
||||
type: 'synced-accounts',
|
||||
syncedAccounts: committedSession.accounts,
|
||||
syncedCurrentDid: committedSession.currentAccount?.did,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[store],
|
||||
)
|
||||
@@ -358,23 +353,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
disposeBundle(bundle)
|
||||
return
|
||||
}
|
||||
await persistedSession.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'login',
|
||||
accountDid: account.did,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
),
|
||||
})
|
||||
await store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'login',
|
||||
accountDid: account.did,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
)
|
||||
ax.metric('account:create:success', metrics, {
|
||||
session: utils.accountToSessionMetadata(account),
|
||||
})
|
||||
@@ -401,23 +393,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
disposeBundle(bundle)
|
||||
return
|
||||
}
|
||||
await persistedSession.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'login',
|
||||
accountDid: account.did,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
),
|
||||
})
|
||||
await store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'login',
|
||||
accountDid: account.did,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
)
|
||||
ax.metric(
|
||||
'account:loggedIn',
|
||||
{logContext, withPassword: true},
|
||||
@@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const prevState = store.getState()
|
||||
const accountDid = prevState.currentBundleState.did
|
||||
if (accountDid) {
|
||||
void persistedSession
|
||||
.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch({type: 'logged-out-current-account', accountDid}, [
|
||||
{type: 'logout', accountDid},
|
||||
]),
|
||||
})
|
||||
void store
|
||||
.dispatch({type: 'logged-out-current-account', accountDid}, [
|
||||
{type: 'logout', accountDid},
|
||||
])
|
||||
.catch(() => {})
|
||||
}
|
||||
ax.metric(
|
||||
@@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
.accounts.map(account => account.did),
|
||||
]),
|
||||
]
|
||||
void persistedSession
|
||||
.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch(
|
||||
{type: 'logged-out-every-account'},
|
||||
accountDids.map(accountDid => ({
|
||||
type: 'logout' as const,
|
||||
accountDid,
|
||||
})),
|
||||
),
|
||||
})
|
||||
void store
|
||||
.dispatch(
|
||||
{type: 'logged-out-every-account'},
|
||||
accountDids.map(accountDid => ({
|
||||
type: 'logout' as const,
|
||||
accountDid,
|
||||
})),
|
||||
)
|
||||
.catch(() => {})
|
||||
ax.metric(
|
||||
'account:loggedOut',
|
||||
@@ -556,24 +539,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
disposeBundle(bundle)
|
||||
return
|
||||
}
|
||||
const committedSession = await persistedSession.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'refresh',
|
||||
accountDid: account.did,
|
||||
baseRefreshJwt: latestStoredAccount.refreshJwt,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
),
|
||||
})
|
||||
const committedSession = await store.dispatch(
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
},
|
||||
[
|
||||
{
|
||||
type: 'refresh',
|
||||
accountDid: account.did,
|
||||
baseRefreshJwt: latestStoredAccount.refreshJwt,
|
||||
resultRefreshJwt: account.refreshJwt,
|
||||
},
|
||||
],
|
||||
)
|
||||
const committedAccount = committedSession?.accounts.find(
|
||||
candidate => candidate.did === account.did,
|
||||
)
|
||||
@@ -625,21 +605,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
||||
if (signal.aborted) return
|
||||
await persistedSession.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
/*
|
||||
* Read the did off the response rather than the session: the bundle may
|
||||
* have been disposed while the request was in flight, and the live
|
||||
* getters throw in that state.
|
||||
*/
|
||||
accountDid: data.did,
|
||||
patch: {
|
||||
emailConfirmed: data.emailConfirmed,
|
||||
emailAuthFactor: data.emailAuthFactor,
|
||||
},
|
||||
}),
|
||||
await store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
/*
|
||||
* Read the did off the response rather than the session: the bundle may
|
||||
* have been disposed while the request was in flight, and the live
|
||||
* getters throw in that state.
|
||||
*/
|
||||
accountDid: data.did,
|
||||
patch: {
|
||||
emailConfirmed: data.emailConfirmed,
|
||||
emailAuthFactor: data.emailAuthFactor,
|
||||
},
|
||||
})
|
||||
}, [store, cancelPendingTask])
|
||||
|
||||
@@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account: redactAccount(account),
|
||||
})
|
||||
cancelPendingTask()
|
||||
void persistedSession
|
||||
.runWithCredentialLock({
|
||||
operation: () =>
|
||||
store.dispatch(
|
||||
{
|
||||
type: 'removed-account',
|
||||
accountDid: account.did,
|
||||
},
|
||||
[{type: 'remove', accountDid: account.did}],
|
||||
),
|
||||
})
|
||||
void store
|
||||
.dispatch(
|
||||
{
|
||||
type: 'removed-account',
|
||||
accountDid: account.did,
|
||||
},
|
||||
[{type: 'remove', accountDid: account.did}],
|
||||
)
|
||||
.catch(() => {})
|
||||
addSessionDebugLog({
|
||||
type: 'method:end',
|
||||
|
||||
Reference in New Issue
Block a user