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.
|
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:
|
||||||
|
|||||||
@@ -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}),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user