Preserve authoritative account selection
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user