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