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. 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 ## 3. Commit refreshes conditionally
Start with both tabs and shared storage at the same credential generation: Start with both tabs and shared storage at the same credential generation:
+49 -1
View File
@@ -8,6 +8,7 @@ import {
} from '../session-merge' } from '../session-merge'
const DID = 'did:plc:example123' const DID = 'did:plc:example123'
const OTHER_DID = 'did:plc:other456'
function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) { function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) {
const encode = (value: object) => const encode = (value: object) =>
@@ -16,15 +17,17 @@ function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) {
} }
function account({ function account({
did = DID,
refreshJwt, refreshJwt,
accessJwt = `access-${refreshJwt}`, accessJwt = `access-${refreshJwt}`,
}: { }: {
did?: PersistedAccount['did']
refreshJwt?: string refreshJwt?: string
accessJwt?: string accessJwt?: string
}): PersistedAccount { }): PersistedAccount {
return { return {
service: 'https://bsky.social/', service: 'https://bsky.social/',
did: DID, did,
handle: 'alice.test', handle: 'alice.test',
refreshJwt, refreshJwt,
accessJwt, accessJwt,
@@ -166,6 +169,49 @@ describe('versioned persisted sessions', () => {
expect(result.accounts[0].accessJwt).toBe(`access-${refreshB}`) 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', () => { it('does not let a stale expiry clear a newer generation', () => {
const storedSession = session({ const storedSession = session({
account: account({refreshJwt: refreshB}), account: account({refreshJwt: refreshB}),
@@ -203,6 +249,8 @@ describe('versioned persisted sessions', () => {
mutation: {type: 'logout', accountDid: DID}, mutation: {type: 'logout', accountDid: DID},
}) })
expect(loggedOut.currentAccount).toBeUndefined()
const staleRefresh = update({ const staleRefresh = update({
storedSession: loggedOut, storedSession: loggedOut,
nextAccount: account({refreshJwt: refreshC}), nextAccount: account({refreshJwt: refreshC}),
+3
View File
@@ -81,15 +81,18 @@ write satisfies PersistedApi['write']
export function writeSession({ export function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}: { }: {
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Promise<Schema['session']> { }): Promise<Schema['session']> {
return enqueueWrite(async () => { return enqueueWrite(async () => {
const session = applySessionUpdate({ const session = applySessionUpdate({
storedSession: _state.session, storedSession: _state.session,
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}) })
const next = normalizeData({..._state, session}) const next = normalizeData({..._state, session})
await persistWithRetry(next) await persistWithRetry(next)
+3
View File
@@ -116,15 +116,18 @@ write satisfies PersistedApi['write']
export async function writeSession({ export async function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}: { }: {
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Promise<Schema['session']> { }): Promise<Schema['session']> {
const stored = readFromStorage() ?? _state const stored = readFromStorage() ?? _state
const session = applySessionUpdate({ const session = applySessionUpdate({
storedSession: stored.session, storedSession: stored.session,
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}) })
const updated = normalizeData({...stored, session}) const updated = normalizeData({...stored, session})
writeToStorage(updated) writeToStorage(updated)
+7 -1
View File
@@ -79,10 +79,12 @@ export function applySessionUpdate({
storedSession, storedSession,
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}: { }: {
storedSession: Schema['session'] storedSession: Schema['session']
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Schema['session'] { }): Schema['session'] {
const incomingByDid = new Map( const incomingByDid = new Map(
nextSession.accounts.map(account => [account.did, account]), 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'] = { const result: Schema['session'] = {
accounts, accounts,
currentAccount: nextSession.currentAccount, currentAccount: selectedCurrentDid
? accounts.find(account => account.did === selectedCurrentDid)
: undefined,
credentialStates, credentialStates,
} }
+2
View File
@@ -18,6 +18,8 @@ export type PersistedApi = {
writeSession(args: { writeSession(args: {
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
/** Omit to preserve the current account read from persisted storage. */
currentAccountDid?: string
}): Promise<Schema['session']> }): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: { runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T> operation: () => T | Promise<T>
@@ -30,9 +30,11 @@ jest.mock('#/state/persisted', () => ({
writeSession: ({ writeSession: ({
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}: { }: {
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: import('#/state/persisted').SessionCredentialMutation[] credentialMutations: import('#/state/persisted').SessionCredentialMutation[]
currentAccountDid?: string
}) => { }) => {
const { const {
applySessionUpdate, applySessionUpdate,
@@ -41,6 +43,7 @@ jest.mock('#/state/persisted', () => ({
storedSession: mockPersisted.latest, storedSession: mockPersisted.latest,
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid,
}) })
mockPersisted.session = committed mockPersisted.session = committed
mockPersisted.latest = committed mockPersisted.latest = committed
+4
View File
@@ -131,6 +131,10 @@ class SessionStore {
persistence = persistedSession.writeSession({ persistence = persistedSession.writeSession({
nextSession: persistedData, nextSession: persistedData,
credentialMutations, credentialMutations,
currentAccountDid:
action.type === 'switched-to-account'
? action.newAccount.did
: undefined,
}) })
} }
this.listeners.forEach(listener => listener()) this.listeners.forEach(listener => listener())