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).
*/
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', () => ({
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<void>>()
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<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)
})
})
+321 -88
View File
@@ -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<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(
(
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<SessionApiContext['createAccount']>(
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)
+141 -149
View File
@@ -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<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
}