collapse cross-tab rebuild to a single synchronous tick

With moderation config sync, the bundle swap no longer defers behind
an async labeler read. The race guard from the review stays as a
defensive invariant against reintroducing an await.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 13:19:47 +03:00
parent 63dad6320e
commit 44b0ba060b
3 changed files with 605 additions and 258 deletions
+143 -21
View File
@@ -19,7 +19,18 @@ jest.mock('#/state/events', () => ({
* bottom-sheet module chain (same approach as session-test.ts). * bottom-sheet module chain (same approach as session-test.ts).
*/ */
jest.mock('#/state/birthdate') 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<void>>()
jest.mock('#/ageAssurance/data', () => ({
prefetchAgeAssuranceServerData: () => mockPrefetchAgeAssuranceServerData(),
setBirthdateForDid: () => {},
setCreatedAtForDid: () => {},
}))
jest.mock('#/ageAssurance/state', () => ({ jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}), unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
})) }))
@@ -37,16 +48,18 @@ jest.mock('#/analytics', () => ({
})) }))
/* /*
* `configureModerationForAccount` is one of the prep awaits in the resume/login * `configureModerationForAccount` is now fully synchronous (the labeler cache
* factories. We replace it with a hook that runs a REAL `session.refresh()`, so * is a local MMKV read), so it is no longer a prep await - but it still runs
* a token rotation happens DURING prep (before arm()) - exactly the 401 * inside each factory with the freshly built bundle, before the awaited prep
* auto-refresh scenario the re-snapshot fix guards against. The default is a * steps. The fix-1 tests use this mock to CAPTURE the bundle, then inject a
* no-op so other tests are unaffected; individual tests install the refreshing * REAL `session.refresh()` into the awaited AA prefetch (see the
* behavior via `mockImplementationOnce`. (jest requires out-of-scope factory * `#/ageAssurance/data` mock above), so a token rotation happens DURING prep
* references to be `mock`-prefixed.) * (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 = const mockConfigureModerationForAccount =
jest.fn<(bundle: unknown, account: unknown) => Promise<void>>() jest.fn<(bundle: unknown, account: unknown) => void>()
jest.mock('../moderation', () => ({ jest.mock('../moderation', () => ({
configureModerationForAccount: (bundle: unknown, account: unknown) => configureModerationForAccount: (bundle: unknown, account: unknown) =>
mockConfigureModerationForAccount(bundle, account), mockConfigureModerationForAccount(bundle, account),
@@ -75,6 +88,8 @@ import {
disposeBundle, disposeBundle,
extractPdsUrl, extractPdsUrl,
makeSessionHooks, makeSessionHooks,
MAX_EXPIRY_RESCUE_GENERATIONS,
pickExpiryRescueCandidate,
registerBundleKillSwitch, registerBundleKillSwitch,
sessionAccountToSessionData, sessionAccountToSessionData,
type SessionBundle, type SessionBundle,
@@ -514,17 +529,21 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
expect(onSessionChange.mock.calls[0][2]).toBe('network-error') 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 * onDeleted maps to 'expired' AND threads the dying SessionData through
* `event === 'update' && sessionData`, so a missing payload here is what * (the library hands onDeleted the session being destroyed, before it nulls
* forces refreshedAccount === undefined (reducer clears tokens + logs out). * 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() const {onSessionChange, hooks} = setup()
hooks.arm() hooks.arm()
void hooks.onDeleted?.call(fakeSession, fakeData) void hooks.onDeleted?.call(fakeSession, fakeData)
expect(onSessionChange.mock.calls[0][2]).toBe('expired') 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 refreshedAccountAtHookTime: SessionAccount | undefined = makeAccount()
let observedEvent: AtpSessionEvent | undefined let observedEvent: AtpSessionEvent | undefined
let observedSessionData: SessionData | undefined
const onSessionChange = jest.fn( const onSessionChange = jest.fn(
( (
_bundle: SessionBundle, _bundle: SessionBundle,
@@ -611,6 +631,7 @@ describe('session-hook payload threading (pre-commit ordering)', () => {
sessionData?: SessionData, sessionData?: SessionData,
) => { ) => {
observedEvent = event observedEvent = event
observedSessionData = sessionData
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData) refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
}, },
) )
@@ -628,6 +649,13 @@ describe('session-hook payload threading (pre-commit ordering)', () => {
await expect(session.refresh()).rejects.toBeDefined() await expect(session.refresh()).rejects.toBeDefined()
expect(observedEvent).toBe('expired') 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) expect(refreshedAccountAtHookTime).toBe(undefined)
}) })
}) })
@@ -827,9 +855,10 @@ describe('refreshSession semantics', () => {
* disarmed latch drops); an early snapshot would persist the stale refreshJwt, * disarmed latch drops); an early snapshot would persist the stale refreshJwt,
* which is dead on the next cold start. * which is dead on the next cold start.
* *
* We simulate the mid-prep rotation by making the mocked * We simulate the mid-prep rotation by capturing the bundle from the mocked
* `configureModerationForAccount` (a genuine prep await in each factory) run a * (now synchronous) `configureModerationForAccount` and making the mocked
* real `session.refresh()`. The factory itself is re-required inside * `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 * `jest.isolateModulesAsync` AFTER overriding `globalThis.fetch`, because
* session-core captures `globalThis.fetch` into `networkAwareFetch` at module * session-core captures `globalThis.fetch` into `networkAwareFetch` at module
* load - and that captured fetch is what PasswordSession's auto-refresh routes * 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(() => { beforeEach(() => {
mockConfigureModerationForAccount.mockReset() mockConfigureModerationForAccount.mockReset()
mockPrefetchAgeAssuranceServerData.mockReset()
}) })
it('resume: returned account carries the tokens rotated DURING prep', async () => { 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. * 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 * `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( mockConfigureModerationForAccount.mockImplementationOnce(
async (bundle: unknown) => { (bundle: unknown) => {
await (bundle as SessionBundle).session.refresh() capturedBundle = bundle as SessionBundle
}, },
) )
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
await capturedBundle!.session.refresh()
})
const fetchMock = makeMockFetch() const fetchMock = makeMockFetch()
await withFreshFactory(asFetch(fetchMock), async core => { 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 * valid) stored tokens, confirming the moved snapshot did not regress the
* happy path. * happy path.
*/ */
mockConfigureModerationForAccount.mockResolvedValueOnce(undefined) mockConfigureModerationForAccount.mockReturnValueOnce(undefined)
const fetchMock = makeMockFetch() const fetchMock = makeMockFetch()
await withFreshFactory(asFetch(fetchMock), async core => { 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<string>()
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)
})
})
+300 -67
View File
@@ -29,6 +29,7 @@ import {
createSessionBundleAndResume, createSessionBundleAndResume,
disposeBundle, disposeBundle,
makeSessionHooks, makeSessionHooks,
pickExpiryRescueCandidate,
type PublicSessionBundle, type PublicSessionBundle,
registerBundleKillSwitch, registerBundleKillSwitch,
sessionAccountToSessionData, sessionAccountToSessionData,
@@ -128,6 +129,32 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const state = useSyncExternalStore(store.subscribe, store.getState) const state = useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch() 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<Map<string, Set<string>>>(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( const onSessionChange = useCallback(
( (
bundle: SessionBundle, bundle: SessionBundle,
@@ -135,6 +162,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
sessionEvent: AtpSessionEvent, sessionEvent: AtpSessionEvent,
sessionData?: SessionData, 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 * Build the refreshed account from the payload the hook delivers, NOT the
* live session getter. `PasswordSession` fires onUpdated/onDeleted BEFORE * live session getter. `PasswordSession` fires onUpdated/onDeleted BEFORE
@@ -151,17 +187,154 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
sessionEvent === 'update' && sessionData sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount(sessionData, sessionData.service) ? sessionDataToSessionAccount(sessionData, sessionData.service)
: undefined : 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() emitSessionDropped()
} }
/* /*
* The reducer stores the whole bundle as `currentAgentState.agent` and * The bundle is the reducer's identity token: it stores the whole bundle
* compares `action.agent` by identity to decide whether an expiry/error * as `currentAgentState.agent` and compares `action.agent` by identity to
* belongs to the active account (background accounts must not be able to * decide whether an event belongs to the active account. A same-bundle
* log the current user out). The hook now hands us the bundle that fired, * event acts on the active account; a stale (background) bundle does not
* so it IS the identity token: a same-bundle event acts on the active * match, so its events are ignored (background accounts must not be able
* account, a stale (background) bundle does not match and its clears are * to log the current user out or resurrect tokens).
* ignored - matching the pre-migration semantics exactly.
*/ */
store.dispatch({ store.dispatch({
type: 'received-agent-event', type: 'received-agent-event',
@@ -173,6 +346,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, },
[store], [store],
) )
onSessionChangeRef.current = onSessionChange
const createAccount = useCallback<SessionApiContext['createAccount']>( const createAccount = useCallback<SessionApiContext['createAccount']>(
async (params, metrics) => { async (params, metrics) => {
@@ -307,6 +481,29 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
if (signal.aborted) { 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 return
} }
store.dispatch({ store.dispatch({
@@ -330,10 +527,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signal = cancelPendingTask() const signal = cancelPendingTask()
/* /*
* Fetch through the account (PDS) client and dispatch the patch. We do NOT * Fetch through the account (PDS) client and dispatch the patch. We do NOT
* mutate the session object anymore (PasswordSession's data is immutable to * mutate the session object (PasswordSession's data is immutable to us); the
* us); the reducer patches only the `accounts` entry, and the email-state * reducer patches only the `accounts` entry, and the email-state hook reads
* hook reads from the account rather than the session. * from the account rather than the session.
* `client.call` returns the response body directly (no `{data}` wrapper).
*/ */
const data = await bundle.accountClient.call(com.atproto.server.getSession) const data = await bundle.accountClient.call(com.atproto.server.getSession)
if (signal.aborted) return if (signal.aborted) return
@@ -355,12 +551,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
| PublicSessionBundle | PublicSessionBundle
if (!bundle.session) return undefined // logged out: nothing to refresh if (!bundle.session) return undefined // logged out: nothing to refresh
/* /*
* PasswordSession.refresh() re-runs com.atproto.server.refreshSession + * refresh() fires the session's onUpdated hook on success, which the armed
* getSession. On success the session's onUpdated hook fires, which the * hooks map to an 'update' event; the reducer snapshots the refreshed
* armed makeSessionHooks wiring maps to an 'update' event; the reducer * account, so no explicit dispatch is needed here. The returned snapshot
* snapshots the refreshed account. No explicit dispatch is needed here. * lets callers read post-refresh fields without waiting on the (async)
* The returned snapshot lets callers read post-refresh fields without * reducer update.
* waiting on the (async) reducer update.
*/ */
await bundle.session.refresh() await bundle.session.refresh()
return sessionDataToSessionAccount( return sessionDataToSessionAccount(
@@ -398,6 +593,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const syncedAccount = synced.accounts.find( const syncedAccount = synced.accounts.find(
a => a.did === synced.currentAccount?.did, 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 && syncedAccount.refreshJwt) {
if (syncedAccount.did !== state.currentAgentState.did) { if (syncedAccount.did !== state.currentAgentState.did) {
/* /*
@@ -421,13 +636,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
| SessionBundle | SessionBundle
| PublicSessionBundle | 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 * 'synced-accounts' dispatch above already keeps the accounts list
* fresh, so if the CURRENT account's tokens are unchanged there is * fresh. So if the CURRENT account's tokens are unchanged, a change to
* nothing to rebuild - a change to a non-current account landed here. * a non-current account landed here: bail out before rebuilding, since
* Bail out before rebuilding: rebuild+swap would kill the live bundle * rebuild+swap would kill the live bundle (client-identity churn,
* (client-identity churn, in-flight request kills) for no reason. * in-flight request kills) for no reason. Fall through to rebuild only
* Fall through to rebuild only when we have no usable live session. * when we have no usable live session.
*/ */
const live = const live =
prevBundle.session && !prevBundle.session.destroyed prevBundle.session && !prevBundle.session.destroyed
@@ -454,29 +669,47 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
registerBundleKillSwitch(newBundle, hooks.kill) registerBundleKillSwitch(newBundle, hooks.kill)
/* /*
* Reapply this account's subscribed labelers to the freshly built * Reapply this account's subscribed labelers to the freshly built
* appview client - buildBundle starts with an empty per-instance * appview client: buildBundle starts with an empty per-instance
* labeler set, and unlike login/resume/createAccount this rebuild * labeler set, and this rebuild path never runs
* path never runs configureModerationForAccount on its own. The * configureModerationForAccount on its own. It is fully synchronous
* bundle swap is deferred until the labeler config resolves so the * (the labeler cache is a local MMKV read), so the whole prep + arm +
* new bundle enters the reducer with its labelers already applied * dispatch sequence below runs in one tick from the onUpdate
* (matching the factories, which await moderation before returning). * broadcast - the new bundle enters the reducer with its labelers
* readLabelers is a local-storage read (microtask-scale, no network, * already applied, with no window where the new session is armed but
* preserving this branch's no-network intent), and the OLD bundle's * the reducer still holds the old bundle.
* 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.
*/ */
void configureModerationForAccount(newBundle, syncedAccount) configureModerationForAccount(newBundle, syncedAccount)
.catch(() => {}) /*
.finally(() => { * 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({ addSessionDebugLog({
type: 'agent:patch', type: 'agent:patch',
agent: newBundle, agent: newBundle,
@@ -486,6 +719,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
: undefined, : undefined,
nextSession: newBundle.session.session, 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 const newAccount = newBundle.session.destroyed
? syncedAccount ? syncedAccount
: (sessionDataToSessionAccount( : (sessionDataToSessionAccount(
@@ -498,11 +736,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: newBundle, newAgent: newBundle,
newAccount, newAccount,
}) })
})
} }
} }
}) })
}, [store, state, resumeSession, onSessionChange]) }, [store, state, resumeSession, onSessionChange, cancelPendingTask])
const stateContext = useMemo( const stateContext = useMemo(
() => ({ () => ({
@@ -621,10 +858,10 @@ export function useRequireAuth() {
/** /**
* Authenticated lex {@link Client} for appview reads. Backed by the active * Authenticated lex {@link Client} for appview reads. Backed by the active
* bundle's appview client (proxied to the Bluesky appview, with labelers). Its * 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 * identity is stable per-bundle. Falls back to the public client when there is
* changes. Falls back to the public client when there is no bundle (logged out, * no bundle (logged out, or used outside the provider) so callers can treat it
* or used outside the provider) so callers can treat it as always-present. * as always-present.
*/ */
export function useLexClient(): Client { export function useLexClient(): Client {
const bundle = useContext(BundleContext) 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 * 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). * mutations go here - requests hit the user's PDS directly (no appview proxy).
* *
* Logged-out contract: returns a stable throwing client * Logged out, returns a stable client ({@link getUnauthenticatedClient}) that
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any * throws `NotAuthenticatedError` before any network I/O, so an unauthenticated
* request, BEFORE any network I/O. This is the write path - it must NOT fall * write fails loudly rather than silently hitting `public.api.bsky.app`.
* back to the public appview, so an unauthenticated write fails immediately and * Components may safely hold this client while logged out; only calling it
* legibly rather than silently hitting `public.api.bsky.app`. Components may * throws. To branch on auth state, use {@link useMaybePdsClient} instead.
* safely hold this client while logged out; only calling it throws. A component
* that genuinely branches on auth state should use {@link useMaybePdsClient}.
*/ */
export function usePdsClient(): Client { export function usePdsClient(): Client {
const bundle = useContext(BundleContext) 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 * The chat lex {@link Client} for the active account. `chat.bsky.*` calls go
* here - proxied to `did:web:api.bsky.chat#bsky_chat`. * here - proxied to `did:web:api.bsky.chat#bsky_chat`.
* *
* Logged-out contract: returns a stable throwing client * Logged out, returns a stable client ({@link getUnauthenticatedClient}) that
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any * throws `NotAuthenticatedError` before any network I/O. Chat is meaningless
* request, BEFORE any network I/O. Chat is meaningless logged out, so this must * logged out, so this must NOT fall back to the public appview. To branch on
* NOT fall back to the public appview. A component that genuinely branches on * auth state, use {@link useMaybeChatClient} instead.
* auth state should use {@link useMaybeChatClient}.
*/ */
export function useChatClient(): Client { export function useChatClient(): Client {
const bundle = useContext(BundleContext) 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 * The escape hatch for the rare component that genuinely renders a logged-out
* branch and must decide whether a write path is available. Prefer * branch and must decide whether a write path is available. Prefer
* {@link usePdsClient} for the common case (a write only reachable while * {@link usePdsClient} for the common case; do NOT reach for this hook merely to
* authenticated); do NOT reach for this hook merely to dodge the throwing * dodge the throwing client's `NotAuthenticatedError`.
* client's `NotAuthenticatedError`.
*/ */
export function useMaybePdsClient(): Client | null { export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext) const bundle = useContext(BundleContext)
+141 -149
View File
@@ -51,14 +51,10 @@ import {type SessionAccount} from './types'
import {isSessionExpired} from './util' import {isSessionExpired} from './util'
/** /**
* The session-change events the reducer/logging/tests speak. * The session-change events the reducer/logging/tests speak. In production only
* * `'update'`/`'expired'`/`'network-error'` are ever emitted from
* Formerly re-exported from the legacy API package; defined locally now that * {@link makeSessionHooks}; `'create'`/`'create-failed'` exist only for the
* the bridge is gone. These are the exact union members the reducer switches * reducer and the session tests.
* 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.
*/ */
export type AtpSessionEvent = export type AtpSessionEvent =
| 'create' | 'create'
@@ -71,9 +67,9 @@ export type AtpSessionEvent =
* Whether an access token was issued for a queued (waitlisted) signup rather * Whether an access token was issued for a queued (waitlisted) signup rather
* than a full session. * than a full session.
* *
* Canonical implementation - util.ts re-exports it. It lives here (rather * Canonical implementation - util.ts re-exports it. It lives here (rather than
* than util.ts) so this module stays dependency-light: util.ts pulls in * util.ts) so this module stays dependency-light: util.ts transitively pulls in
* agent.ts and, transitively, a large chunk of the app. * a large chunk of the app.
*/ */
export function isSignupQueued(accessJwt: string | undefined) { export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) { 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 * Captured once at module load so the wrapper below is immune to later
* monkey-patching of globalThis.fetch (mirrors the old BskyAppAgent fetch). * monkey-patching of globalThis.fetch.
*/ */
const realFetch = 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 * Any resolved response (including HTTP errors) confirms the network is up; a
* thrown error (DNS failure, timeout, offline) reports it as lost. * thrown error (DNS failure, timeout, offline) reports it as lost.
* *
* This replaces the custom `fetch` previously passed to `BskyAppAgent`. It is * Passed as `PasswordSessionOptions.fetch` and as the `fetch` option of
* intended to be passed as `PasswordSessionOptions.fetch` and as the `fetch` * unauthenticated lex `Client`s, so every network path in the session stack
* option of unauthenticated lex `Client`s, so every network path in the * feeds the same reachability signal.
* session stack feeds the same reachability signal.
*/ */
export const networkAwareFetch: typeof fetch = async (...args) => { export const networkAwareFetch: typeof fetch = async (...args) => {
try { 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. * Extract the PDS endpoint URL from a DID document, if present and valid.
* *
* Local reimplementation of `@atproto/lex-password-session`'s private * Must stay behaviorally identical to `@atproto/lex-password-session`'s private
* `extractPdsUrl` util (it lives in a non-exported module, so we cannot import * `extractPdsUrl` (non-exported, so we reimplement it): `PasswordSession.
* it). Must stay behaviorally identical: `PasswordSession.fetchHandler` * fetchHandler` derives its request origin as `extractPdsUrl(didDoc) ?? service`,
* derives its request origin as `extractPdsUrl(didDoc) ?? service`, and we use * and we reuse this derivation to persist `pdsUrl` on the account snapshot.
* this same derivation to persist `pdsUrl` on the account snapshot.
*/ */
export function extractPdsUrl(didDoc: unknown): string | null { export function extractPdsUrl(didDoc: unknown): string | null {
if (typeof didDoc !== 'object' || didDoc === 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 * Build a minimal synthetic DID document whose only service entry is the given
* given PDS endpoint. * 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 * requests via `extractPdsUrl(didDoc) ?? service`. On the non-expired resume
* fast path (no network), we synthesize this doc from the stored `pdsUrl` so * fast path (no network) we synthesize this doc from the stored `pdsUrl` so the
* the very first requests hit the right PDS (entryway accounts have * very first requests hit the right PDS (entryway accounts have
* service=bsky.social but a different PDS host). After the first refresh, * service=bsky.social but a different PDS host). After the first refresh,
* `PasswordSession` refetches `getSession` and replaces this with the real * `PasswordSession` refetches `getSession` and replaces it with the real doc.
* DID document.
*/ */
export function synthDidDoc( export function synthDidDoc(
did: string, did: string,
@@ -191,15 +184,13 @@ export function synthDidDoc(
* Convert live `PasswordSession` session data into the persisted * Convert live `PasswordSession` session data into the persisted
* `SessionAccount` snapshot. * `SessionAccount` snapshot.
* *
* Replaces `agentToSessionAccount`. The object literal's field ORDER must * The object literal's field ORDER is load-bearing: the reducer's
* match the old `agentToSessionAccount` exactly - the reducer's
* `JSON.stringify` fast path and the session test snapshots depend on * `JSON.stringify` fast path and the session test snapshots depend on
* byte-stable serialization. `service` is normalized through `new URL()` to * byte-stable serialization. `service` and `pdsUrl` are normalized through
* keep the trailing slash the old `agent.serviceUrl.toString()` produced, and * `new URL().toString()` for a stable trailing slash.
* `pdsUrl` likewise (the old code read `agent.pdsUrl?.toString()`, a URL).
* *
* `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts * `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts (no
* (no didDoc PDS entry) keep `pdsUrl: undefined`, matching the old behavior. * didDoc PDS entry) keep `pdsUrl: undefined`.
*/ */
export function sessionDataToSessionAccount( export function sessionDataToSessionAccount(
session: SessionData | null | undefined, session: SessionData | null | undefined,
@@ -231,9 +222,7 @@ export function sessionDataToSessionAccount(
* Convert a persisted `SessionAccount` back into `SessionData` for * Convert a persisted `SessionAccount` back into `SessionData` for
* constructing/resuming a `PasswordSession`. * constructing/resuming a `PasswordSession`.
* *
* Replaces `sessionAccountToSession`. Field order mirrors the shape returned * When the account has a stored `pdsUrl`, a synthetic didDoc is injected so
* by the server (roughly alphabetical, matching the old function). When the
* account has a stored `pdsUrl`, a synthetic didDoc is injected so
* `PasswordSession` routes requests to the right PDS before its first refresh * `PasswordSession` routes requests to the right PDS before its first refresh
* (see {@link synthDidDoc}). * (see {@link synthDidDoc}).
*/ */
@@ -259,11 +248,7 @@ export function sessionAccountToSessionData(
/** /**
* The service (entryway) URL for a session, or the public appview URL when * The service (entryway) URL for a session, or the public appview URL when
* logged out / destroyed. * logged out / destroyed. Backs the {@link SessionBundle.service} getter.
*
* 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.
*/ */
function deriveServiceUrl(session: PasswordSession | null): URL { function deriveServiceUrl(session: PasswordSession | null): URL {
return new URL( return new URL(
@@ -322,25 +307,19 @@ export function registerBundleKillSwitch(
/** /**
* Assemble a {@link SessionBundle} from a live session: the account, appview, * Assemble a {@link SessionBundle} from a live session: the account, appview,
* and chat clients, all read-through views over the one session. The appview * and chat clients, all read-through views over the one session.
* proxy header is baked into `buildAppviewClient` (`service: api.app.service`),
* so no separate proxy configuration is needed here.
*/ */
export function buildBundle(session: PasswordSession): SessionBundle { export function buildBundle(session: PasswordSession): SessionBundle {
return { return {
session, session,
accountClient: buildAccountClient(session), accountClient: buildAccountClient(session),
/* /*
* Per-account labelers are applied to the appview client by * Starts with an empty per-account labeler set; configureModerationForAccount
* configureModerationForAccount; buildAppviewClient carries only the base * applies this account's labelers afterwards.
* Bluesky moderation labeler until then.
*/ */
appviewClient: buildAppviewClient(session, []), appviewClient: buildAppviewClient(session, []),
chatClient: buildChatClient(session), chatClient: buildChatClient(session),
/* /* A getter keeps `.service` live with the session's state (destroyed -> public). */
* 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).
*/
get service() { get service() {
return deriveServiceUrl(session) return deriveServiceUrl(session)
}, },
@@ -350,20 +329,19 @@ export function buildBundle(session: PasswordSession): SessionBundle {
/** /**
* The session-change callback the provider passes into the hooks. * The session-change callback the provider passes into the hooks.
* *
* `PasswordSession` surfaces three hooks (`onUpdated`/`onDeleted`/ * The whole {@link SessionBundle} is handed through so the provider can snapshot
* `onUpdateFailure`) which {@link makeSessionHooks} maps into the * the live session and use the bundle itself as the reducer's identity token.
* {@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.
* *
* `sessionData` is the fresh payload the library hands the hook. It matters * `sessionData` is the payload the library hands the hook. It is present on
* because `PasswordSession` fires `onUpdated`/`onDeleted` BEFORE committing * 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 * `#sessionData` (see `refresh()`/`logout()` in password-session.js), so the
* live getter (`bundle.session.session`) still returns the OLD tokens at hook * live getter (`bundle.session.session`) still returns the OLD tokens at hook
* time. The provider must build the refreshed account from this argument, not * time. On `'update'` the provider builds the refreshed account from this
* from the live getter. Present on the `'update'` path (the new session) and * argument; on `'expired'` it reads the dying refreshJwt from it to drive the
* absent on the error paths. * compare-and-rescue at the dispatch site.
*/ */
type OnSessionChange = ( type OnSessionChange = (
bundle: SessionBundle, bundle: SessionBundle,
@@ -376,31 +354,19 @@ type OnSessionChange = (
* Build the `PasswordSession` hooks with an arm latch. * Build the `PasswordSession` hooks with an arm latch.
* *
* `PasswordSession` fires `onUpdated` once during login/resume/createAccount * `PasswordSession` fires `onUpdated` once during login/resume/createAccount
* (before the factory returns). We must NOT dispatch that initial event to the * before the factory returns. We must NOT dispatch that initial event, so hooks
* reducer - it corresponds to today's dropped `'create'` event, which never * stay inert until `arm()` is called after the prepare tail resolves.
* reached the reducer because `persistSessionHandler` was still undefined
* during `prepare()`. So hooks are inert until `arm()` is called, after the
* prepare tail resolves.
* *
* `getBundle` is deferred because the bundle does not exist yet when the hooks * `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 * are constructed (session first, then bundle built over it).
* it).
* *
* The hooks thread the fresh `SessionData` the library delivers straight * The `fetch` option is wrapped in a kill-switch: `kill()` sets a closure flag
* through to `onSessionChange` (the `'update'` payload). The library fires the * so every subsequent request through this session - direct fetches AND the
* hook BEFORE committing that data internally, so the provider must read tokens * internal auto-refresh, which `PasswordSession` routes through the same
* from this argument rather than the (still-stale) live session getter. * captured `options.fetch` - throws instead of hitting the network. `kill()`
* * also disarms the hooks so a disposed session can never dispatch into the
* The `fetch` option is wrapped in a kill-switch: `kill()` (returned alongside * reducer. This is the disposal mechanism {@link disposeBundle} relies on
* `arm()`) sets a closure flag so every subsequent request through this * (`PasswordSession` exposes no local destroy).
* 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).
*/ */
export function makeSessionHooks( export function makeSessionHooks(
onSessionChange: OnSessionChange, onSessionChange: OnSessionChange,
@@ -415,11 +381,7 @@ export function makeSessionHooks(
} }
const did = getDid() const did = getDid()
onSessionChange(getBundle(), did, event, sessionData) onSessionChange(getBundle(), did, event, sessionData)
/* // Log the error-ish events ('expired'/'network-error').
* 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.
*/
if (event !== 'create' && event !== 'update') { if (event !== 'create' && event !== 'update') {
addSessionErrorLog(did, event) addSessionErrorLog(did, event)
} }
@@ -434,8 +396,8 @@ export function makeSessionHooks(
onUpdated(data) { onUpdated(data) {
dispatch('update', data) dispatch('update', data)
}, },
onDeleted() { onDeleted(data) {
dispatch('expired') dispatch('expired', data)
}, },
onUpdateFailure() { onUpdateFailure() {
dispatch('network-error') dispatch('network-error')
@@ -463,8 +425,7 @@ export type PublicSessionBundle = {
/** /**
* The throwing unauthenticated client (NOT the public client): chat is * The throwing unauthenticated client (NOT the public client): chat is
* meaningless logged out, and `useChatClient()` must fail loudly rather than * meaningless logged out, and `useChatClient()` must fail loudly rather than
* silently target the public appview. See {@link getUnauthenticatedClient} * silently target the public appview. See {@link getUnauthenticatedClient}.
* and design section J.
*/ */
chatClient: Client chatClient: Client
/** The public appview URL. See {@link SessionBundle.service}. */ /** The public appview URL. See {@link SessionBundle.service}. */
@@ -481,11 +442,10 @@ export function createPublicSessionBundle(): PublicSessionBundle {
return { return {
session: null, session: null,
/* /*
* Write/auth clients throw on use when logged out (design section J): the * The account (PDS) and chat clients throw on use when logged out, so an
* public bundle exposes the throwing unauthenticated client for the account * unauthenticated write or chat call fails loudly instead of silently
* (PDS) and chat clients so an unauthenticated write or chat call fails * targeting the public appview. Reads keep the public client (appviewClient),
* loudly instead of silently targeting the public appview. Reads keep the * which reads public data without auth.
* public client (appviewClient), which reads public data without auth.
*/ */
accountClient: getUnauthenticatedClient(), accountClient: getUnauthenticatedClient(),
appviewClient: publicClient, appviewClient: publicClient,
@@ -495,12 +455,9 @@ export function createPublicSessionBundle(): PublicSessionBundle {
} }
/** /**
* Resume a stored account into a {@link SessionBundle}. * Resume a stored account into a {@link SessionBundle}. Expired sessions take a
* * network resume (one retry); still-valid stored tokens take a synchronous
* Preserves the old `createAgentAndResume` behavior: prefer-low-latency gates * no-network fast path. Hooks are armed only after the prepare tail resolves.
* 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.
*/ */
export async function createSessionBundleAndResume( export async function createSessionBundleAndResume(
storedAccount: SessionAccount, storedAccount: SessionAccount,
@@ -525,10 +482,7 @@ export async function createSessionBundleAndResume(
PasswordSession.resume(sessionData, hooks), PasswordSession.resume(sessionData, hooks),
) )
} else { } else {
/* // Sync fast path: trust the stored tokens, no network.
* Sync fast path: trust the stored tokens, no network. Matches the old
* `agent.sessionManager.session = prev`.
*/
session = new PasswordSession(sessionData, hooks) session = new PasswordSession(sessionData, hooks)
} }
@@ -543,19 +497,18 @@ export async function createSessionBundleAndResume(
sessionDataToSessionAccount(session.session, session.session.service) ?? sessionDataToSessionAccount(session.session, session.session.service) ??
storedAccount storedAccount
const moderation = configureModerationForAccount(bundle, earlyAccount) configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({ const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient, appviewClient: bundle.appviewClient,
accountClient: bundle.accountClient, 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
* (e.g. the AA prefetch) triggers PasswordSession's internal auto-refresh, * (e.g. the AA prefetch) triggers PasswordSession's internal auto-refresh,
* which rotates both tokens; its onUpdated is dropped by the still-disarmed * which rotates both tokens; its onUpdated is dropped by the still-disarmed
* latch. Snapshotting the returned account here (not before prep) ensures we * latch. Snapshotting here (not before prep) persists the fresh refreshJwt
* persist the fresh refreshJwt rather than a stale one that is dead on the * rather than a stale one that is dead on the next cold start.
* next cold start.
*/ */
const account = const account =
sessionDataToSessionAccount(session.session, session.session.service) ?? sessionDataToSessionAccount(session.session, session.session.service) ??
@@ -566,9 +519,6 @@ export async function createSessionBundleAndResume(
/** /**
* Log in with credentials and build a {@link SessionBundle}. * 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( export async function createSessionBundleAndLogin(
{ {
@@ -603,26 +553,23 @@ export async function createSessionBundleAndLogin(
bundle = buildBundle(session) bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill) registerBundleKillSwitch(bundle, hooks.kill)
/* // Early snapshot: needed now to seed `accountDid` (the getDid closure).
* Early snapshot: needed now to seed `accountDid` (the getDid closure the
* hooks read). The RETURNED account is re-snapshotted after the prep awaits.
*/
const earlyAccount = sessionDataToSessionAccountOrThrow(session) const earlyAccount = sessionDataToSessionAccountOrThrow(session)
accountDid = earlyAccount.did accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'}) const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, earlyAccount) configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({ const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient, appviewClient: bundle.appviewClient,
accountClient: bundle.accountClient, 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 * triggers PasswordSession's internal auto-refresh, which rotates both tokens
* and fires an onUpdated the disarmed latch drops; snapshotting here persists * and fires an onUpdated the disarmed latch drops, so this persists the fresh
* the fresh refreshJwt. If the session was destroyed mid-prep, OrThrow throws * refreshJwt. If the session was destroyed mid-prep, OrThrow throws (login
* (login effectively failed). * effectively failed).
*/ */
const account = sessionDataToSessionAccountOrThrow(session) const account = sessionDataToSessionAccountOrThrow(session)
hooks.arm() hooks.arm()
@@ -630,13 +577,10 @@ export async function createSessionBundleAndLogin(
} }
/** /**
* Create an account and build a {@link SessionBundle}. * Create an account and build a {@link SessionBundle}. Writes created-at and
* * birthdate locally for sync reads, then fires the deferred server-write block
* Preserves `createAgentAndCreateAccount` verbatim: local sync writes for * (personal details, profile, saved feeds, and AA-gated chat restrictions) as
* created-at/birthdate, the prod vs non-prod deferred server-write block * SDK actions against the account (PDS) client.
* (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.
*/ */
export async function createSessionBundleAndCreateAccount( export async function createSessionBundleAndCreateAccount(
{ {
@@ -685,15 +629,13 @@ export async function createSessionBundleAndCreateAccount(
registerBundleKillSwitch(bundle, hooks.kill) registerBundleKillSwitch(bundle, hooks.kill)
/* /*
* Early snapshot: needed now to seed `accountDid` and for the DID/handle used * Early snapshot: needed now to seed `accountDid` and for the DID/handle used
* across the local writes and deferred server writes below (all * across the local and deferred server writes below (all refresh-stable).
* refresh-stable). The RETURNED account is re-snapshotted after the prep
* awaits.
*/ */
const earlyAccount = sessionDataToSessionAccountOrThrow(session) const earlyAccount = sessionDataToSessionAccountOrThrow(session)
accountDid = earlyAccount.did accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'}) const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, earlyAccount) configureModerationForAccount(bundle, earlyAccount)
const createdAt = toDatetimeString(new Date()) const createdAt = toDatetimeString(new Date())
const birthdate = birthDate.toISOString() 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 * triggers PasswordSession's internal auto-refresh, which rotates both tokens
* and fires an onUpdated the disarmed latch drops; snapshotting here persists * and fires an onUpdated the disarmed latch drops, so this persists the fresh
* the fresh refreshJwt rather than a stale one. If the session was destroyed * refreshJwt. If the session was destroyed mid-prep, OrThrow throws.
* mid-prep, OrThrow throws.
*/ */
const account = sessionDataToSessionAccountOrThrow(session) const account = sessionDataToSessionAccountOrThrow(session)
hooks.arm() hooks.arm()
@@ -834,7 +775,7 @@ export async function createSessionBundleAndCreateAccount(
/** /**
* Snapshot a live session as a `SessionAccount`, throwing if there is no active * Snapshot a live session as a `SessionAccount`, throwing if there is no active
* session. Replacement for the old `agentToSessionAccountOrThrow`. * session.
*/ */
function sessionDataToSessionAccountOrThrow( function sessionDataToSessionAccountOrThrow(
session: PasswordSession, session: PasswordSession,
@@ -863,9 +804,8 @@ function sessionDataToSessionAccountOrThrow(
* captured `options.fetch` - throws before touching the network. A tripped * captured `options.fetch` - throws before touching the network. A tripped
* refresh routes into the `onUpdateFailure` path (session preserved locally, * refresh routes into the `onUpdateFailure` path (session preserved locally,
* refresh token NOT consumed server-side). `kill()` also disarms the hooks so * refresh token NOT consumed server-side). `kill()` also disarms the hooks so
* the stale bundle can no longer dispatch into the reducer. The important * the stale bundle can no longer dispatch into the reducer. The guarantee: this
* guarantee - matching the old `dispose()` - is that this session's tokens are * session's tokens are no longer reachable by any live network path.
* no longer reachable by any live network path.
*/ */
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) { export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
const session = bundle.session const session = bundle.session
@@ -874,3 +814,55 @@ export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
} }
bundleKillSwitches.get(bundle)?.() 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<string>
}): 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
}