diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index cad8efa37a..61ae2f54f2 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -19,7 +19,18 @@ jest.mock('#/state/events', () => ({ * bottom-sheet module chain (same approach as session-test.ts). */ jest.mock('#/state/birthdate') -jest.mock('#/ageAssurance/data') +/* + * `prefetchAgeAssuranceServerData` is a genuine prep await in each factory + * (moderation config is synchronous now, so the AA prefetch is where the + * fix-1 tests inject a mid-prep token rotation). The default is a no-op; + * individual tests install behavior via `mockImplementationOnce`. + */ +const mockPrefetchAgeAssuranceServerData = jest.fn<() => void | Promise>() +jest.mock('#/ageAssurance/data', () => ({ + prefetchAgeAssuranceServerData: () => mockPrefetchAgeAssuranceServerData(), + setBirthdateForDid: () => {}, + setCreatedAtForDid: () => {}, +})) jest.mock('#/ageAssurance/state', () => ({ unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}), })) @@ -37,16 +48,18 @@ jest.mock('#/analytics', () => ({ })) /* - * `configureModerationForAccount` is one of the prep awaits in the resume/login - * factories. We replace it with a hook that runs a REAL `session.refresh()`, so - * a token rotation happens DURING prep (before arm()) - exactly the 401 - * auto-refresh scenario the re-snapshot fix guards against. The default is a - * no-op so other tests are unaffected; individual tests install the refreshing - * behavior via `mockImplementationOnce`. (jest requires out-of-scope factory - * references to be `mock`-prefixed.) + * `configureModerationForAccount` is now fully synchronous (the labeler cache + * is a local MMKV read), so it is no longer a prep await - but it still runs + * inside each factory with the freshly built bundle, before the awaited prep + * steps. The fix-1 tests use this mock to CAPTURE the bundle, then inject a + * REAL `session.refresh()` into the awaited AA prefetch (see the + * `#/ageAssurance/data` mock above), so a token rotation happens DURING prep + * (before arm()) - exactly the 401 auto-refresh scenario the re-snapshot fix + * guards against. The default is a no-op so other tests are unaffected. + * (jest requires out-of-scope factory references to be `mock`-prefixed.) */ const mockConfigureModerationForAccount = - jest.fn<(bundle: unknown, account: unknown) => Promise>() + jest.fn<(bundle: unknown, account: unknown) => void>() jest.mock('../moderation', () => ({ configureModerationForAccount: (bundle: unknown, account: unknown) => mockConfigureModerationForAccount(bundle, account), @@ -75,6 +88,8 @@ import { disposeBundle, extractPdsUrl, makeSessionHooks, + MAX_EXPIRY_RESCUE_GENERATIONS, + pickExpiryRescueCandidate, registerBundleKillSwitch, sessionAccountToSessionData, type SessionBundle, @@ -514,17 +529,21 @@ describe('makeSessionHooks arm-latch + event mapping', () => { expect(onSessionChange.mock.calls[0][2]).toBe('network-error') }) - it("does NOT thread a payload on the 'expired' path", () => { + it("threads the dying session payload on the 'expired' path", () => { /* - * onDeleted maps to 'expired' with no sessionData. The provider guards on - * `event === 'update' && sessionData`, so a missing payload here is what - * forces refreshedAccount === undefined (reducer clears tokens + logs out). + * onDeleted maps to 'expired' AND threads the dying SessionData through + * (the library hands onDeleted the session being destroyed, before it nulls + * its internal state). The provider reads the dying refreshJwt from this + * payload to drive the cross-tab expiry rescue. The provider still guards + * `refreshedAccount` on `event === 'update' && sessionData`, so the payload + * on 'expired' does NOT produce a refreshedAccount (reducer still clears + * tokens + logs out when no rescue applies) - see the provider test below. */ const {onSessionChange, hooks} = setup() hooks.arm() void hooks.onDeleted?.call(fakeSession, fakeData) expect(onSessionChange.mock.calls[0][2]).toBe('expired') - expect(onSessionChange.mock.calls[0][3]).toBe(undefined) + expect(onSessionChange.mock.calls[0][3]).toBe(fakeData) }) }) @@ -603,6 +622,7 @@ describe('session-hook payload threading (pre-commit ordering)', () => { }) let refreshedAccountAtHookTime: SessionAccount | undefined = makeAccount() let observedEvent: AtpSessionEvent | undefined + let observedSessionData: SessionData | undefined const onSessionChange = jest.fn( ( _bundle: SessionBundle, @@ -611,6 +631,7 @@ describe('session-hook payload threading (pre-commit ordering)', () => { sessionData?: SessionData, ) => { observedEvent = event + observedSessionData = sessionData refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData) }, ) @@ -628,6 +649,13 @@ describe('session-hook payload threading (pre-commit ordering)', () => { await expect(session.refresh()).rejects.toBeDefined() expect(observedEvent).toBe('expired') + /* + * The dying SessionData IS threaded on 'expired' (its refreshJwt drives the + * provider's cross-tab rescue), but it does NOT become a refreshedAccount: + * deriveRefreshedAccount only maps the 'update' path, so the reducer still + * sees `undefined` and logs out when no rescue applies. + */ + expect(observedSessionData?.refreshJwt).toBe('refresh-jwt') expect(refreshedAccountAtHookTime).toBe(undefined) }) }) @@ -827,9 +855,10 @@ describe('refreshSession semantics', () => { * disarmed latch drops); an early snapshot would persist the stale refreshJwt, * which is dead on the next cold start. * - * We simulate the mid-prep rotation by making the mocked - * `configureModerationForAccount` (a genuine prep await in each factory) run a - * real `session.refresh()`. The factory itself is re-required inside + * We simulate the mid-prep rotation by capturing the bundle from the mocked + * (now synchronous) `configureModerationForAccount` and making the mocked + * `prefetchAgeAssuranceServerData` (a genuine prep await in each factory) run + * a real `session.refresh()`. The factory itself is re-required inside * `jest.isolateModulesAsync` AFTER overriding `globalThis.fetch`, because * session-core captures `globalThis.fetch` into `networkAwareFetch` at module * load - and that captured fetch is what PasswordSession's auto-refresh routes @@ -856,19 +885,26 @@ describe('factory account snapshot is taken AFTER prep (fix 1)', () => { beforeEach(() => { mockConfigureModerationForAccount.mockReset() + mockPrefetchAgeAssuranceServerData.mockReset() }) it('resume: returned account carries the tokens rotated DURING prep', async () => { /* * A refresh mid-prep rotates the session to access-jwt-2/refresh-jwt-2. * `valid-access-jwt` decodes as non-expired, so resume() takes the sync - * fast path and the only refresh is the one prep triggers. + * fast path and the only refresh is the one prep triggers. The bundle is + * captured from the (synchronous) moderation call, and the rotation is + * injected into the awaited AA prefetch. */ + let capturedBundle: SessionBundle | undefined mockConfigureModerationForAccount.mockImplementationOnce( - async (bundle: unknown) => { - await (bundle as SessionBundle).session.refresh() + (bundle: unknown) => { + capturedBundle = bundle as SessionBundle }, ) + mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { + await capturedBundle!.session.refresh() + }) const fetchMock = makeMockFetch() await withFreshFactory(asFetch(fetchMock), async core => { @@ -892,7 +928,7 @@ describe('factory account snapshot is taken AFTER prep (fix 1)', () => { * valid) stored tokens, confirming the moved snapshot did not regress the * happy path. */ - mockConfigureModerationForAccount.mockResolvedValueOnce(undefined) + mockConfigureModerationForAccount.mockReturnValueOnce(undefined) const fetchMock = makeMockFetch() await withFreshFactory(asFetch(fetchMock), async core => { @@ -905,3 +941,89 @@ describe('factory account snapshot is taken AFTER prep (fix 1)', () => { }) }) }) + +/* + * Fix 1: the pure decision behind the cross-tab expiry rescue. Given the dying + * session's refreshJwt and a preference-ordered list of "latest known" + * candidates, it picks the first candidate that is a usable, strictly-newer, + * not-already-failed generation - or undefined (fall through to logout). + */ +describe('pickExpiryRescueCandidate', () => { + it('picks a candidate whose refreshJwt differs from the dying one', () => { + const fresh = makeAccount({refreshJwt: 'refresh-jwt-2'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [fresh], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(fresh) + }) + + it('rejects a candidate carrying the dying refreshJwt (equally dead)', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: 'refresh-jwt-1'})], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(undefined) + }) + + it('rejects a candidate with no refreshJwt', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: undefined}), undefined], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(undefined) + }) + + it('rejects a candidate already recorded as failed (loop guard)', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: 'refresh-jwt-2'})], + failedRefreshJwts: new Set(['refresh-jwt-2']), + }) + expect(picked).toBe(undefined) + }) + + it('tries candidates in order, preferring the first qualifying one', () => { + const persistedCandidate = makeAccount({ + refreshJwt: 'refresh-jwt-persisted', + }) + const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [persistedCandidate, reducerCandidate], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(persistedCandidate) + }) + + it('skips an unusable first candidate and falls back to a later one', () => { + const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + /* first candidate is the dying token; second is genuinely newer */ + candidates: [ + makeAccount({refreshJwt: 'refresh-jwt-1'}), + reducerCandidate, + ], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(reducerCandidate) + }) + + it('gives up once the failed-generation set hits the hard cap', () => { + const failed = new Set() + for (let i = 0; i < MAX_EXPIRY_RESCUE_GENERATIONS; i++) { + failed.add(`refresh-jwt-failed-${i}`) + } + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-dying', + /* a genuinely newer candidate exists, but the budget is exhausted */ + candidates: [makeAccount({refreshJwt: 'refresh-jwt-brand-new'})], + failedRefreshJwts: failed, + }) + expect(picked).toBe(undefined) + }) +}) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index bea05d1967..9e4f4d5da6 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -29,6 +29,7 @@ import { createSessionBundleAndResume, disposeBundle, makeSessionHooks, + pickExpiryRescueCandidate, type PublicSessionBundle, registerBundleKillSwitch, sessionAccountToSessionData, @@ -128,6 +129,32 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const state = useSyncExternalStore(store.subscribe, store.getState) const onboardingDispatch = useOnboardingDispatch() + /* + * Failed-token loop guard for the expiry rescue below. Maps a did to the set + * of refreshJwts that have already produced an 'expired'. Before rescuing + * from a candidate we require its refreshJwt not be in this set, and every + * expiry records its dying token here; a successful 'update' clears the set. + * See the rescue docblock in onSessionChange for why this is a set (not a + * single-shot flag) and why it stays bounded. + */ + const failedExpiryTokensRef = useRef>>(new Map()) + /* + * Self-reference shim. The rescue path rebuilds a bundle and must wire it to + * this same onSessionChange (so the rescued bundle's own future events flow + * back here). Referencing onSessionChange inside its own useCallback body + * would be an unsatisfiable exhaustive-deps cycle, so we thread it through a + * ref kept current right after the callback is defined. + */ + const onSessionChangeRef = useRef< + | (( + bundle: SessionBundle, + accountDid: string, + sessionEvent: AtpSessionEvent, + sessionData?: SessionData, + ) => void) + | null + >(null) + const onSessionChange = useCallback( ( bundle: SessionBundle, @@ -135,6 +162,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { sessionEvent: AtpSessionEvent, sessionData?: SessionData, ) => { + /* + * A successful refresh means this did's world is healthy again, so reset + * its failed-token guard set (a later expiry starts a fresh rescue + * budget). + */ + if (sessionEvent === 'update' && sessionData) { + failedExpiryTokensRef.current.get(accountDid)?.clear() + } + /* * Build the refreshed account from the payload the hook delivers, NOT the * live session getter. `PasswordSession` fires onUpdated/onDeleted BEFORE @@ -151,17 +187,154 @@ export function Provider({children}: React.PropsWithChildren<{}>) { sessionEvent === 'update' && sessionData ? sessionDataToSessionAccount(sessionData, sessionData.service) : undefined - if (sessionEvent === 'expired' || sessionEvent === 'create-failed') { + + /* + * Expiry rescue (compare-and-clear + resume-from-newer). THE BUG: a stale + * tab (frozen by Chrome, a failed localStorage write, or a native app + * killed before its async persist landed) can wake holding a >2h-old + * refresh token. Its refresh gets ExpiredToken, and the naive 'expired' + * handling clears the tokens in storage and logs out EVERY tab - even + * though another tab already rotated to a healthy generation (PDS refresh + * tokens keep a 2h grace window after rotation). + * + * Fix: before letting an expiry become a logout, check whether a NEWER + * generation for this did is known, and if so rebuild the current bundle + * from it instead of dropping the session. The compare lives here at the + * dispatch site (not in the reducer) because the reducer is pure and has + * no access to persisted storage. + * + * Two freshness sources, tried in order: + * - persisted.readLatest('session'): on web this re-reads localStorage + * directly, covering the frozen-tab case where queued cross-tab + * broadcasts have not been processed yet (so both the reducer state and + * persisted's in-memory cache are stale). On native it equals `get`. + * - the reducer's accounts: on native this IS the truth; on web it is + * kept fresh by 'synced-accounts' broadcasts. + * On native the two always agree, so the rescue effectively never fires + * (the dying bundle is the only generation) and expiry falls straight + * through to logout. On web, readLatest is what sees the healthy tokens. + * + * Termination: a rescued bundle that expires AGAIN now matches persisted + * (this tab wrote nothing newer), so no newer candidate exists and it + * falls through to a real logout. The failed-token set is the belt-and- + * suspenders bound - each rescue consumes a strictly newer generation, so + * the set grows by at most one per expiry and is hard-capped + * (MAX_EXPIRY_RESCUE_GENERATIONS). A set rather than a single-shot flag is + * required: with a flag, a second expiry would fall through to logout and + * clobber a healthy THIRD generation another tab just wrote, recreating + * the exact bug. + */ + if (sessionEvent === 'expired') { + const current = store.getState() + const currentAgent = current.currentAgentState.agent as unknown as + | SessionBundle + | PublicSessionBundle + const dyingRefreshJwt = sessionData?.refreshJwt + /* + * The rescue only applies when the expiring bundle IS the current one. + * Otherwise fall through: the reducer's identity guard drops a stale + * bundle's expiry anyway. + */ + if ( + currentAgent === bundle && + current.currentAgentState.did === accountDid && + dyingRefreshJwt + ) { + /* + * Record the dying token FIRST (at the start of handling), so a + * rescued-then-failed generation is remembered and never rescued back + * into. + */ + let failedSet = failedExpiryTokensRef.current.get(accountDid) + if (!failedSet) { + failedSet = new Set() + failedExpiryTokensRef.current.set(accountDid, failedSet) + } + failedSet.add(dyingRefreshJwt) + + /* + * Prefer the persisted re-read over the reducer state: storage is the + * cross-tab source of truth on web (on native they are identical). + */ + const persistedCandidate = persisted + .readLatest('session') + .accounts.find(a => a.did === accountDid) + const reducerCandidate = current.accounts.find( + a => a.did === accountDid, + ) + const candidate = pickExpiryRescueCandidate({ + dyingRefreshJwt, + candidates: [persistedCandidate, reducerCandidate], + failedRefreshJwts: failedSet, + }) + + if (candidate) { + /* + * Rebuild a bundle from the newer tokens synchronously, modeled on + * the same-did rebuild in the persisted.onUpdate handler below. No + * expiry is dispatched and no emitSessionDropped fires - the session + * is not dropped, it is healed. + */ + let newBundle!: SessionBundle + const hooks = makeSessionHooks( + onSessionChangeRef.current!, + () => newBundle, + () => candidate.did, + ) + const newSession = new PasswordSession( + sessionAccountToSessionData(candidate), + hooks, + ) + newBundle = buildBundle(newSession) + registerBundleKillSwitch(newBundle, hooks.kill) + configureModerationForAccount(newBundle, candidate) + /* + * Re-snapshot through the freshly built session (fallback covers + * the destroyed case, which cannot happen for a just-built, + * never-armed session). + */ + const newAccount = newBundle.session.destroyed + ? candidate + : (sessionDataToSessionAccount( + newBundle.session.session, + newBundle.session.session.service, + ) ?? candidate) + hooks.arm() + store.dispatch({ + type: 'replaced-current-bundle', + newAgent: newBundle, + newAccount, + }) + return + } + } + } + + /* + * Fall-through-to-logout path (no rescue was taken). emitSessionDropped + * fires here - never on the rescue path, where the session survives. + * 'create-failed' never fires in production but is kept for parity. + * + * Gate on the expiring bundle still being current: the reducer drops + * events from non-current bundles, and disposal of a replaced bundle + * happens in a deferred useEffect. A stale-but-still-armed bundle expiring + * in that window must not show a spurious "session expired" toast while + * the current session is healthy - only emit when a CURRENT bundle truly + * expires with no rescue. + */ + if ( + (sessionEvent === 'expired' || sessionEvent === 'create-failed') && + store.getState().currentAgentState.agent === bundle + ) { emitSessionDropped() } /* - * The reducer stores the whole bundle as `currentAgentState.agent` and - * compares `action.agent` by identity to decide whether an expiry/error - * belongs to the active account (background accounts must not be able to - * log the current user out). The hook now hands us the bundle that fired, - * so it IS the identity token: a same-bundle event acts on the active - * account, a stale (background) bundle does not match and its clears are - * ignored - matching the pre-migration semantics exactly. + * The bundle is the reducer's identity token: it stores the whole bundle + * as `currentAgentState.agent` and compares `action.agent` by identity to + * decide whether an event belongs to the active account. A same-bundle + * event acts on the active account; a stale (background) bundle does not + * match, so its events are ignored (background accounts must not be able + * to log the current user out or resurrect tokens). */ store.dispatch({ type: 'received-agent-event', @@ -173,6 +346,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [store], ) + onSessionChangeRef.current = onSessionChange const createAccount = useCallback( async (params, metrics) => { @@ -307,6 +481,29 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) if (signal.aborted) { + /* + * A newer task superseded this resume. The bundle is fully built and + * armed, so its session can still consume refresh tokens - dispose it + * before bailing (fixes a leak where an aborted resume left an armed, + * undisposed bundle behind). + */ + disposeBundle(bundle) + return + } + /* + * Completion bail: re-read state and drop out if this account's entry is + * gone, or its tokens were cleared by a cross-tab logout that raced this + * resume (the residual hole where the leader logged in X then out while + * this follower's current did was still undefined, so the onUpdate cancel + * in 2a did not fire). The check is on the ACCOUNTS entry, not on + * "persisted current did": a persisted-current-did check would break + * normal user-initiated account switching, where the target account is + * deliberately not current yet. + */ + const latest = store.getState() + const latestEntry = latest.accounts.find(a => a.did === account.did) + if (!latestEntry || !latestEntry.refreshJwt) { + disposeBundle(bundle) return } store.dispatch({ @@ -330,10 +527,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const signal = cancelPendingTask() /* * Fetch through the account (PDS) client and dispatch the patch. We do NOT - * mutate the session object anymore (PasswordSession's data is immutable to - * us); the reducer patches only the `accounts` entry, and the email-state - * hook reads from the account rather than the session. - * `client.call` returns the response body directly (no `{data}` wrapper). + * mutate the session object (PasswordSession's data is immutable to us); the + * reducer patches only the `accounts` entry, and the email-state hook reads + * from the account rather than the session. */ const data = await bundle.accountClient.call(com.atproto.server.getSession) if (signal.aborted) return @@ -355,12 +551,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { | PublicSessionBundle if (!bundle.session) return undefined // logged out: nothing to refresh /* - * PasswordSession.refresh() re-runs com.atproto.server.refreshSession + - * getSession. On success the session's onUpdated hook fires, which the - * armed makeSessionHooks wiring maps to an 'update' event; the reducer - * snapshots the refreshed account. No explicit dispatch is needed here. - * The returned snapshot lets callers read post-refresh fields without - * waiting on the (async) reducer update. + * refresh() fires the session's onUpdated hook on success, which the armed + * hooks map to an 'update' event; the reducer snapshots the refreshed + * account, so no explicit dispatch is needed here. The returned snapshot + * lets callers read post-refresh fields without waiting on the (async) + * reducer update. */ await bundle.session.refresh() return sessionDataToSessionAccount( @@ -398,6 +593,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const syncedAccount = synced.accounts.find( a => a.did === synced.currentAccount?.did, ) + /* + * The leader tab's current account, but only if it still has tokens - + * a tokenless entry means the leader logged out. When the leader logged + * out (`syncedDid === undefined`) while THIS tab thinks it is logged in, + * cancel any pending task so a resume racing this logout does not win and + * dispatch a switch over the top of the synced logout. We do NOT cancel + * unconditionally on every no-current broadcast: a logged-out tab may be + * mid-login, and another logged-out tab removing a stored account must not + * abort that unrelated in-flight login. resumeSession already cancels at + * its start, so the different-did case is covered elsewhere. + */ + const syncedDid = syncedAccount?.refreshJwt + ? syncedAccount.did + : undefined + if ( + syncedDid === undefined && + state.currentAgentState.did !== undefined + ) { + cancelPendingTask() + } if (syncedAccount && syncedAccount.refreshJwt) { if (syncedAccount.did !== state.currentAgentState.did) { /* @@ -421,13 +636,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { | SessionBundle | PublicSessionBundle /* - * Any change to ANY saved account fires persisted.onUpdate. The + * Any change to ANY saved account fires persisted.onUpdate, and the * 'synced-accounts' dispatch above already keeps the accounts list - * fresh, so if the CURRENT account's tokens are unchanged there is - * nothing to rebuild - a change to a non-current account landed here. - * Bail out before rebuilding: rebuild+swap would kill the live bundle - * (client-identity churn, in-flight request kills) for no reason. - * Fall through to rebuild only when we have no usable live session. + * fresh. So if the CURRENT account's tokens are unchanged, a change to + * a non-current account landed here: bail out before rebuilding, since + * rebuild+swap would kill the live bundle (client-identity churn, + * in-flight request kills) for no reason. Fall through to rebuild only + * when we have no usable live session. */ const live = prevBundle.session && !prevBundle.session.destroyed @@ -454,55 +669,77 @@ export function Provider({children}: React.PropsWithChildren<{}>) { registerBundleKillSwitch(newBundle, hooks.kill) /* * Reapply this account's subscribed labelers to the freshly built - * appview client - buildBundle starts with an empty per-instance - * labeler set, and unlike login/resume/createAccount this rebuild - * path never runs configureModerationForAccount on its own. The - * bundle swap is deferred until the labeler config resolves so the - * new bundle enters the reducer with its labelers already applied - * (matching the factories, which await moderation before returning). - * readLabelers is a local-storage read (microtask-scale, no network, - * preserving this branch's no-network intent), and the OLD bundle's - * access token stays valid throughout the deferral, so nothing - * regresses by waiting. - * - * arm() happens inside the same callback as the dispatch (not - * before the deferral) so there is no async window where the new - * session is armed but the reducer still holds the old bundle - - * an 'expired' event fired in such a window would be dropped by - * the reducer's bundle-identity check. Mirroring the factories' - * snapshot-after-prep, the dispatched account is re-read from the - * live session, so a token refresh during the (unarmed) deferral - * still persists fresh tokens. + * appview client: buildBundle starts with an empty per-instance + * labeler set, and this rebuild path never runs + * configureModerationForAccount on its own. It is fully synchronous + * (the labeler cache is a local MMKV read), so the whole prep + arm + + * dispatch sequence below runs in one tick from the onUpdate + * broadcast - the new bundle enters the reducer with its labelers + * already applied, with no window where the new session is armed but + * the reducer still holds the old bundle. */ - void configureModerationForAccount(newBundle, syncedAccount) - .catch(() => {}) - .finally(() => { - addSessionDebugLog({ - type: 'agent:patch', - agent: newBundle, - prevSession: - prevBundle.session && !prevBundle.session.destroyed - ? prevBundle.session.session - : undefined, - nextSession: newBundle.session.session, - }) - const newAccount = newBundle.session.destroyed - ? syncedAccount - : (sessionDataToSessionAccount( - newBundle.session.session, - newBundle.session.session.service, - ) ?? syncedAccount) - hooks.arm() - store.dispatch({ - type: 'replaced-current-bundle', - newAgent: newBundle, - newAccount, - }) - }) + configureModerationForAccount(newBundle, syncedAccount) + /* + * Defensive race guard. With the whole path synchronous, nothing can + * have dispatched between the 'synced-accounts' dispatch above and + * here, so these conditions are trivially satisfied today. They are + * kept as a cheap invariant check against a future edit reintroducing + * an await into this path: a competing rebuild, an account switch, a + * logout, or a newer token generation would each show up as a + * bundle-identity or token mismatch, and the stale completion must + * drop out (self-disposing the never-installed bundle) rather than + * clobber the newer bundle or resurrect an authenticated bundle into a + * logged-out/other-account slot (the reducer's + * 'replaced-current-bundle' keeps the current did and does no identity + * check on the outgoing agent). + */ + const current = store.getState() + const latestAccount = current.accounts.find( + account => account.did === syncedAccount.did, + ) + if ( + current.currentAgentState.agent !== prevBundle || + latestAccount?.accessJwt !== syncedAccount.accessJwt || + latestAccount?.refreshJwt !== syncedAccount.refreshJwt + ) { + /* + * This bundle was never armed and never installed, so dispose it + * here (the install path's normal disposal in the bundle-identity + * effect will never run for it). + */ + disposeBundle(newBundle) + return + } + addSessionDebugLog({ + type: 'agent:patch', + agent: newBundle, + prevSession: + prevBundle.session && !prevBundle.session.destroyed + ? prevBundle.session.session + : undefined, + nextSession: newBundle.session.session, + }) + /* + * Re-read syncedAccount's data through the freshly built session (the + * fallbacks cover the destroyed case, which cannot happen here since + * the session was just built synchronously and never armed). + */ + const newAccount = newBundle.session.destroyed + ? syncedAccount + : (sessionDataToSessionAccount( + newBundle.session.session, + newBundle.session.session.service, + ) ?? syncedAccount) + hooks.arm() + store.dispatch({ + type: 'replaced-current-bundle', + newAgent: newBundle, + newAccount, + }) } } }) - }, [store, state, resumeSession, onSessionChange]) + }, [store, state, resumeSession, onSessionChange, cancelPendingTask]) const stateContext = useMemo( () => ({ @@ -621,10 +858,10 @@ export function useRequireAuth() { /** * Authenticated lex {@link Client} for appview reads. Backed by the active - * bundle's appview client (proxied to the Bluesky appview, with labelers). Its - * identity is stable per-bundle, so it only changes when the active account - * changes. Falls back to the public client when there is no bundle (logged out, - * or used outside the provider) so callers can treat it as always-present. + * bundle's appview client (proxied to the Bluesky appview, with labelers); its + * identity is stable per-bundle. Falls back to the public client when there is + * no bundle (logged out, or used outside the provider) so callers can treat it + * as always-present. */ export function useLexClient(): Client { const bundle = useContext(BundleContext) @@ -644,13 +881,11 @@ export function useAppviewClient(): Client { * The account (PDS) lex {@link Client} for the active account. Writes and record * mutations go here - requests hit the user's PDS directly (no appview proxy). * - * Logged-out contract: returns a stable throwing client - * ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any - * request, BEFORE any network I/O. This is the write path - it must NOT fall - * back to the public appview, so an unauthenticated write fails immediately and - * legibly rather than silently hitting `public.api.bsky.app`. Components may - * safely hold this client while logged out; only calling it throws. A component - * that genuinely branches on auth state should use {@link useMaybePdsClient}. + * Logged out, returns a stable client ({@link getUnauthenticatedClient}) that + * throws `NotAuthenticatedError` before any network I/O, so an unauthenticated + * write fails loudly rather than silently hitting `public.api.bsky.app`. + * Components may safely hold this client while logged out; only calling it + * throws. To branch on auth state, use {@link useMaybePdsClient} instead. */ export function usePdsClient(): Client { const bundle = useContext(BundleContext) @@ -661,11 +896,10 @@ export function usePdsClient(): Client { * The chat lex {@link Client} for the active account. `chat.bsky.*` calls go * here - proxied to `did:web:api.bsky.chat#bsky_chat`. * - * Logged-out contract: returns a stable throwing client - * ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any - * request, BEFORE any network I/O. Chat is meaningless logged out, so this must - * NOT fall back to the public appview. A component that genuinely branches on - * auth state should use {@link useMaybeChatClient}. + * Logged out, returns a stable client ({@link getUnauthenticatedClient}) that + * throws `NotAuthenticatedError` before any network I/O. Chat is meaningless + * logged out, so this must NOT fall back to the public appview. To branch on + * auth state, use {@link useMaybeChatClient} instead. */ export function useChatClient(): Client { const bundle = useContext(BundleContext) @@ -678,9 +912,8 @@ export function useChatClient(): Client { * * The escape hatch for the rare component that genuinely renders a logged-out * branch and must decide whether a write path is available. Prefer - * {@link usePdsClient} for the common case (a write only reachable while - * authenticated); do NOT reach for this hook merely to dodge the throwing - * client's `NotAuthenticatedError`. + * {@link usePdsClient} for the common case; do NOT reach for this hook merely to + * dodge the throwing client's `NotAuthenticatedError`. */ export function useMaybePdsClient(): Client | null { const bundle = useContext(BundleContext) diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 14ce81a2d6..a74e7c06ad 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -51,14 +51,10 @@ import {type SessionAccount} from './types' import {isSessionExpired} from './util' /** - * The session-change events the reducer/logging/tests speak. - * - * Formerly re-exported from the legacy API package; defined locally now that - * the bridge is gone. These are the exact union members the reducer switches - * on. In - * production only `'update'`/`'expired'`/`'network-error'` are ever emitted from - * {@link makeSessionHooks}; `'create'`/`'create-failed'` remain in the type for - * the reducer and the session tests. + * The session-change events the reducer/logging/tests speak. In production only + * `'update'`/`'expired'`/`'network-error'` are ever emitted from + * {@link makeSessionHooks}; `'create'`/`'create-failed'` exist only for the + * reducer and the session tests. */ export type AtpSessionEvent = | 'create' @@ -71,9 +67,9 @@ export type AtpSessionEvent = * Whether an access token was issued for a queued (waitlisted) signup rather * than a full session. * - * Canonical implementation - util.ts re-exports it. It lives here (rather - * than util.ts) so this module stays dependency-light: util.ts pulls in - * agent.ts and, transitively, a large chunk of the app. + * Canonical implementation - util.ts re-exports it. It lives here (rather than + * util.ts) so this module stays dependency-light: util.ts transitively pulls in + * a large chunk of the app. */ export function isSignupQueued(accessJwt: string | undefined) { if (accessJwt) { @@ -87,8 +83,8 @@ export function isSignupQueued(accessJwt: string | undefined) { } /* - * Captured once at module load so that the wrapper below is immune to later - * monkey-patching of globalThis.fetch (mirrors the old BskyAppAgent fetch). + * Captured once at module load so the wrapper below is immune to later + * monkey-patching of globalThis.fetch. */ const realFetch = globalThis.fetch @@ -97,10 +93,9 @@ const realFetch = globalThis.fetch * Any resolved response (including HTTP errors) confirms the network is up; a * thrown error (DNS failure, timeout, offline) reports it as lost. * - * This replaces the custom `fetch` previously passed to `BskyAppAgent`. It is - * intended to be passed as `PasswordSessionOptions.fetch` and as the `fetch` - * option of unauthenticated lex `Client`s, so every network path in the - * session stack feeds the same reachability signal. + * Passed as `PasswordSessionOptions.fetch` and as the `fetch` option of + * unauthenticated lex `Client`s, so every network path in the session stack + * feeds the same reachability signal. */ export const networkAwareFetch: typeof fetch = async (...args) => { try { @@ -116,11 +111,10 @@ export const networkAwareFetch: typeof fetch = async (...args) => { /** * Extract the PDS endpoint URL from a DID document, if present and valid. * - * Local reimplementation of `@atproto/lex-password-session`'s private - * `extractPdsUrl` util (it lives in a non-exported module, so we cannot import - * it). Must stay behaviorally identical: `PasswordSession.fetchHandler` - * derives its request origin as `extractPdsUrl(didDoc) ?? service`, and we use - * this same derivation to persist `pdsUrl` on the account snapshot. + * Must stay behaviorally identical to `@atproto/lex-password-session`'s private + * `extractPdsUrl` (non-exported, so we reimplement it): `PasswordSession. + * fetchHandler` derives its request origin as `extractPdsUrl(didDoc) ?? service`, + * and we reuse this derivation to persist `pdsUrl` on the account snapshot. */ export function extractPdsUrl(didDoc: unknown): string | null { if (typeof didDoc !== 'object' || didDoc === null) { @@ -160,16 +154,15 @@ function canParseUrl(input: string): boolean { } /** - * Build a minimal synthetic DID document whose only service entry is the - * given PDS endpoint. + * Build a minimal synthetic DID document whose only service entry is the given + * PDS endpoint. * - * Why: the persisted `SessionAccount` stores `pdsUrl` but `SessionData` routes + * The persisted `SessionAccount` stores `pdsUrl` but `SessionData` routes * requests via `extractPdsUrl(didDoc) ?? service`. On the non-expired resume - * fast path (no network), we synthesize this doc from the stored `pdsUrl` so - * the very first requests hit the right PDS (entryway accounts have + * fast path (no network) we synthesize this doc from the stored `pdsUrl` so the + * very first requests hit the right PDS (entryway accounts have * service=bsky.social but a different PDS host). After the first refresh, - * `PasswordSession` refetches `getSession` and replaces this with the real - * DID document. + * `PasswordSession` refetches `getSession` and replaces it with the real doc. */ export function synthDidDoc( did: string, @@ -191,15 +184,13 @@ export function synthDidDoc( * Convert live `PasswordSession` session data into the persisted * `SessionAccount` snapshot. * - * Replaces `agentToSessionAccount`. The object literal's field ORDER must - * match the old `agentToSessionAccount` exactly - the reducer's + * The object literal's field ORDER is load-bearing: the reducer's * `JSON.stringify` fast path and the session test snapshots depend on - * byte-stable serialization. `service` is normalized through `new URL()` to - * keep the trailing slash the old `agent.serviceUrl.toString()` produced, and - * `pdsUrl` likewise (the old code read `agent.pdsUrl?.toString()`, a URL). + * byte-stable serialization. `service` and `pdsUrl` are normalized through + * `new URL().toString()` for a stable trailing slash. * - * `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts - * (no didDoc PDS entry) keep `pdsUrl: undefined`, matching the old behavior. + * `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts (no + * didDoc PDS entry) keep `pdsUrl: undefined`. */ export function sessionDataToSessionAccount( session: SessionData | null | undefined, @@ -231,9 +222,7 @@ export function sessionDataToSessionAccount( * Convert a persisted `SessionAccount` back into `SessionData` for * constructing/resuming a `PasswordSession`. * - * Replaces `sessionAccountToSession`. Field order mirrors the shape returned - * by the server (roughly alphabetical, matching the old function). When the - * account has a stored `pdsUrl`, a synthetic didDoc is injected so + * When the account has a stored `pdsUrl`, a synthetic didDoc is injected so * `PasswordSession` routes requests to the right PDS before its first refresh * (see {@link synthDidDoc}). */ @@ -259,11 +248,7 @@ export function sessionAccountToSessionData( /** * The service (entryway) URL for a session, or the public appview URL when - * logged out / destroyed. - * - * Byte-identical to the derivation the old service getter used: a - * `new URL(...)` over `session.session.service` when the session is live, else - * `PUBLIC_BSKY_SERVICE`. Used for the {@link SessionBundle.service} getter. + * logged out / destroyed. Backs the {@link SessionBundle.service} getter. */ function deriveServiceUrl(session: PasswordSession | null): URL { return new URL( @@ -322,25 +307,19 @@ export function registerBundleKillSwitch( /** * Assemble a {@link SessionBundle} from a live session: the account, appview, - * and chat clients, all read-through views over the one session. The appview - * proxy header is baked into `buildAppviewClient` (`service: api.app.service`), - * so no separate proxy configuration is needed here. + * and chat clients, all read-through views over the one session. */ export function buildBundle(session: PasswordSession): SessionBundle { return { session, accountClient: buildAccountClient(session), /* - * Per-account labelers are applied to the appview client by - * configureModerationForAccount; buildAppviewClient carries only the base - * Bluesky moderation labeler until then. + * Starts with an empty per-account labeler set; configureModerationForAccount + * applies this account's labelers afterwards. */ appviewClient: buildAppviewClient(session, []), chatClient: buildChatClient(session), - /* - * Derived from the session so the reducer's opaque view can read `.service`. - * A getter keeps it live with the session's state (destroyed -> public). - */ + /* A getter keeps `.service` live with the session's state (destroyed -> public). */ get service() { return deriveServiceUrl(session) }, @@ -350,20 +329,19 @@ export function buildBundle(session: PasswordSession): SessionBundle { /** * The session-change callback the provider passes into the hooks. * - * `PasswordSession` surfaces three hooks (`onUpdated`/`onDeleted`/ - * `onUpdateFailure`) which {@link makeSessionHooks} maps into the - * {@link AtpSessionEvent} vocabulary: refresh -> `'update'`, dead session/logout - * -> `'expired'`, transient failure -> `'network-error'`. The whole - * {@link SessionBundle} is handed through so the provider can snapshot the live - * session and use the bundle itself as the reducer's identity token. + * The whole {@link SessionBundle} is handed through so the provider can snapshot + * the live session and use the bundle itself as the reducer's identity token. * - * `sessionData` is the fresh payload the library hands the hook. It matters - * because `PasswordSession` fires `onUpdated`/`onDeleted` BEFORE committing + * `sessionData` is the payload the library hands the hook. It is present on + * BOTH the `'update'` path (the fresh, rotated session) and the `'expired'` + * path (the DYING session's data, which `refresh()` passes to `onDeleted` + * BEFORE it nulls its internal `#sessionData`). It matters because + * `PasswordSession` fires `onUpdated`/`onDeleted` BEFORE committing * `#sessionData` (see `refresh()`/`logout()` in password-session.js), so the * live getter (`bundle.session.session`) still returns the OLD tokens at hook - * time. The provider must build the refreshed account from this argument, not - * from the live getter. Present on the `'update'` path (the new session) and - * absent on the error paths. + * time. On `'update'` the provider builds the refreshed account from this + * argument; on `'expired'` it reads the dying refreshJwt from it to drive the + * compare-and-rescue at the dispatch site. */ type OnSessionChange = ( bundle: SessionBundle, @@ -376,31 +354,19 @@ type OnSessionChange = ( * Build the `PasswordSession` hooks with an arm latch. * * `PasswordSession` fires `onUpdated` once during login/resume/createAccount - * (before the factory returns). We must NOT dispatch that initial event to the - * reducer - it corresponds to today's dropped `'create'` event, which never - * reached the reducer because `persistSessionHandler` was still undefined - * during `prepare()`. So hooks are inert until `arm()` is called, after the - * prepare tail resolves. + * before the factory returns. We must NOT dispatch that initial event, so hooks + * stay inert until `arm()` is called after the prepare tail resolves. * * `getBundle` is deferred because the bundle does not exist yet when the hooks - * are constructed (the session is created first, then the bundle is built over - * it). + * are constructed (session first, then bundle built over it). * - * The hooks thread the fresh `SessionData` the library delivers straight - * through to `onSessionChange` (the `'update'` payload). The library fires the - * hook BEFORE committing that data internally, so the provider must read tokens - * from this argument rather than the (still-stale) live session getter. - * - * The `fetch` option is wrapped in a kill-switch: `kill()` (returned alongside - * `arm()`) sets a closure flag so every subsequent request through this - * session - direct fetches AND the internal auto-refresh, which - * `PasswordSession` routes through the same `options.fetch` captured at - * construction - throws instead of hitting the network. `kill()` also disarms - * the hooks so a disposed session can never dispatch into the reducer. This is - * the disposal mechanism {@link disposeBundle} relies on (`PasswordSession` - * exposes no local destroy). - * - * Exported for testing (the arm-latch + event mapping is the core semantics). + * The `fetch` option is wrapped in a kill-switch: `kill()` sets a closure flag + * so every subsequent request through this session - direct fetches AND the + * internal auto-refresh, which `PasswordSession` routes through the same + * captured `options.fetch` - throws instead of hitting the network. `kill()` + * also disarms the hooks so a disposed session can never dispatch into the + * reducer. This is the disposal mechanism {@link disposeBundle} relies on + * (`PasswordSession` exposes no local destroy). */ export function makeSessionHooks( onSessionChange: OnSessionChange, @@ -415,11 +381,7 @@ export function makeSessionHooks( } const did = getDid() onSessionChange(getBundle(), did, event, sessionData) - /* - * Mirror the old BskyAppAgent.prepare wiring: log any non-create/update - * session event. In practice we only emit 'update'/'expired'/'network-error' - * here, so this logs the error-ish ones. - */ + // Log the error-ish events ('expired'/'network-error'). if (event !== 'create' && event !== 'update') { addSessionErrorLog(did, event) } @@ -434,8 +396,8 @@ export function makeSessionHooks( onUpdated(data) { dispatch('update', data) }, - onDeleted() { - dispatch('expired') + onDeleted(data) { + dispatch('expired', data) }, onUpdateFailure() { dispatch('network-error') @@ -463,8 +425,7 @@ export type PublicSessionBundle = { /** * The throwing unauthenticated client (NOT the public client): chat is * meaningless logged out, and `useChatClient()` must fail loudly rather than - * silently target the public appview. See {@link getUnauthenticatedClient} - * and design section J. + * silently target the public appview. See {@link getUnauthenticatedClient}. */ chatClient: Client /** The public appview URL. See {@link SessionBundle.service}. */ @@ -481,11 +442,10 @@ export function createPublicSessionBundle(): PublicSessionBundle { return { session: null, /* - * Write/auth clients throw on use when logged out (design section J): the - * public bundle exposes the throwing unauthenticated client for the account - * (PDS) and chat clients so an unauthenticated write or chat call fails - * loudly instead of silently targeting the public appview. Reads keep the - * public client (appviewClient), which reads public data without auth. + * The account (PDS) and chat clients throw on use when logged out, so an + * unauthenticated write or chat call fails loudly instead of silently + * targeting the public appview. Reads keep the public client (appviewClient), + * which reads public data without auth. */ accountClient: getUnauthenticatedClient(), appviewClient: publicClient, @@ -495,12 +455,9 @@ export function createPublicSessionBundle(): PublicSessionBundle { } /** - * Resume a stored account into a {@link SessionBundle}. - * - * Preserves the old `createAgentAndResume` behavior: prefer-low-latency gates - * refresh (not awaited up front), a network resume with one retry for expired - * sessions, and a synchronous no-network fast path for still-valid stored - * tokens. The session hooks are armed only after the prepare tail resolves. + * Resume a stored account into a {@link SessionBundle}. Expired sessions take a + * network resume (one retry); still-valid stored tokens take a synchronous + * no-network fast path. Hooks are armed only after the prepare tail resolves. */ export async function createSessionBundleAndResume( storedAccount: SessionAccount, @@ -525,10 +482,7 @@ export async function createSessionBundleAndResume( PasswordSession.resume(sessionData, hooks), ) } else { - /* - * Sync fast path: trust the stored tokens, no network. Matches the old - * `agent.sessionManager.session = prev`. - */ + // Sync fast path: trust the stored tokens, no network. session = new PasswordSession(sessionData, hooks) } @@ -543,19 +497,18 @@ export async function createSessionBundleAndResume( sessionDataToSessionAccount(session.session, session.session.service) ?? storedAccount - const moderation = configureModerationForAccount(bundle, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: bundle.appviewClient, accountClient: bundle.accountClient, }) - await Promise.all([gates, moderation, aa]) + await Promise.all([gates, aa]) /* * Re-snapshot AFTER prep, right before arm(). A 401 during a prep request * (e.g. the AA prefetch) triggers PasswordSession's internal auto-refresh, * which rotates both tokens; its onUpdated is dropped by the still-disarmed - * latch. Snapshotting the returned account here (not before prep) ensures we - * persist the fresh refreshJwt rather than a stale one that is dead on the - * next cold start. + * latch. Snapshotting here (not before prep) persists the fresh refreshJwt + * rather than a stale one that is dead on the next cold start. */ const account = sessionDataToSessionAccount(session.session, session.session.service) ?? @@ -566,9 +519,6 @@ export async function createSessionBundleAndResume( /** * Log in with credentials and build a {@link SessionBundle}. - * - * Preserves `createAgentAndLogin`: `allowTakendown: true`, prefer-fresh-gates - * refresh, moderation + AA prefetch, and the deferred arm. */ export async function createSessionBundleAndLogin( { @@ -603,26 +553,23 @@ export async function createSessionBundleAndLogin( bundle = buildBundle(session) registerBundleKillSwitch(bundle, hooks.kill) - /* - * Early snapshot: needed now to seed `accountDid` (the getDid closure the - * hooks read). The RETURNED account is re-snapshotted after the prep awaits. - */ + // Early snapshot: needed now to seed `accountDid` (the getDid closure). const earlyAccount = sessionDataToSessionAccountOrThrow(session) accountDid = earlyAccount.did const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - const moderation = configureModerationForAccount(bundle, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: bundle.appviewClient, accountClient: bundle.accountClient, }) - await Promise.all([gates, moderation, aa]) + await Promise.all([gates, aa]) /* - * Re-snapshot AFTER prep, right before arm(). A 401 during a prep request + * Re-snapshot AFTER prep, right before arm(): a 401 during a prep request * triggers PasswordSession's internal auto-refresh, which rotates both tokens - * and fires an onUpdated the disarmed latch drops; snapshotting here persists - * the fresh refreshJwt. If the session was destroyed mid-prep, OrThrow throws - * (login effectively failed). + * and fires an onUpdated the disarmed latch drops, so this persists the fresh + * refreshJwt. If the session was destroyed mid-prep, OrThrow throws (login + * effectively failed). */ const account = sessionDataToSessionAccountOrThrow(session) hooks.arm() @@ -630,13 +577,10 @@ export async function createSessionBundleAndLogin( } /** - * Create an account and build a {@link SessionBundle}. - * - * Preserves `createAgentAndCreateAccount` verbatim: local sync writes for - * created-at/birthdate, the prod vs non-prod deferred server-write block - * (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids, - * restrictChatSettings gated on AA flags), and snoozeEmailConfirmationPrompt. - * The deferred writes run as SDK actions against the account (PDS) client. + * Create an account and build a {@link SessionBundle}. Writes created-at and + * birthdate locally for sync reads, then fires the deferred server-write block + * (personal details, profile, saved feeds, and AA-gated chat restrictions) as + * SDK actions against the account (PDS) client. */ export async function createSessionBundleAndCreateAccount( { @@ -685,15 +629,13 @@ export async function createSessionBundleAndCreateAccount( registerBundleKillSwitch(bundle, hooks.kill) /* * Early snapshot: needed now to seed `accountDid` and for the DID/handle used - * across the local writes and deferred server writes below (all - * refresh-stable). The RETURNED account is re-snapshotted after the prep - * awaits. + * across the local and deferred server writes below (all refresh-stable). */ const earlyAccount = sessionDataToSessionAccountOrThrow(session) accountDid = earlyAccount.did const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - const moderation = configureModerationForAccount(bundle, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const createdAt = toDatetimeString(new Date()) const birthdate = birthDate.toISOString() @@ -819,13 +761,12 @@ export async function createSessionBundleAndCreateAccount( }) } - await Promise.all([gates, moderation, aa]) + await Promise.all([gates, aa]) /* - * Re-snapshot AFTER prep, right before arm(). A 401 during a prep request + * Re-snapshot AFTER prep, right before arm(): a 401 during a prep request * triggers PasswordSession's internal auto-refresh, which rotates both tokens - * and fires an onUpdated the disarmed latch drops; snapshotting here persists - * the fresh refreshJwt rather than a stale one. If the session was destroyed - * mid-prep, OrThrow throws. + * and fires an onUpdated the disarmed latch drops, so this persists the fresh + * refreshJwt. If the session was destroyed mid-prep, OrThrow throws. */ const account = sessionDataToSessionAccountOrThrow(session) hooks.arm() @@ -834,7 +775,7 @@ export async function createSessionBundleAndCreateAccount( /** * Snapshot a live session as a `SessionAccount`, throwing if there is no active - * session. Replacement for the old `agentToSessionAccountOrThrow`. + * session. */ function sessionDataToSessionAccountOrThrow( session: PasswordSession, @@ -863,9 +804,8 @@ function sessionDataToSessionAccountOrThrow( * captured `options.fetch` - throws before touching the network. A tripped * refresh routes into the `onUpdateFailure` path (session preserved locally, * refresh token NOT consumed server-side). `kill()` also disarms the hooks so - * the stale bundle can no longer dispatch into the reducer. The important - * guarantee - matching the old `dispose()` - is that this session's tokens are - * no longer reachable by any live network path. + * the stale bundle can no longer dispatch into the reducer. The guarantee: this + * session's tokens are no longer reachable by any live network path. */ export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) { const session = bundle.session @@ -874,3 +814,55 @@ export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) { } bundleKillSwitches.get(bundle)?.() } + +/** + * Hard bound on how many distinct refresh-token generations the expiry rescue + * will burn through for a single did before giving up and logging out. Each + * rescue consumes a strictly newer generation (a token that differs from every + * one already recorded as failed), so this set can only grow one entry per + * expiry and this cap guarantees termination even under a pathological storm + * of expiries against ever-newer tokens. + */ +export const MAX_EXPIRY_RESCUE_GENERATIONS = 5 + +/** + * Pure decision for the cross-tab expiry rescue (side-effecting rebuild stays + * in the provider). Given the dying session's refreshJwt and the "latest known" + * candidate accounts for that did (in preference order), pick the first + * candidate that carries a usable, strictly-newer generation: + * + * - has a non-empty `refreshJwt`, + * - whose `refreshJwt` DIFFERS from the dying one (a same-token candidate is + * just as dead), and + * - whose `refreshJwt` is NOT already recorded as failed (loop guard). + * + * Returns `undefined` (fall through to logout) when nothing qualifies or the + * failed-generation set has hit {@link MAX_EXPIRY_RESCUE_GENERATIONS}. + * + * `candidates` are tried in order, so the caller passes its most-authoritative + * source first (on web, the fresh persisted re-read before the reducer state). + */ +export function pickExpiryRescueCandidate({ + dyingRefreshJwt, + candidates, + failedRefreshJwts, +}: { + dyingRefreshJwt: string + candidates: (SessionAccount | undefined)[] + failedRefreshJwts: ReadonlySet +}): SessionAccount | undefined { + if (failedRefreshJwts.size >= MAX_EXPIRY_RESCUE_GENERATIONS) { + return undefined + } + for (const candidate of candidates) { + const refreshJwt = candidate?.refreshJwt + if ( + refreshJwt && + refreshJwt !== dyingRefreshJwt && + !failedRefreshJwts.has(refreshJwt) + ) { + return candidate + } + } + return undefined +}