keep the stored pds url when a refresh delivers no did doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 16:45:47 +03:00
parent 517439abd6
commit 131d4815e6
2 changed files with 93 additions and 1 deletions
@@ -242,6 +242,36 @@ function dyingData(refreshJwt: string): SessionData {
}
}
/** The rotated payload PasswordSession threads through its `onUpdated` hook. */
function refreshedData(
refreshJwt: string,
didDoc?: SessionData['didDoc'],
): SessionData {
return {
accessJwt: 'fresh-access-jwt',
refreshJwt,
handle: 'alice.test',
did: DID,
active: true,
service: SERVICE,
...(didDoc ? {didDoc} : {}),
}
}
/** A minimal valid DID document whose only service entry is a PDS. */
function makeDidDoc(pdsUrl: string): SessionData['didDoc'] {
return {
id: DID,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
beforeEach(() => {
mockPersisted.session = {accounts: [], currentAccount: undefined}
mockPersisted.latest = {accounts: [], currentAccount: undefined}
@@ -366,6 +396,59 @@ describe('expiry rescue', () => {
})
})
/*
* A refresh payload only carries a didDoc when the server sends one, but
* `pdsUrl` is never derived from the login service. If the provider does not
* thread the stored value through, an ordinary refresh persists
* `pdsUrl: undefined` and the next cold start routes pre-refresh requests to
* the entryway instead of the account's PDS.
*/
describe('refresh persistence', () => {
const PDS_HOST = 'https://shimeji.us-east.host.bsky.network'
const DIDDOC_PDS_HOST = 'https://morel.us-west.host.bsky.network'
it('keeps the stored pdsUrl when the refresh carries no didDoc', async () => {
const account = makeAccount({pdsUrl: `${PDS_HOST}/`})
const bundle = makeBundle(account)
const {onSessionChange, currentAccount} = await renderLoggedIn(
account,
bundle,
)
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'update',
refreshedData('refresh-jwt-2'),
)
})
expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2')
expect(currentAccount()?.pdsUrl).toBe(`${PDS_HOST}/`)
})
it('prefers the didDoc endpoint over the stored pdsUrl', async () => {
const account = makeAccount({pdsUrl: `${PDS_HOST}/`})
const bundle = makeBundle(account)
const {onSessionChange, currentAccount} = await renderLoggedIn(
account,
bundle,
)
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'update',
refreshedData('refresh-jwt-2', makeDidDoc(DIDDOC_PDS_HOST)),
)
})
expect(currentAccount()?.pdsUrl).toBe(`${DIDDOC_PDS_HOST}/`)
})
})
/** Deliver a cross-tab `persisted` update to the provider's newest listener. */
function emitSynced(session: Schema['session']) {
mockPersistedListeners[mockPersistedListeners.length - 1](session)
+10 -1
View File
@@ -161,10 +161,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
/*
* 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)
? sessionDataToSessionAccount(
sessionData,
sessionData.service,
store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
)
: undefined
/*