Preserve authoritative account selection

This commit is contained in:
Eric Bailey
2026-08-31 14:20:02 -05:00
parent e59533b38a
commit f4aff44e8f
8 changed files with 83 additions and 2 deletions
+12
View File
@@ -104,6 +104,18 @@ If a queued stale refresh succeeds, its result is conditionally committed agains
Tabs must not publish complete in-memory session snapshots as authoritative state. Metadata updates should patch metadata onto a fresh localStorage read without touching credential fields.
Current-account selection is also explicit mutation intent, not a field inferred from an otherwise stale session snapshot. A commit preserves the current account read from localStorage unless the operation explicitly switches accounts. Login, account creation, and explicit resume/switch operations may select their target DID. Refreshes and metadata updates do not change the persisted selection. Logout, removal, and expiration begin from the persisted selection; the final credential-state validation clears it only if that selected account is no longer active.
For example:
```text
Tab A commits: current account Y
Tab B stale memory: current account X
Tab B updates: metadata or refreshed credentials for X
Tab B rereads: current account Y
Tab B commits: preserve current account Y
```
## 3. Commit refreshes conditionally
Start with both tabs and shared storage at the same credential generation:
+49 -1
View File
@@ -8,6 +8,7 @@ import {
} from '../session-merge'
const DID = 'did:plc:example123'
const OTHER_DID = 'did:plc:other456'
function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) {
const encode = (value: object) =>
@@ -16,15 +17,17 @@ function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) {
}
function account({
did = DID,
refreshJwt,
accessJwt = `access-${refreshJwt}`,
}: {
did?: PersistedAccount['did']
refreshJwt?: string
accessJwt?: string
}): PersistedAccount {
return {
service: 'https://bsky.social/',
did: DID,
did,
handle: 'alice.test',
refreshJwt,
accessJwt,
@@ -166,6 +169,49 @@ describe('versioned persisted sessions', () => {
expect(result.accounts[0].accessJwt).toBe(`access-${refreshB}`)
})
it('preserves the persisted current account for ordinary updates', () => {
const alice = account({refreshJwt: refreshA})
const bob = {
...account({did: OTHER_DID, refreshJwt: refreshB}),
handle: 'bob.test',
}
const result = applySessionUpdate({
storedSession: {
accounts: [alice, bob],
currentAccount: bob,
},
nextSession: {
accounts: [{...alice, handle: 'alice-renamed.test'}, bob],
currentAccount: alice,
},
credentialMutations: [],
})
expect(result.currentAccount?.did).toBe(OTHER_DID)
})
it('changes the persisted current account for an explicit selection', () => {
const alice = account({refreshJwt: refreshA})
const bob = {
...account({did: OTHER_DID, refreshJwt: refreshB}),
handle: 'bob.test',
}
const result = applySessionUpdate({
storedSession: {
accounts: [alice, bob],
currentAccount: bob,
},
nextSession: {
accounts: [alice, bob],
currentAccount: alice,
},
credentialMutations: [],
currentAccountDid: DID,
})
expect(result.currentAccount?.did).toBe(DID)
})
it('does not let a stale expiry clear a newer generation', () => {
const storedSession = session({
account: account({refreshJwt: refreshB}),
@@ -203,6 +249,8 @@ describe('versioned persisted sessions', () => {
mutation: {type: 'logout', accountDid: DID},
})
expect(loggedOut.currentAccount).toBeUndefined()
const staleRefresh = update({
storedSession: loggedOut,
nextAccount: account({refreshJwt: refreshC}),
+3
View File
@@ -81,15 +81,18 @@ write satisfies PersistedApi['write']
export function writeSession({
nextSession,
credentialMutations,
currentAccountDid,
}: {
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Promise<Schema['session']> {
return enqueueWrite(async () => {
const session = applySessionUpdate({
storedSession: _state.session,
nextSession,
credentialMutations,
currentAccountDid,
})
const next = normalizeData({..._state, session})
await persistWithRetry(next)
+3
View File
@@ -116,15 +116,18 @@ write satisfies PersistedApi['write']
export async function writeSession({
nextSession,
credentialMutations,
currentAccountDid,
}: {
nextSession: Schema['session']
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)
+7 -1
View File
@@ -79,10 +79,12 @@ export function applySessionUpdate({
storedSession,
nextSession,
credentialMutations,
currentAccountDid,
}: {
storedSession: Schema['session']
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Schema['session'] {
const incomingByDid = new Map(
nextSession.accounts.map(account => [account.did, account]),
@@ -112,9 +114,13 @@ export function applySessionUpdate({
})
}
const persistedCurrentDid = storedSession.currentAccount?.did
const selectedCurrentDid = currentAccountDid ?? persistedCurrentDid
const result: Schema['session'] = {
accounts,
currentAccount: nextSession.currentAccount,
currentAccount: selectedCurrentDid
? accounts.find(account => account.did === selectedCurrentDid)
: undefined,
credentialStates,
}
+2
View File
@@ -18,6 +18,8 @@ export type PersistedApi = {
writeSession(args: {
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
/** Omit to preserve the current account read from persisted storage. */
currentAccountDid?: string
}): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T>
@@ -30,9 +30,11 @@ jest.mock('#/state/persisted', () => ({
writeSession: ({
nextSession,
credentialMutations,
currentAccountDid,
}: {
nextSession: Schema['session']
credentialMutations: import('#/state/persisted').SessionCredentialMutation[]
currentAccountDid?: string
}) => {
const {
applySessionUpdate,
@@ -41,6 +43,7 @@ jest.mock('#/state/persisted', () => ({
storedSession: mockPersisted.latest,
nextSession,
credentialMutations,
currentAccountDid,
})
mockPersisted.session = committed
mockPersisted.latest = committed
+4
View File
@@ -131,6 +131,10 @@ class SessionStore {
persistence = persistedSession.writeSession({
nextSession: persistedData,
credentialMutations,
currentAccountDid:
action.type === 'switched-to-account'
? action.newAccount.did
: undefined,
})
}
this.listeners.forEach(listener => listener())