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