rebuild the session lifecycle on passwordsession
Replace the AtpAgent-owned session lifecycle with PasswordSession-backed
bundles, dispatched through the bridge agent. The provider, reducer and
factories now hold a `{session, agent, service}` bundle whose identity gates
session events, so a stale session can no longer log out the current account
or restore its tokens after a switch.
Behavioural changes that come with the new auth core:
- token rotation is read from the hook payload (PasswordSession fires before
committing its live getter), so refreshes persist the new tokens
- replaced bundles are disposed rather than mutated, since PasswordSession has
no in-place patch; cross-tab syncs rebuild instead
- the expiry rescue path prefers a newer persisted generation over logging
every tab out
Post-signup writes keep main's agent.* call style; createAccount synthesizes
the email/active fields the thinner lex output omits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1456,11 +1456,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/session/agent.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/color-mode.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
import {
|
||||
PasswordSession,
|
||||
type PasswordSessionOptions,
|
||||
type SessionData,
|
||||
} from '@atproto/lex-password-session'
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
import {type SessionAccount} from '../types'
|
||||
|
||||
jest.mock('#/state/events', () => ({
|
||||
emitNetworkConfirmed: jest.fn(),
|
||||
emitNetworkLost: jest.fn(),
|
||||
}))
|
||||
|
||||
/*
|
||||
* `prefetchAgeAssuranceServerData` is a genuine prep await in each factory
|
||||
* (moderation config is synchronous, so the AA prefetch is where the factory
|
||||
* 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(),
|
||||
}))
|
||||
/*
|
||||
* The factory tail awaits `features.refresh(...)`; stub the analytics module so
|
||||
* the factory does not pull GrowthBook (and its native deps) into this
|
||||
* lightweight suite.
|
||||
*/
|
||||
jest.mock('#/analytics', () => ({
|
||||
features: {refresh: () => Promise.resolve()},
|
||||
}))
|
||||
|
||||
/*
|
||||
* `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 bridge agent, before the awaited
|
||||
* prep steps. The factory tests capture the agent with this mock, then inject a
|
||||
* real refresh into the awaited AA prefetch so a token rotation happens during
|
||||
* prep, before arm(). 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<(agent: unknown, account: unknown) => void>()
|
||||
jest.mock('../moderation', () => ({
|
||||
configureModerationForAccount: (agent: unknown, account: unknown) =>
|
||||
mockConfigureModerationForAccount(agent, account),
|
||||
configureModerationForGuest: () => {},
|
||||
}))
|
||||
|
||||
jest.mock('jwt-decode', () => ({
|
||||
jwtDecode(token: string) {
|
||||
if (token === 'queued-access-jwt') {
|
||||
return {scope: 'com.atproto.signupQueued'}
|
||||
}
|
||||
/*
|
||||
* A far-future exp so isSessionExpired() reads this stored token as still
|
||||
* valid, which routes resume() through the sync (no-network) fast path.
|
||||
* That isolates the prep-time refresh as the ONLY token rotation.
|
||||
*/
|
||||
if (token === 'valid-access-jwt') {
|
||||
return {scope: 'com.atproto.access', exp: 4102444800}
|
||||
}
|
||||
return {scope: 'com.atproto.access'}
|
||||
},
|
||||
}))
|
||||
|
||||
import {type BskyAppAgent} from '../bridge-agent'
|
||||
import {
|
||||
type AtpSessionEvent,
|
||||
buildBundle,
|
||||
createSessionBundleFromStoredAccount,
|
||||
disposeBundle,
|
||||
makeSessionHooks,
|
||||
registerBundleKillSwitch,
|
||||
sessionAccountToSessionData,
|
||||
type SessionBundle,
|
||||
sessionDataToSessionAccount,
|
||||
} from '../session-core'
|
||||
|
||||
const DID = 'did:plc:example123'
|
||||
const HANDLE = 'alice.test'
|
||||
const SERVICE = 'https://bsky.social'
|
||||
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
|
||||
|
||||
function synthDidDoc(
|
||||
did: string,
|
||||
pdsUrl: string,
|
||||
): NonNullable<SessionData['didDoc']> {
|
||||
return {
|
||||
id: did,
|
||||
service: [
|
||||
{
|
||||
id: '#atproto_pds',
|
||||
type: 'AtprotoPersonalDataServer',
|
||||
serviceEndpoint: pdsUrl,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
|
||||
return {
|
||||
accessJwt: 'access-jwt',
|
||||
refreshJwt: 'refresh-jwt',
|
||||
handle: HANDLE,
|
||||
did: DID,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
active: true,
|
||||
service: 'https://bsky.social',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sessionDataToSessionAccount', () => {
|
||||
it('returns undefined for a missing session', () => {
|
||||
expect(sessionDataToSessionAccount(undefined, 'https://bsky.social')).toBe(
|
||||
undefined,
|
||||
)
|
||||
expect(sessionDataToSessionAccount(null, 'https://bsky.social')).toBe(
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('maps fields for a hosted account (no didDoc)', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account).toEqual({
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes service as a normalized URL', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.service).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('serializes the didDoc PDS endpoint as a normalized URL', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
|
||||
})
|
||||
|
||||
it('leaves pdsUrl undefined for hosted accounts (no service fallback)', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: undefined}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.pdsUrl).toBe(undefined)
|
||||
})
|
||||
|
||||
it('retains the stored PDS when a valid didDoc has no PDS service', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: {id: DID}}),
|
||||
'https://bsky.social',
|
||||
PDS_URL,
|
||||
)!
|
||||
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
|
||||
})
|
||||
|
||||
it('derives isSelfHosted from the service URL', () => {
|
||||
const hosted = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(hosted.isSelfHosted).toBe(false)
|
||||
|
||||
const selfHosted = sessionDataToSessionAccount(
|
||||
makeSessionData({service: 'https://pds.example.com'}),
|
||||
'https://pds.example.com',
|
||||
)!
|
||||
expect(selfHosted.isSelfHosted).toBe(true)
|
||||
})
|
||||
|
||||
it('derives signupQueued from the access token scope', () => {
|
||||
const queued = sessionDataToSessionAccount(
|
||||
makeSessionData({accessJwt: 'queued-access-jwt'}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(queued.signupQueued).toBe(true)
|
||||
|
||||
const notQueued = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(notQueued.signupQueued).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces missing email flags to false', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({
|
||||
email: undefined,
|
||||
emailConfirmed: undefined,
|
||||
emailAuthFactor: undefined,
|
||||
}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.email).toBe(undefined)
|
||||
expect(account.emailConfirmed).toBe(false)
|
||||
expect(account.emailAuthFactor).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the exact SessionAccount field order', () => {
|
||||
/*
|
||||
* Byte-stability guard: the reducer's JSON.stringify fast path and the
|
||||
* session test snapshots depend on this exact persisted key order.
|
||||
*/
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
const golden: SessionAccount = {
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
isSelfHosted: false,
|
||||
}
|
||||
expect(Object.keys(account)).toEqual(Object.keys(golden))
|
||||
expect(JSON.stringify(account)).toBe(JSON.stringify(golden))
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionAccountToSessionData', () => {
|
||||
const baseAccount: SessionAccount = {
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
}
|
||||
|
||||
it('maps fields with empty-string token fallbacks and active default', () => {
|
||||
const data = sessionAccountToSessionData({
|
||||
...baseAccount,
|
||||
accessJwt: undefined,
|
||||
refreshJwt: undefined,
|
||||
active: undefined,
|
||||
})
|
||||
expect(data.accessJwt).toBe('')
|
||||
expect(data.refreshJwt).toBe('')
|
||||
expect(data.active).toBe(true)
|
||||
expect(data.did).toBe(DID)
|
||||
expect(data.handle).toBe(HANDLE)
|
||||
expect(data.service).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('omits didDoc when the account has no stored pdsUrl', () => {
|
||||
const data = sessionAccountToSessionData(baseAccount)
|
||||
expect('didDoc' in data).toBe(false)
|
||||
})
|
||||
|
||||
it('does not synthesize protocol data from a stored pdsUrl', () => {
|
||||
const data = sessionAccountToSessionData({
|
||||
...baseAccount,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
})
|
||||
expect('didDoc' in data).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips account -> SessionData -> account preserving all fields', () => {
|
||||
const withPds: SessionAccount = {
|
||||
...baseAccount,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
}
|
||||
for (const account of [baseAccount, withPds]) {
|
||||
const data = sessionAccountToSessionData(account)
|
||||
const roundTripped = sessionDataToSessionAccount(
|
||||
data,
|
||||
account.service,
|
||||
account.pdsUrl,
|
||||
)!
|
||||
expect(roundTripped).toEqual(account)
|
||||
expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(account))
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips signupQueued via the access token scope', () => {
|
||||
const queued: SessionAccount = {
|
||||
...baseAccount,
|
||||
accessJwt: 'queued-access-jwt',
|
||||
signupQueued: true,
|
||||
}
|
||||
const roundTripped = sessionDataToSessionAccount(
|
||||
sessionAccountToSessionData(queued),
|
||||
queued.service,
|
||||
)!
|
||||
expect(roundTripped.signupQueued).toBe(true)
|
||||
expect(roundTripped).toEqual(queued)
|
||||
})
|
||||
|
||||
it('round-trips a self-hosted account', () => {
|
||||
const selfHosted: SessionAccount = {
|
||||
...baseAccount,
|
||||
service: 'https://pds.example.com/',
|
||||
pdsUrl: 'https://pds.example.com/',
|
||||
isSelfHosted: true,
|
||||
}
|
||||
const roundTripped = sessionDataToSessionAccount(
|
||||
sessionAccountToSessionData(selfHosted),
|
||||
selfHosted.service,
|
||||
selfHosted.pdsUrl,
|
||||
)!
|
||||
expect(roundTripped).toEqual(selfHosted)
|
||||
})
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
||||
return {
|
||||
service: SERVICE,
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSessionBundleFromStoredAccount', () => {
|
||||
it('builds a bridge agent over one session', () => {
|
||||
const result = createSessionBundleFromStoredAccount(
|
||||
makeAccount(),
|
||||
jest.fn(),
|
||||
)!
|
||||
|
||||
/* the agent reads its identity straight through the shared session */
|
||||
expect(result.bundle.agent.session?.accessJwt).toBe('access-jwt')
|
||||
expect(result.bundle.agent.did).toBe(DID)
|
||||
expect(result.bundle.agent.sessionManager.session).toBe(
|
||||
result.bundle.agent.session,
|
||||
)
|
||||
expect(result.bundle.service.toString()).toBe(`${SERVICE}/`)
|
||||
disposeBundle(result.bundle)
|
||||
/* disposal detaches the agent from the session */
|
||||
expect(result.bundle.agent.session).toBe(undefined)
|
||||
})
|
||||
|
||||
it('disposes a bundle rejected by the activation guard', async () => {
|
||||
const onSessionChange = jest.fn()
|
||||
let rejectedBundle: SessionBundle | undefined
|
||||
const result = createSessionBundleFromStoredAccount(
|
||||
makeAccount(),
|
||||
onSessionChange,
|
||||
bundle => {
|
||||
rejectedBundle = bundle
|
||||
return false
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
await expect(
|
||||
rejectedBundle!.session.fetchHandler('/xrpc/test', {}),
|
||||
).rejects.toThrow('session disposed')
|
||||
expect(onSessionChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
|
||||
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
|
||||
* echoes the account; anything else returns an empty 200.
|
||||
*/
|
||||
function makeMockFetch(
|
||||
overrides: Record<
|
||||
string,
|
||||
(url: string, init: RequestInit) => Response | Promise<Response>
|
||||
> = {},
|
||||
) {
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {'content-type': 'application/json'},
|
||||
})
|
||||
const fetchMock = jest.fn(
|
||||
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
|
||||
const url = input instanceof URL ? input.href : input
|
||||
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
|
||||
const handler = nsid ? overrides[nsid] : undefined
|
||||
if (handler) {
|
||||
return handler(url, init)
|
||||
}
|
||||
if (nsid === 'com.atproto.server.refreshSession') {
|
||||
return json({
|
||||
accessJwt: 'access-jwt-2',
|
||||
refreshJwt: 'refresh-jwt-2',
|
||||
handle: HANDLE,
|
||||
did: DID,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
if (nsid === 'com.atproto.server.getSession') {
|
||||
return json({
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return json({})
|
||||
},
|
||||
)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
|
||||
function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
|
||||
return mock as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('makeSessionHooks arm-latch + event mapping', () => {
|
||||
/*
|
||||
* The hooks read neither `this` (the PasswordSession) nor their data
|
||||
* argument, so we invoke them with empty stand-ins cast to the declared
|
||||
* parameter types. This keeps the test focused on the arm-latch + event
|
||||
* mapping.
|
||||
*/
|
||||
const fakeSession = {} as PasswordSession
|
||||
const fakeData = {} as SessionData
|
||||
|
||||
function setup() {
|
||||
const onSessionChange =
|
||||
jest.fn<
|
||||
(
|
||||
bundle: SessionBundle,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => void
|
||||
>()
|
||||
/* the hook only passes this through by identity; a stub bundle suffices */
|
||||
const bundle = {} as SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => DID,
|
||||
)
|
||||
return {onSessionChange, bundle, hooks}
|
||||
}
|
||||
|
||||
it('swallows events before arm()', () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
void hooks.onUpdated?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("maps onUpdated -> 'update' after arm(), passing the bundle + payload through", () => {
|
||||
const {onSessionChange, bundle, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onUpdated?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
||||
expect(onSessionChange.mock.calls[0][0]).toBe(bundle)
|
||||
expect(onSessionChange.mock.calls[0][1]).toBe(DID)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
||||
/* the fresh SessionData the library delivers is threaded through verbatim */
|
||||
expect(onSessionChange.mock.calls[0][3]).toBe(fakeData)
|
||||
})
|
||||
|
||||
it("maps onDeleted -> 'expired' after arm()", () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onDeleted?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
|
||||
})
|
||||
|
||||
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onUpdateFailure?.call(
|
||||
fakeSession,
|
||||
fakeData,
|
||||
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
|
||||
)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
|
||||
})
|
||||
|
||||
it("threads the dying session payload on the 'expired' path", () => {
|
||||
/*
|
||||
* 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(fakeData)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* The exact derivation from the provider's onSessionChange (index.tsx). Pinned
|
||||
* here because payload threading and this mapping together read tokens from
|
||||
* the delivered payload on 'update' and force
|
||||
* undefined on the drop paths so the reducer logs the user out.
|
||||
*/
|
||||
function deriveRefreshedAccount(
|
||||
event: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
): SessionAccount | undefined {
|
||||
return event === 'update' && sessionData
|
||||
? sessionDataToSessionAccount(sessionData, sessionData.service)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/*
|
||||
* `PasswordSession` fires onUpdated with
|
||||
* the fresh session BEFORE committing it internally, so the live getter is
|
||||
* still stale at hook time. Driven through the real library (not a hand-rolled
|
||||
* fixture) so the ordering is authentic.
|
||||
*/
|
||||
describe('session-hook payload threading (pre-commit ordering)', () => {
|
||||
it('delivers the NEW tokens via the payload even though the live getter is still pre-commit stale', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
let session!: PasswordSession
|
||||
let liveGetterAtHookTime: SessionAccount | undefined
|
||||
let refreshedAccountAtHookTime: SessionAccount | undefined
|
||||
const onSessionChange = jest.fn(
|
||||
(
|
||||
_bundle: SessionBundle,
|
||||
_did: string,
|
||||
event: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => {
|
||||
/* Capture the live getter to demonstrate its pre-commit state. */
|
||||
liveGetterAtHookTime = sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
)
|
||||
/* Derive fresh data from the delivered payload. */
|
||||
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
|
||||
},
|
||||
)
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => ({}) as SessionBundle,
|
||||
() => DID,
|
||||
)
|
||||
session = new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
||||
...hooks,
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
hooks.arm()
|
||||
|
||||
await session.refresh()
|
||||
|
||||
/* at hook time the live getter still held the previous tokens */
|
||||
expect(liveGetterAtHookTime?.accessJwt).toBe('access-jwt')
|
||||
/* the payload already contains the fresh tokens */
|
||||
expect(refreshedAccountAtHookTime?.accessJwt).toBe('access-jwt-2')
|
||||
expect(refreshedAccountAtHookTime?.refreshJwt).toBe('refresh-jwt-2')
|
||||
/* and the session does eventually commit those same tokens */
|
||||
expect(session.session.accessJwt).toBe('access-jwt-2')
|
||||
})
|
||||
|
||||
it("yields refreshedAccount === undefined on the 'expired' path (forces logout)", async () => {
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(
|
||||
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
||||
{status: 400, headers: {'content-type': 'application/json'}},
|
||||
),
|
||||
})
|
||||
let refreshedAccountAtHookTime: SessionAccount | undefined = makeAccount()
|
||||
let observedEvent: AtpSessionEvent | undefined
|
||||
let observedSessionData: SessionData | undefined
|
||||
const onSessionChange = jest.fn(
|
||||
(
|
||||
_bundle: SessionBundle,
|
||||
_did: string,
|
||||
event: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => {
|
||||
observedEvent = event
|
||||
observedSessionData = sessionData
|
||||
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
|
||||
},
|
||||
)
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => ({}) as SessionBundle,
|
||||
() => DID,
|
||||
)
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{...hooks, fetch: asFetch(fetchMock)},
|
||||
)
|
||||
hooks.arm()
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* `PasswordSession` exposes no local
|
||||
* destroy, so disposeBundle neutralizes the session by tripping the flag inside
|
||||
* the injected fetch - after disposal every request (direct or auto-refresh,
|
||||
* which shares this same captured fetch) throws before touching the network.
|
||||
*/
|
||||
describe('disposeBundle kill-switch', () => {
|
||||
it('the injected fetch throws after disposeBundle', () => {
|
||||
const hooks = makeSessionHooks(
|
||||
jest.fn(),
|
||||
() => ({}) as SessionBundle,
|
||||
() => DID,
|
||||
)
|
||||
/* the injected fetch is the kill-switch wrapper makeSessionHooks bakes in */
|
||||
const injectedFetch = hooks.fetch!
|
||||
|
||||
/*
|
||||
* A live session is required for disposeBundle to act (it early-returns on
|
||||
* a null/destroyed session).
|
||||
*/
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{...hooks},
|
||||
)
|
||||
const bundle = buildBundle(session)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
|
||||
/*
|
||||
* Before disposal the wrapper does NOT throw synchronously - it delegates
|
||||
* to the async networkAwareFetch and returns a promise. Swallow that
|
||||
* promise's rejection (the real network is unavailable under jest); we only
|
||||
* care that no synchronous throw happened here.
|
||||
*/
|
||||
const pending = injectedFetch('https://bsky.social/xrpc/x')
|
||||
expect(pending).toBeInstanceOf(Promise)
|
||||
void pending.catch(() => {})
|
||||
|
||||
disposeBundle(bundle)
|
||||
|
||||
/* after disposal every call through the injected fetch throws */
|
||||
expect(() => injectedFetch('https://bsky.social/xrpc/x')).toThrow(
|
||||
'session disposed',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* PasswordSession lifecycle over a mocked fetch, covering the resume fast path
|
||||
* and the hooks that makeSessionHooks maps into reducer events.
|
||||
*/
|
||||
describe('PasswordSession lifecycle over mocked fetch', () => {
|
||||
it('resume fast path: constructing does not hit the network', () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
/* not expired -> new PasswordSession(...) with no refresh */
|
||||
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('resume network path fires onUpdated with fresh tokens', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const onUpdated =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
||||
const session = await PasswordSession.resume(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock), onUpdated},
|
||||
)
|
||||
expect(onUpdated).toHaveBeenCalledTimes(1)
|
||||
expect(session.session.accessJwt).toBe('access-jwt-2')
|
||||
})
|
||||
|
||||
it('onDeleted fires when refresh returns a declared invalid-token error', async () => {
|
||||
const onDeleted =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
||||
const onUpdated =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(
|
||||
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
||||
{status: 400, headers: {'content-type': 'application/json'}},
|
||||
),
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
|
||||
)
|
||||
await expect(session.refresh()).rejects.toBeDefined()
|
||||
expect(onDeleted).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => {
|
||||
const onDeleted =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
||||
const onUpdateFailure =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(JSON.stringify({error: 'InternalServerError'}), {
|
||||
status: 500,
|
||||
headers: {'content-type': 'application/json'},
|
||||
}),
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{
|
||||
fetch: asFetch(fetchMock),
|
||||
onDeleted,
|
||||
onUpdateFailure,
|
||||
},
|
||||
)
|
||||
await session.refresh()
|
||||
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
|
||||
expect(onDeleted).not.toHaveBeenCalled()
|
||||
/* session data is preserved (still the original tokens) */
|
||||
expect(session.session.accessJwt).toBe('access-jwt')
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* `useSessionApi().refreshSession()` is a thin wrapper over
|
||||
* `PasswordSession.refresh()`: on success the armed hooks dispatch exactly one
|
||||
* 'update' event and the returned snapshot reflects the refreshed data;
|
||||
* rejections propagate. We exercise the auth-core mechanics that the callback
|
||||
* relies on (a full provider render is out of scope for a unit test).
|
||||
*/
|
||||
describe('refreshSession semantics', () => {
|
||||
it('refresh() resolves updated data and the armed hooks dispatch exactly one update', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const onSessionChange =
|
||||
jest.fn<
|
||||
(bundle: SessionBundle, did: string, event: AtpSessionEvent) => void
|
||||
>()
|
||||
const bundle = {} as SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => DID,
|
||||
)
|
||||
/*
|
||||
* makeSessionHooks bakes in networkAwareFetch (the real global fetch);
|
||||
* override it with the mock while keeping the arm-latched callbacks (they
|
||||
* close over the same `armed` flag, so hooks.arm() below still applies).
|
||||
*/
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{...hooks, fetch: asFetch(fetchMock)},
|
||||
)
|
||||
hooks.arm()
|
||||
|
||||
await session.refresh()
|
||||
|
||||
/* the refreshed tokens are live on the session */
|
||||
expect(session.session.accessJwt).toBe('access-jwt-2')
|
||||
/* exactly one 'update' event reached the reducer via the armed hooks */
|
||||
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
||||
|
||||
/* the callback's return value is the post-refresh SessionAccount snapshot */
|
||||
const snapshot = sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
)!
|
||||
expect(snapshot.accessJwt).toBe('access-jwt-2')
|
||||
expect(snapshot.refreshJwt).toBe('refresh-jwt-2')
|
||||
})
|
||||
|
||||
it('propagates a rejection from refresh() (invalid session)', async () => {
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(
|
||||
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
||||
{status: 400, headers: {'content-type': 'application/json'}},
|
||||
),
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock)},
|
||||
)
|
||||
await expect(session.refresh()).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* The resume/login factories must snapshot the returned account after
|
||||
* the prep awaits, not before. A 401 during prep triggers PasswordSession's
|
||||
* internal auto-refresh (rotating BOTH tokens and firing an onUpdated the
|
||||
* 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 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
|
||||
* the network leaf captures `globalThis.fetch` at module load - and that
|
||||
* captured fetch is what PasswordSession's auto-refresh routes through.
|
||||
*/
|
||||
describe('factory account snapshot after preparation', () => {
|
||||
/** Load a fresh factory graph whose network leaf captures `fetch`. */
|
||||
async function withFreshFactory(
|
||||
fetch: typeof globalThis.fetch,
|
||||
run: (core: typeof import('../session-core')) => Promise<void>,
|
||||
) {
|
||||
const realFetch = globalThis.fetch
|
||||
globalThis.fetch = fetch
|
||||
try {
|
||||
await jest.isolateModulesAsync(async () => {
|
||||
const core =
|
||||
require('../session-core') as typeof import('../session-core')
|
||||
await run(core)
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = realFetch
|
||||
}
|
||||
}
|
||||
|
||||
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. The bundle is
|
||||
* captured from the (synchronous) moderation call, and the rotation is
|
||||
* injected into the awaited AA prefetch.
|
||||
*/
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(agent: unknown) => {
|
||||
capturedAgent = agent as BskyAppAgent
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
|
||||
/* routes through the bridge into the shared PasswordSession's refresh */
|
||||
await capturedAgent!.sessionManager.refreshSession()
|
||||
})
|
||||
const fetchMock = makeMockFetch()
|
||||
|
||||
await withFreshFactory(asFetch(fetchMock), async core => {
|
||||
const {account, bundle} = await core.createSessionBundleAndResume(
|
||||
makeAccount({accessJwt: 'valid-access-jwt'}),
|
||||
jest.fn(),
|
||||
)
|
||||
/* the moderation prep step ran the refresh */
|
||||
expect(mockConfigureModerationForAccount).toHaveBeenCalledTimes(1)
|
||||
/* the RETURNED account carries the POST-prep (rotated) tokens */
|
||||
expect(account.accessJwt).toBe('access-jwt-2')
|
||||
expect(account.refreshJwt).toBe('refresh-jwt-2')
|
||||
/* and it matches the session's committed state */
|
||||
expect(bundle.session.session.accessJwt).toBe('access-jwt-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('resume: returned account falls back to the stored account when the fast path yields no live token change', async () => {
|
||||
/*
|
||||
* With no mid-prep refresh, the snapshot still reflects the valid stored
|
||||
* tokens.
|
||||
*/
|
||||
mockConfigureModerationForAccount.mockReturnValueOnce(undefined)
|
||||
const fetchMock = makeMockFetch()
|
||||
|
||||
await withFreshFactory(asFetch(fetchMock), async core => {
|
||||
const {account} = await core.createSessionBundleAndResume(
|
||||
makeAccount({accessJwt: 'valid-access-jwt'}),
|
||||
jest.fn(),
|
||||
)
|
||||
expect(account.accessJwt).toBe('valid-access-jwt')
|
||||
expect(account.refreshJwt).toBe('refresh-jwt')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+6
-367
@@ -1,324 +1,17 @@
|
||||
import {
|
||||
Agent as BaseAgent,
|
||||
type AppBskyActorProfile,
|
||||
AtpAgent,
|
||||
type AtprotoServiceType,
|
||||
type AtpSessionData,
|
||||
type AtpSessionEvent,
|
||||
type Did,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
BSKY_SERVICE,
|
||||
DISCOVER_SAVED_FEED,
|
||||
IS_PROD_SERVICE,
|
||||
PUBLIC_BSKY_SERVICE,
|
||||
TIMELINE_SAVED_FEED,
|
||||
} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceServerData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {features} from '#/analytics'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {networkAwareFetch} from './network'
|
||||
import {
|
||||
isSessionExpired,
|
||||
isSignupQueued,
|
||||
sessionAccountToSession,
|
||||
} from './session-data'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
export {sessionAccountToSession} from './session-data'
|
||||
|
||||
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
|
||||
const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
return agent
|
||||
}
|
||||
|
||||
export async function createAgentAndResume(
|
||||
storedAccount: SessionAccount,
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service: storedAccount.service})
|
||||
if (storedAccount.pdsUrl) {
|
||||
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
||||
}
|
||||
const gates = features.refresh({
|
||||
strategy: 'prefer-low-latency',
|
||||
})
|
||||
configureModerationForAccount(agent, storedAccount)
|
||||
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
} else {
|
||||
agent.sessionManager.session = prevSession
|
||||
}
|
||||
|
||||
// after session is attached
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
{
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.login({
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
allowTakendown: true,
|
||||
})
|
||||
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(agent, account)
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
birthDate,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.createAccount({
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
})
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(agent, account)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: account.did, createdAt})
|
||||
setBirthdateForDid({did: account.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||
// do this last
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthdate,
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(1, () => {
|
||||
return agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
|
||||
throw e
|
||||
}),
|
||||
// wait for AA data to load first, then check state
|
||||
aa.then(() => {
|
||||
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
|
||||
if (flags?.chatDisabled || flags?.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthDate.toISOString(),
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// snooze first prompt after signup, defer to next prompt
|
||||
snoozeEmailConfirmationPrompt()
|
||||
} catch (e: any) {
|
||||
logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
|
||||
}
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount {
|
||||
const account = agentToSessionAccount(agent)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
export function agentToSessionAccount(
|
||||
agent: AtpAgent,
|
||||
): SessionAccount | undefined {
|
||||
if (!agent.session) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
service: agent.serviceUrl.toString(),
|
||||
did: agent.session.did,
|
||||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
signupQueued: isSignupQueued(agent.session.accessJwt),
|
||||
active: agent.session.active,
|
||||
status: agent.session.status,
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `Agent` that applies a service-proxy header on construction.
|
||||
*
|
||||
* Used for the unauthenticated, service-specific calls that cannot go through
|
||||
* the session agent (PDS detection, password reset, handle availability).
|
||||
*/
|
||||
export class Agent extends BaseAgent {
|
||||
constructor(
|
||||
proxyHeader: ProxyHeaderValue | null,
|
||||
@@ -330,57 +23,3 @@ export class Agent extends BaseAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not exported. Use factories above to create it.
|
||||
// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do.
|
||||
// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it
|
||||
// feels safer to just let those run as-is and set the header afterward.
|
||||
class BskyAppAgent extends AtpAgent {
|
||||
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
||||
undefined
|
||||
|
||||
constructor({service}: {service: string}) {
|
||||
super({
|
||||
service,
|
||||
fetch: networkAwareFetch,
|
||||
persistSession: (event: AtpSessionEvent) => {
|
||||
if (this.persistSessionHandler) {
|
||||
this.persistSessionHandler(event)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async prepare({
|
||||
resolvers,
|
||||
onSessionChange,
|
||||
}: {
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
resolvers: Promise<unknown>[]
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void
|
||||
}) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all(resolvers)
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
this.persistSessionHandler = event => {
|
||||
onSessionChange(this, account.did, event)
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(account.did, event)
|
||||
}
|
||||
}
|
||||
return {account, agent: this}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.sessionManager.session = undefined
|
||||
this.persistSessionHandler = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type {BskyAppAgent}
|
||||
|
||||
@@ -358,13 +358,8 @@ export class BskyAppAgent extends AtpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logged-out agent used for public/guest browsing.
|
||||
*
|
||||
* Temporary name: it exists alongside `createPublicAgent` in `./agent` until
|
||||
* the provider is switched over to the bridge.
|
||||
*/
|
||||
export function createPublicBridgeAgent() {
|
||||
/** Build the logged-out agent used for public/guest browsing. */
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
|
||||
const agent = new BskyAppAgent(
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import {type AppBskyActorProfile, type Un$Typed} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import {PasswordSession} from '@atproto/lex-password-session'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
DISCOVER_SAVED_FEED,
|
||||
IS_PROD_SERVICE,
|
||||
TIMELINE_SAVED_FEED,
|
||||
} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceServerData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {features} from '#/analytics'
|
||||
import {type BskyAppAgent} from './bridge-agent'
|
||||
import {configureModerationForAccount} from './moderation'
|
||||
import {
|
||||
buildBundle,
|
||||
makeSessionHooks,
|
||||
type OnSessionChange,
|
||||
registerBundleKillSwitch,
|
||||
type SessionBundle,
|
||||
} from './session-core'
|
||||
import {sessionDataToSessionAccount} from './session-data'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/** Create an account, prepare its session, and start post-signup writes. */
|
||||
export async function createSessionBundleAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
birthDate,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
},
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
let bundle!: SessionBundle
|
||||
let accountDid = ''
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => accountDid,
|
||||
)
|
||||
|
||||
const session = await PasswordSession.createAccount(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
|
||||
handle: handle as `${string}.${string}`,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
},
|
||||
{...hooks, service},
|
||||
)
|
||||
|
||||
bundle = buildBundle(session)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
// Seed the hook and the deferred writes with refresh-stable account fields.
|
||||
const earlyAccount = snapshotNewAccount(session, email)
|
||||
accountDid = earlyAccount.did
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: earlyAccount.did, createdAt})
|
||||
setBirthdateForDid({did: earlyAccount.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
|
||||
// Start the prefetch after seeding its synchronous birthdate inputs.
|
||||
const aa = prefetchAgeAssuranceServerData({agent: bundle.agent})
|
||||
|
||||
const isProd = Boolean(IS_PROD_SERVICE(service))
|
||||
const postSignupTasks: Promise<unknown>[] = [
|
||||
savePersonalDetails(bundle.agent, birthdate),
|
||||
initializeProfile(bundle.agent, {handle, createdAt, isProd}),
|
||||
]
|
||||
if (isProd) {
|
||||
postSignupTasks.push(
|
||||
initializeSavedFeeds(bundle.agent),
|
||||
restrictChatAfterAgeAssurance(aa, bundle.agent, earlyAccount.did),
|
||||
)
|
||||
}
|
||||
// Post-signup writes are not required to enter onboarding.
|
||||
void reportPostSignupFailures(postSignupTasks)
|
||||
|
||||
try {
|
||||
// snooze first prompt after signup, defer to next prompt
|
||||
snoozeEmailConfirmationPrompt()
|
||||
} catch (e) {
|
||||
logger.error(e instanceof Error ? e : String(e), {
|
||||
message: `session: failed snoozeEmailConfirmationPrompt`,
|
||||
})
|
||||
}
|
||||
|
||||
// Proxy-header ordering matches the old agent factories: after the void-fired
|
||||
// post-signup writes are started, before the prep await.
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
await Promise.all([gates, aa])
|
||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||
const account = snapshotNewAccount(session, email)
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a just-created session as a `SessionAccount`.
|
||||
*
|
||||
* `com.atproto.server.createAccount` returns only tokens, handle, did and
|
||||
* didDoc, so the lex session carries no email state or `active` flag until the
|
||||
* first refresh. The old `CredentialSession.createAccount` synthesized those
|
||||
* fields from the creation input; do the same here so the persisted account
|
||||
* does not briefly claim the email is unknown.
|
||||
*/
|
||||
function snapshotNewAccount(
|
||||
session: PasswordSession,
|
||||
email: string,
|
||||
): SessionAccount {
|
||||
const account = sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return {
|
||||
...account,
|
||||
email: account.email ?? email,
|
||||
emailConfirmed: account.emailConfirmed ?? false,
|
||||
emailAuthFactor: account.emailAuthFactor ?? false,
|
||||
active: account.active ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
function savePersonalDetails(agent: BskyAppAgent, birthDate: string) {
|
||||
return retryPostSignupTask('set birthDate', 3, () =>
|
||||
agent.setPersonalDetails({birthDate}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeProfile(
|
||||
agent: BskyAppAgent,
|
||||
{
|
||||
handle,
|
||||
createdAt,
|
||||
isProd,
|
||||
}: {
|
||||
handle: string
|
||||
createdAt: string
|
||||
isProd: boolean
|
||||
},
|
||||
) {
|
||||
return retryPostSignupTask('set initial profile', 3, () =>
|
||||
agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
if (isProd) {
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
} else {
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
}
|
||||
return next
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeSavedFeeds(agent: BskyAppAgent) {
|
||||
return retryPostSignupTask('set initial feeds', 1, () =>
|
||||
agent.overwriteSavedFeeds([
|
||||
{...DISCOVER_SAVED_FEED, id: TID.nextStr()},
|
||||
{...TIMELINE_SAVED_FEED, id: TID.nextStr()},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function restrictChatAfterAgeAssurance(
|
||||
ageAssurance: Promise<unknown>,
|
||||
agent: BskyAppAgent,
|
||||
did: string,
|
||||
) {
|
||||
return ageAssurance.then(() => {
|
||||
const {flags} = unsafeGetAndComputeAgeAssurance({did})
|
||||
if (flags?.chatDisabled || flags?.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function retryPostSignupTask<T>(
|
||||
description: string,
|
||||
retries: number,
|
||||
task: () => Promise<T>,
|
||||
) {
|
||||
return networkRetry(retries, task).catch(e => {
|
||||
logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`)
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
async function reportPostSignupFailures(tasks: Promise<unknown>[]) {
|
||||
const results = await Promise.allSettled(tasks)
|
||||
if (results.some(result => result.status === 'rejected')) {
|
||||
logger.error(
|
||||
`session: createSessionBundleAndCreateAccount failed to save post-signup settings`,
|
||||
)
|
||||
}
|
||||
}
|
||||
+254
-74
@@ -8,7 +8,8 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {type SessionData} from '@atproto/lex-password-session'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
@@ -16,15 +17,19 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {
|
||||
agentToSessionAccount,
|
||||
type BskyAppAgent,
|
||||
createAgentAndCreateAccount,
|
||||
createAgentAndLogin,
|
||||
createAgentAndResume,
|
||||
sessionAccountToSession,
|
||||
} from './agent'
|
||||
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
import {
|
||||
type AtpSessionEvent,
|
||||
createSessionBundleAndLogin,
|
||||
createSessionBundleAndResume,
|
||||
createSessionBundleFromStoredAccount,
|
||||
disposeBundle,
|
||||
type PublicSessionBundle,
|
||||
type SessionBundle,
|
||||
sessionDataToSessionAccount,
|
||||
} from './session-core'
|
||||
export {isSignupQueued} from './session-data'
|
||||
import {addSessionDebugLog} from './logging'
|
||||
export type {SessionAccount} from '#/state/session/types'
|
||||
@@ -47,8 +52,11 @@ const StateContext = createContext<SessionStateContext>({
|
||||
})
|
||||
StateContext.displayName = 'SessionStateContext'
|
||||
|
||||
const AgentContext = createContext<AtpAgent | null>(null)
|
||||
AgentContext.displayName = 'SessionAgentContext'
|
||||
/** Active account bundle, or the public bundle when logged out. */
|
||||
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
|
||||
null,
|
||||
)
|
||||
BundleContext.displayName = 'SessionBundleContext'
|
||||
|
||||
const ApiContext = createContext<SessionApiContext>({
|
||||
createAccount: async () => {},
|
||||
@@ -92,7 +100,7 @@ class SessionStore {
|
||||
const persistedData = {
|
||||
accounts: nextState.accounts,
|
||||
currentAccount: nextState.accounts.find(
|
||||
a => a.did === nextState.currentAgentState.did,
|
||||
a => a.did === nextState.currentBundleState.did,
|
||||
),
|
||||
}
|
||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
||||
@@ -110,15 +118,107 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState)
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
const onAgentSessionChange = useCallback(
|
||||
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||
// Refresh-token generations that have already failed during expiry rescue.
|
||||
const failedExpiryTokensRef = useRef<Map<string, Set<string>>>(new Map())
|
||||
/*
|
||||
* Rescued bundles need this callback for their own events. A ref avoids a
|
||||
* self-reference in the callback's dependency list.
|
||||
*/
|
||||
const onSessionChangeRef = useRef<
|
||||
| ((
|
||||
bundle: SessionBundle,
|
||||
accountDid: string,
|
||||
sessionEvent: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => void)
|
||||
| null
|
||||
>(null)
|
||||
|
||||
const onSessionChange = useCallback(
|
||||
(
|
||||
bundle: SessionBundle,
|
||||
accountDid: string,
|
||||
sessionEvent: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => {
|
||||
if (sessionEvent === 'update' && sessionData) {
|
||||
failedExpiryTokensRef.current.get(accountDid)?.clear()
|
||||
}
|
||||
|
||||
/*
|
||||
* PasswordSession invokes its hooks before updating its live getter. Use
|
||||
* the delivered payload so a refresh persists the newly rotated tokens.
|
||||
*/
|
||||
const refreshedAccount =
|
||||
sessionEvent === 'update' && sessionData
|
||||
? sessionDataToSessionAccount(sessionData, sessionData.service)
|
||||
: undefined
|
||||
|
||||
/*
|
||||
* A stale tab may expire a token after another tab has already rotated it.
|
||||
* Prefer a newer persisted or reducer generation over logging every tab
|
||||
* out. Failed generations are recorded and bounded to guarantee that a
|
||||
* repeatedly expiring session eventually falls through to logout.
|
||||
*/
|
||||
if (sessionEvent === 'expired') {
|
||||
const current = store.getState()
|
||||
const currentBundle = current.currentBundleState.bundle as unknown as
|
||||
| SessionBundle
|
||||
| PublicSessionBundle
|
||||
const dyingRefreshJwt = sessionData?.refreshJwt
|
||||
// Stale bundle events are handled by the reducer's identity guard.
|
||||
if (
|
||||
currentBundle === bundle &&
|
||||
current.currentBundleState.did === accountDid &&
|
||||
dyingRefreshJwt
|
||||
) {
|
||||
let failedSet = failedExpiryTokensRef.current.get(accountDid)
|
||||
if (!failedSet) {
|
||||
failedSet = new Set()
|
||||
failedExpiryTokensRef.current.set(accountDid, failedSet)
|
||||
}
|
||||
failedSet.add(dyingRefreshJwt)
|
||||
|
||||
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) {
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
candidate,
|
||||
onSessionChangeRef.current!,
|
||||
)
|
||||
if (rebuilt) {
|
||||
store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle: rebuilt.bundle,
|
||||
newAccount: rebuilt.account,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the current bundle may report that its session was dropped.
|
||||
if (
|
||||
sessionEvent === 'expired' &&
|
||||
store.getState().currentBundleState.bundle === bundle
|
||||
) {
|
||||
emitSessionDropped()
|
||||
}
|
||||
// Bundle identity prevents stale sessions from changing the active account.
|
||||
store.dispatch({
|
||||
type: 'received-agent-event',
|
||||
agent,
|
||||
type: 'received-session-event',
|
||||
bundle,
|
||||
refreshedAccount,
|
||||
accountDid,
|
||||
sessionEvent,
|
||||
@@ -126,15 +226,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
[store],
|
||||
)
|
||||
onSessionChangeRef.current = onSessionChange
|
||||
|
||||
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
||||
async (params, metrics) => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||
const signal = cancelPendingTask()
|
||||
ax.metric('account:create:begin', {})
|
||||
const {agent, account} = await createAgentAndCreateAccount(
|
||||
const {bundle, account} = await createSessionBundleAndCreateAccount(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
onSessionChange,
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
@@ -142,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
ax.metric('account:create:success', metrics, {
|
||||
@@ -150,16 +251,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
||||
},
|
||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
||||
[ax, store, onSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const login = useCallback<SessionApiContext['login']>(
|
||||
async (params, logContext) => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndLogin(
|
||||
const {bundle, account} = await createSessionBundleAndLogin(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
onSessionChange,
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
@@ -167,7 +268,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
ax.metric(
|
||||
@@ -177,7 +278,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
||||
},
|
||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
||||
[ax, store, onSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logoutCurrentAccount = useCallback<
|
||||
@@ -196,17 +297,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{
|
||||
session: utils.accountToSessionMetadata(
|
||||
prevState.accounts.find(
|
||||
a => a.did === prevState.currentAgentState.did,
|
||||
a => a.did === prevState.currentBundleState.did,
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
if (prevState.currentAgentState.did) {
|
||||
if (prevState.currentBundleState.did) {
|
||||
clearAgeAssuranceServerDataForDid({
|
||||
did: prevState.currentAgentState.did,
|
||||
did: prevState.currentBundleState.did,
|
||||
})
|
||||
void clearPersistedQueryStorage(prevState.currentAgentState.did)
|
||||
void clearPersistedQueryStorage(prevState.currentBundleState.did)
|
||||
}
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
@@ -230,7 +331,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{
|
||||
session: utils.accountToSessionMetadata(
|
||||
prevState.accounts.find(
|
||||
a => a.did === prevState.currentAgentState.did,
|
||||
a => a.did === prevState.currentBundleState.did,
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -254,17 +355,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account: storedAccount,
|
||||
})
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndResume(
|
||||
const {bundle, account} = await createSessionBundleAndResume(
|
||||
storedAccount,
|
||||
onAgentSessionChange,
|
||||
onSessionChange,
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
// The factory returns an armed bundle, so a superseded resume must dispose it.
|
||||
disposeBundle(bundle)
|
||||
return
|
||||
}
|
||||
/*
|
||||
* A cross-tab logout may clear or remove the account while resume is in
|
||||
* flight. Check the account entry rather than the current did so ordinary
|
||||
* account switching remains valid.
|
||||
*/
|
||||
const latest = store.getState()
|
||||
const latestEntry = latest.accounts.find(a => a.did === account.did)
|
||||
if (!latestEntry || !latestEntry.refreshJwt) {
|
||||
disposeBundle(bundle)
|
||||
return
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newBundle: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||
@@ -273,19 +387,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
onboardingDispatch({type: 'skip'})
|
||||
}
|
||||
},
|
||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
||||
[store, onSessionChange, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const partialRefreshSession = useCallback<
|
||||
SessionApiContext['partialRefreshSession']
|
||||
>(async () => {
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const bundle = state.currentBundleState.bundle as unknown as SessionBundle
|
||||
const signal = cancelPendingTask()
|
||||
const {data} = await agent.com.atproto.server.getSession()
|
||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||
const {data} = await bundle.agent.com.atproto.server.getSession()
|
||||
if (signal.aborted) return
|
||||
store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
accountDid: agent.session!.did,
|
||||
/*
|
||||
* Read the did off the response rather than the session: the bundle may
|
||||
* have been disposed while the request was in flight, and the live
|
||||
* getters throw in that state.
|
||||
*/
|
||||
accountDid: data.did,
|
||||
patch: {
|
||||
emailConfirmed: data.emailConfirmed,
|
||||
emailAuthFactor: data.emailAuthFactor,
|
||||
@@ -322,38 +442,90 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const syncedAccount = synced.accounts.find(
|
||||
a => a.did === synced.currentAccount?.did,
|
||||
)
|
||||
/*
|
||||
* Cancel pending work when another tab logs out the account this tab
|
||||
* considers current. Do not cancel unrelated work between logged-out tabs.
|
||||
*/
|
||||
const syncedDid = syncedAccount?.refreshJwt
|
||||
? syncedAccount.did
|
||||
: undefined
|
||||
if (
|
||||
syncedDid === undefined &&
|
||||
state.currentBundleState.did !== undefined
|
||||
) {
|
||||
cancelPendingTask()
|
||||
}
|
||||
if (syncedAccount && syncedAccount.refreshJwt) {
|
||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
||||
/*
|
||||
* Web handling: if leader tab has switched to a diff account that is
|
||||
* stale, it will refresh the session before triggering the update to
|
||||
* follower tabs. Follower tabs will therefore receive the fresh
|
||||
* session. See APP-1960, or ask Eric.
|
||||
*/
|
||||
if (syncedAccount.did !== state.currentBundleState.did) {
|
||||
// The leader refreshes before broadcasting, so followers receive fresh tokens.
|
||||
void resumeSession(syncedAccount)
|
||||
} else {
|
||||
const agent = state.currentAgentState.agent as AtpAgent
|
||||
const prevSession = agent.session
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
|
||||
addSessionDebugLog({
|
||||
type: 'agent:patch',
|
||||
agent,
|
||||
prevSession,
|
||||
nextSession: agent.session,
|
||||
/*
|
||||
* PasswordSession cannot be patched in place. Rebuild from the tokens
|
||||
* the leader already refreshed, then dispose the previous bundle.
|
||||
*/
|
||||
const prevBundle = state.currentBundleState.bundle as unknown as
|
||||
| SessionBundle
|
||||
| PublicSessionBundle
|
||||
// Avoid replacing the live bundle for an unrelated account update.
|
||||
const live =
|
||||
prevBundle.session && !prevBundle.session.destroyed
|
||||
? prevBundle.session.session
|
||||
: undefined
|
||||
if (
|
||||
live &&
|
||||
live.accessJwt === syncedAccount.accessJwt &&
|
||||
live.refreshJwt === syncedAccount.refreshJwt
|
||||
) {
|
||||
return
|
||||
}
|
||||
const rebuilt = createSessionBundleFromStoredAccount(
|
||||
syncedAccount,
|
||||
onSessionChange,
|
||||
newBundle => {
|
||||
const current = store.getState()
|
||||
const latestAccount = current.accounts.find(
|
||||
account => account.did === syncedAccount.did,
|
||||
)
|
||||
const isCurrent =
|
||||
current.currentBundleState.bundle === prevBundle &&
|
||||
latestAccount?.accessJwt === syncedAccount.accessJwt &&
|
||||
latestAccount?.refreshJwt === syncedAccount.refreshJwt
|
||||
if (isCurrent) {
|
||||
addSessionDebugLog({
|
||||
type: 'bundle:patch',
|
||||
bundle: newBundle,
|
||||
prevSession:
|
||||
prevBundle.session && !prevBundle.session.destroyed
|
||||
? prevBundle.session.session
|
||||
: undefined,
|
||||
nextSession: newBundle.session.session,
|
||||
})
|
||||
}
|
||||
return isCurrent
|
||||
},
|
||||
)
|
||||
if (!rebuilt) {
|
||||
return
|
||||
}
|
||||
const {bundle: newBundle, account: newAccount} = rebuilt
|
||||
store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newBundle,
|
||||
newAccount,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [store, state, resumeSession])
|
||||
}, [store, state, resumeSession, onSessionChange, cancelPendingTask])
|
||||
|
||||
const stateContext = useMemo(
|
||||
() => ({
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAgentState.did,
|
||||
a => a.did === state.currentBundleState.did,
|
||||
),
|
||||
hasSession: !!state.currentAgentState.did,
|
||||
hasSession: !!state.currentBundleState.did,
|
||||
}),
|
||||
[state],
|
||||
)
|
||||
@@ -379,26 +551,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
],
|
||||
)
|
||||
|
||||
const bundle = state.currentBundleState.bundle as unknown as
|
||||
| SessionBundle
|
||||
| PublicSessionBundle
|
||||
|
||||
// @ts-expect-error window type is not declared, debug only
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent
|
||||
if (__DEV__ && IS_WEB) window.agent = bundle.agent
|
||||
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const currentAgentRef = useRef(agent)
|
||||
const currentBundleRef = useRef(bundle)
|
||||
useEffect(() => {
|
||||
if (currentAgentRef.current !== agent) {
|
||||
// Read the previous value and immediately advance the pointer.
|
||||
const prevAgent = currentAgentRef.current
|
||||
currentAgentRef.current = agent
|
||||
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
||||
// We never reuse agents so let's fully neutralize the previous one.
|
||||
// This ensures it won't try to consume any refresh tokens.
|
||||
prevAgent.dispose()
|
||||
if (currentBundleRef.current !== bundle) {
|
||||
const prevBundle = currentBundleRef.current
|
||||
currentBundleRef.current = bundle
|
||||
addSessionDebugLog({
|
||||
type: 'bundle:switch',
|
||||
prevBundle,
|
||||
nextBundle: bundle,
|
||||
})
|
||||
// Replaced bundles must never consume another refresh token.
|
||||
disposeBundle(prevBundle)
|
||||
}
|
||||
}, [agent])
|
||||
}, [bundle])
|
||||
|
||||
return (
|
||||
<AgentContext.Provider value={agent}>
|
||||
<BundleContext.Provider value={bundle}>
|
||||
<StateContext.Provider value={stateContext}>
|
||||
<ApiContext.Provider value={api}>
|
||||
<AnalyticsContext
|
||||
@@ -411,7 +588,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
</AnalyticsContext>
|
||||
</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
</AgentContext.Provider>
|
||||
</BundleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -453,10 +630,13 @@ export function useRequireAuth() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The active session's agent, or the public agent when logged out.
|
||||
*/
|
||||
export function useAgent(): AtpAgent {
|
||||
const agent = useContext(AgentContext)
|
||||
if (!agent) {
|
||||
const bundle = useContext(BundleContext)
|
||||
if (!bundle) {
|
||||
throw Error('useAgent() must be below <SessionProvider>.')
|
||||
}
|
||||
return agent
|
||||
return bundle.agent
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
|
||||
|
||||
import {type Schema} from '../persisted'
|
||||
import {type Action, type State} from './reducer'
|
||||
import {type SessionAccount} from './types'
|
||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||
|
||||
type Reducer = (state: State, action: Action) => State
|
||||
|
||||
@@ -46,15 +44,20 @@ type Log =
|
||||
data: Schema['session']
|
||||
}
|
||||
| {
|
||||
type: 'agent:switch'
|
||||
prevAgent: object
|
||||
nextAgent: object
|
||||
type: 'bundle:switch'
|
||||
prevBundle: object
|
||||
nextBundle: object
|
||||
}
|
||||
| {
|
||||
type: 'agent:patch'
|
||||
agent: object
|
||||
prevSession: AtpSessionData | undefined
|
||||
nextSession: AtpSessionData | undefined
|
||||
/*
|
||||
* Dev-only bundle-swap log. The bundle is treated as an opaque object
|
||||
* (the reducer never reads its internals); the session snapshots are
|
||||
* plain objects captured for debugging.
|
||||
*/
|
||||
type: 'bundle:patch'
|
||||
bundle: object
|
||||
prevSession: object | undefined
|
||||
nextSession: object | undefined
|
||||
}
|
||||
|
||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
|
||||
|
||||
import {unregisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {logger} from '#/lib/notifications/util'
|
||||
import {createPublicAgent} from './agent'
|
||||
import {wrapSessionReducerForLogging} from './logging'
|
||||
import {type SessionAccount} from './types'
|
||||
import {createPublicSessionBundle} from './session-core'
|
||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||
import {createTemporaryAgentsAndResume} from './util'
|
||||
|
||||
// A hack so that the reducer can't read anything from the agent.
|
||||
// From the reducer's point of view, it should be a completely opaque object.
|
||||
type OpaqueBskyAgent = {
|
||||
// Keep session internals outside the reducer's static view of a bundle.
|
||||
type OpaqueSessionBundle = {
|
||||
readonly service: URL
|
||||
readonly api: unknown
|
||||
readonly app: unknown
|
||||
readonly com: unknown
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: OpaqueBskyAgent
|
||||
type BundleState = {
|
||||
readonly bundle: OpaqueSessionBundle
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
export type State = {
|
||||
readonly accounts: SessionAccount[]
|
||||
readonly currentAgentState: AgentState
|
||||
needsPersist: boolean // Mutated in an effect.
|
||||
readonly currentBundleState: BundleState
|
||||
needsPersist: boolean // Cleared after persistence is scheduled.
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'received-agent-event'
|
||||
agent: OpaqueBskyAgent
|
||||
type: 'received-session-event'
|
||||
bundle: OpaqueSessionBundle
|
||||
accountDid: string
|
||||
refreshedAccount: SessionAccount | undefined
|
||||
sessionEvent: AtpSessionEvent
|
||||
}
|
||||
| {
|
||||
type: 'switched-to-account'
|
||||
newAgent: OpaqueBskyAgent
|
||||
newBundle: OpaqueSessionBundle
|
||||
newAccount: SessionAccount
|
||||
}
|
||||
| {
|
||||
// Replace an immutable session from synced or rescued tokens without rebroadcasting.
|
||||
type: 'replaced-current-bundle'
|
||||
newBundle: OpaqueSessionBundle
|
||||
newAccount: SessionAccount
|
||||
}
|
||||
| {
|
||||
@@ -61,9 +61,9 @@ export type Action =
|
||||
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
||||
}
|
||||
|
||||
function createPublicAgentState(): AgentState {
|
||||
function createPublicBundleState(): BundleState {
|
||||
return {
|
||||
agent: createPublicAgent(),
|
||||
bundle: createPublicSessionBundle(),
|
||||
did: undefined,
|
||||
}
|
||||
}
|
||||
@@ -71,22 +71,20 @@ function createPublicAgentState(): AgentState {
|
||||
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
||||
return {
|
||||
accounts: persistedAccounts,
|
||||
currentAgentState: createPublicAgentState(),
|
||||
currentBundleState: createPublicBundleState(),
|
||||
needsPersist: false,
|
||||
}
|
||||
}
|
||||
|
||||
let reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'received-agent-event': {
|
||||
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
||||
if (
|
||||
refreshedAccount === undefined &&
|
||||
agent !== state.currentAgentState.agent
|
||||
) {
|
||||
// If the session got cleared out (e.g. due to expiry or network error) but
|
||||
// this account isn't the active one, don't clear it out at this time.
|
||||
// This way, if the problem is transient, it'll work on next resume.
|
||||
case 'received-session-event': {
|
||||
const {bundle, accountDid, refreshedAccount, sessionEvent} = action
|
||||
if (bundle !== state.currentBundleState.bundle) {
|
||||
/*
|
||||
* Stale bundles must neither log out the current account nor restore
|
||||
* tokens after logout or an account switch.
|
||||
*/
|
||||
return state
|
||||
}
|
||||
if (sessionEvent === 'network-error') {
|
||||
@@ -98,7 +96,6 @@ let reducer = (state: State, action: Action): State => {
|
||||
!existingAccount ||
|
||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||
) {
|
||||
// Fast path without a state update.
|
||||
return state
|
||||
}
|
||||
return {
|
||||
@@ -118,26 +115,40 @@ let reducer = (state: State, action: Action): State => {
|
||||
return a
|
||||
}
|
||||
}),
|
||||
currentAgentState: refreshedAccount
|
||||
? state.currentAgentState
|
||||
: createPublicAgentState(), // Log out if expired.
|
||||
currentBundleState: refreshedAccount
|
||||
? state.currentBundleState
|
||||
: createPublicBundleState(), // Log out if expired.
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'switched-to-account': {
|
||||
const {newAccount, newAgent} = action
|
||||
const {newAccount, newBundle} = action
|
||||
return {
|
||||
accounts: [
|
||||
newAccount,
|
||||
...state.accounts.filter(a => a.did !== newAccount.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
currentBundleState: {
|
||||
did: newAccount.did,
|
||||
agent: newAgent,
|
||||
bundle: newBundle,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'replaced-current-bundle': {
|
||||
const {newBundle, newAccount} = action
|
||||
return {
|
||||
...state,
|
||||
currentBundleState: {
|
||||
did: state.currentBundleState.did,
|
||||
bundle: newBundle,
|
||||
},
|
||||
accounts: state.accounts.map(a =>
|
||||
a.did === newAccount.did ? newAccount : a,
|
||||
),
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}
|
||||
}
|
||||
case 'removed-account': {
|
||||
const {accountDid} = action
|
||||
|
||||
@@ -159,16 +170,16 @@ let reducer = (state: State, action: Action): State => {
|
||||
|
||||
return {
|
||||
accounts: state.accounts.filter(a => a.did !== accountDid),
|
||||
currentAgentState:
|
||||
state.currentAgentState.did === accountDid
|
||||
? createPublicAgentState() // Log out if removing the current one.
|
||||
: state.currentAgentState,
|
||||
currentBundleState:
|
||||
state.currentBundleState.did === accountDid
|
||||
? createPublicBundleState() // Log out if removing the current one.
|
||||
: state.currentBundleState,
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'logged-out-current-account': {
|
||||
const {currentAgentState} = state
|
||||
const accountDid = currentAgentState.did
|
||||
const {currentBundleState} = state
|
||||
const accountDid = currentBundleState.did
|
||||
// side effect
|
||||
const account = state.accounts.find(a => a.did === accountDid)
|
||||
if (account && accountDid) {
|
||||
@@ -195,7 +206,7 @@ let reducer = (state: State, action: Action): State => {
|
||||
}
|
||||
: a,
|
||||
),
|
||||
currentAgentState: createPublicAgentState(),
|
||||
currentBundleState: createPublicBundleState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
@@ -216,7 +227,7 @@ let reducer = (state: State, action: Action): State => {
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
})),
|
||||
currentAgentState: createPublicAgentState(),
|
||||
currentBundleState: createPublicBundleState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
@@ -224,33 +235,19 @@ let reducer = (state: State, action: Action): State => {
|
||||
const {syncedAccounts, syncedCurrentDid} = action
|
||||
return {
|
||||
accounts: syncedAccounts,
|
||||
currentAgentState:
|
||||
syncedCurrentDid === state.currentAgentState.did
|
||||
? state.currentAgentState
|
||||
: createPublicAgentState(), // Log out if different user.
|
||||
currentBundleState:
|
||||
syncedCurrentDid === state.currentBundleState.did
|
||||
? state.currentBundleState
|
||||
: createPublicBundleState(), // Log out if different user.
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}
|
||||
}
|
||||
case 'partial-refresh-session': {
|
||||
const {accountDid, patch} = action
|
||||
const agent = state.currentAgentState.agent as AtpAgent
|
||||
|
||||
/*
|
||||
* Only mutating values that are safe. Be very careful with this.
|
||||
*/
|
||||
if (agent.session) {
|
||||
agent.session.emailConfirmed =
|
||||
patch.emailConfirmed ?? agent.session.emailConfirmed
|
||||
agent.session.emailAuthFactor =
|
||||
patch.emailAuthFactor ?? agent.session.emailAuthFactor
|
||||
}
|
||||
|
||||
// PasswordSession has no setter; consumers read these fields from the account.
|
||||
return {
|
||||
...state,
|
||||
currentAgentState: {
|
||||
...state.currentAgentState,
|
||||
agent,
|
||||
},
|
||||
accounts: state.accounts.map(a => {
|
||||
if (a.did === accountDid) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
PasswordSession,
|
||||
type PasswordSessionOptions,
|
||||
type SessionData,
|
||||
} from '@atproto/lex-password-session'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
|
||||
import {features} from '#/analytics'
|
||||
import {
|
||||
BskyAppAgent,
|
||||
createPublicAgent,
|
||||
PasswordSessionManager,
|
||||
} from './bridge-agent'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {configureModerationForAccount} from './moderation'
|
||||
import {networkAwareFetch} from './network'
|
||||
import {
|
||||
isSessionExpired,
|
||||
sessionAccountToSessionData,
|
||||
sessionDataToSessionAccount,
|
||||
} from './session-data'
|
||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||
|
||||
export {networkAwareFetch} from './network'
|
||||
export {
|
||||
isSignupQueued,
|
||||
sessionAccountToSessionData,
|
||||
sessionDataToSessionAccount,
|
||||
} from './session-data'
|
||||
export type {AtpSessionEvent} from './types'
|
||||
|
||||
/**
|
||||
* The service the bundle authenticated against.
|
||||
*
|
||||
* `PasswordSession`'s getters throw once the session is destroyed, so the read
|
||||
* is guarded and falls back to the public service.
|
||||
*/
|
||||
function deriveServiceUrl(session: PasswordSession | null): URL {
|
||||
return new URL(
|
||||
session && !session.destroyed
|
||||
? session.session.service
|
||||
: PUBLIC_BSKY_SERVICE,
|
||||
)
|
||||
}
|
||||
|
||||
/** An `AtpAgent` bridged over one `PasswordSession`, the bundle's sole auth core. */
|
||||
export type SessionBundle = {
|
||||
session: PasswordSession
|
||||
agent: BskyAppAgent
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* `PasswordSession` exposes no local (logout-free) destroy, so disposal is
|
||||
* implemented by disabling its injected fetch and hooks. Keep that lifecycle
|
||||
* state private and tied to bundle identity.
|
||||
*/
|
||||
const bundleKillSwitches = new WeakMap<SessionBundle, () => void>()
|
||||
|
||||
/**
|
||||
* Register the lifecycle closure used by {@link disposeBundle}.
|
||||
*
|
||||
* Disposing also detaches the bridge agent from its session, so a stale
|
||||
* bundle's `agent.session` / `agent.pdsUrl` read as `undefined` rather than
|
||||
* serving tokens the app has stopped tracking.
|
||||
*/
|
||||
export function registerBundleKillSwitch(
|
||||
bundle: SessionBundle,
|
||||
kill: () => void,
|
||||
) {
|
||||
bundleKillSwitches.set(bundle, () => {
|
||||
kill()
|
||||
bundle.agent.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a session in the bridge agent.
|
||||
*
|
||||
* `storedPdsUrl` seeds {@link PasswordSessionManager}'s PDS routing so requests
|
||||
* made before the first refresh delivers a didDoc still reach the right host.
|
||||
* Once a didDoc arrives the manager prefers its endpoint.
|
||||
*/
|
||||
export function buildBundle(
|
||||
session: PasswordSession,
|
||||
storedPdsUrl?: string,
|
||||
): SessionBundle {
|
||||
const manager = new PasswordSessionManager(session, {
|
||||
service: deriveServiceUrl(session).toString(),
|
||||
pdsUrl: storedPdsUrl,
|
||||
})
|
||||
return {
|
||||
session,
|
||||
agent: new BskyAppAgent(manager),
|
||||
get service() {
|
||||
return deriveServiceUrl(session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PasswordSession delivers `sessionData` before updating its live getter. The
|
||||
* provider uses that payload for rotated tokens and expiry rescue.
|
||||
*/
|
||||
export type OnSessionChange = (
|
||||
bundle: SessionBundle,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
sessionData?: SessionData,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Hooks stay inert during initial session preparation. `kill()` disarms them
|
||||
* and disables the injected fetch so a disposed session cannot refresh or
|
||||
* dispatch. The bundle getters are deferred because hooks are created first.
|
||||
*/
|
||||
export function makeSessionHooks(
|
||||
onSessionChange: OnSessionChange,
|
||||
getBundle: () => SessionBundle,
|
||||
getDid: () => string,
|
||||
) {
|
||||
let armed = false
|
||||
let killed = false
|
||||
const dispatch = (event: AtpSessionEvent, sessionData?: SessionData) => {
|
||||
if (!armed) {
|
||||
return
|
||||
}
|
||||
const did = getDid()
|
||||
onSessionChange(getBundle(), did, event, sessionData)
|
||||
if (event !== 'update') {
|
||||
addSessionErrorLog(did, event)
|
||||
}
|
||||
}
|
||||
const hooks: PasswordSessionOptions = {
|
||||
fetch: (input, init) => {
|
||||
if (killed) {
|
||||
throw new Error('session disposed')
|
||||
}
|
||||
return networkAwareFetch(input, init)
|
||||
},
|
||||
onUpdated(data) {
|
||||
dispatch('update', data)
|
||||
},
|
||||
onDeleted(data) {
|
||||
dispatch('expired', data)
|
||||
},
|
||||
onUpdateFailure() {
|
||||
dispatch('network-error')
|
||||
},
|
||||
}
|
||||
return Object.assign(hooks, {
|
||||
arm() {
|
||||
armed = true
|
||||
},
|
||||
kill() {
|
||||
killed = true
|
||||
armed = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The agent exposed while logged out. */
|
||||
export type PublicSessionBundle = {
|
||||
session: null
|
||||
agent: BskyAppAgent
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logged-out bundle. `createPublicAgent` installs the guest
|
||||
* moderation authorities as part of building the agent.
|
||||
*/
|
||||
export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
return {
|
||||
session: null,
|
||||
agent: createPublicAgent(),
|
||||
service: new URL(PUBLIC_BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
const gates = features.refresh({strategy: 'prefer-low-latency'})
|
||||
let bundle!: SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => storedAccount.did,
|
||||
)
|
||||
|
||||
let session: PasswordSession
|
||||
const sessionData = sessionAccountToSessionData(storedAccount)
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
// The arm latch swallows resume's initial onUpdated event.
|
||||
session = await networkRetry(1, () =>
|
||||
PasswordSession.resume(sessionData, hooks),
|
||||
)
|
||||
} else {
|
||||
// Sync fast path: trust the stored tokens, no network.
|
||||
session = new PasswordSession(sessionData, hooks)
|
||||
}
|
||||
|
||||
bundle = buildBundle(session, storedAccount.pdsUrl)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
// The returned account is captured again after asynchronous preparation.
|
||||
const earlyAccount =
|
||||
sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
storedAccount.pdsUrl,
|
||||
) ?? storedAccount
|
||||
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({agent: bundle.agent})
|
||||
|
||||
/*
|
||||
* Proxy-header ordering matches the old agent factories: the header is
|
||||
* applied after the age-assurance prefetch starts and before the prep await,
|
||||
* so the PDS-targeting setup calls above run without it.
|
||||
*/
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
await Promise.all([gates, aa])
|
||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||
const account =
|
||||
sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
storedAccount.pdsUrl,
|
||||
) ?? storedAccount
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in with credentials and build a {@link SessionBundle}.
|
||||
*/
|
||||
export async function createSessionBundleAndLogin(
|
||||
{
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
},
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
let bundle!: SessionBundle
|
||||
let accountDid = ''
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => accountDid,
|
||||
)
|
||||
|
||||
const session = await PasswordSession.login({
|
||||
...hooks,
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
allowTakendown: true,
|
||||
})
|
||||
|
||||
bundle = buildBundle(session)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
// Seed the hook's did before it is armed.
|
||||
const earlyAccount = sessionDataToSessionAccountOrThrow(session)
|
||||
accountDid = earlyAccount.did
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(bundle.agent, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({agent: bundle.agent})
|
||||
|
||||
// Proxy-header ordering matches the old agent factories.
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
await Promise.all([gates, aa])
|
||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||
const account = sessionDataToSessionAccountOrThrow(session)
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a bundle synchronously from stored tokens. The optional guard runs
|
||||
* after construction but before hooks are armed; rejected bundles are disposed.
|
||||
*/
|
||||
export function createSessionBundleFromStoredAccount(
|
||||
storedAccount: SessionAccount,
|
||||
onSessionChange: OnSessionChange,
|
||||
shouldActivate: (
|
||||
bundle: SessionBundle,
|
||||
account: SessionAccount,
|
||||
) => boolean = () => true,
|
||||
): {account: SessionAccount; bundle: SessionBundle} | undefined {
|
||||
let bundle!: SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle,
|
||||
() => storedAccount.did,
|
||||
)
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(storedAccount),
|
||||
hooks,
|
||||
)
|
||||
bundle = buildBundle(session, storedAccount.pdsUrl)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
configureModerationForAccount(bundle.agent, storedAccount)
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
const account = session.destroyed
|
||||
? storedAccount
|
||||
: (sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
storedAccount.pdsUrl,
|
||||
) ?? storedAccount)
|
||||
if (!shouldActivate(bundle, account)) {
|
||||
disposeBundle(bundle)
|
||||
return undefined
|
||||
}
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
export function sessionDataToSessionAccountOrThrow(
|
||||
session: PasswordSession,
|
||||
): SessionAccount {
|
||||
const account = sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a replaced bundle without revoking its server session. PasswordSession
|
||||
* has no local destroy operation, so the registered lifecycle closure disables
|
||||
* its fetch and hooks instead.
|
||||
*/
|
||||
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
|
||||
const session = bundle.session
|
||||
if (!session || session.destroyed) {
|
||||
return
|
||||
}
|
||||
bundleKillSwitches.get(bundle)?.()
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import {type Metrics} from '#/analytics/metrics'
|
||||
|
||||
export type SessionAccount = PersistedAccount
|
||||
|
||||
/** Session-change events understood by the reducer and logging hooks. */
|
||||
export type AtpSessionEvent = 'update' | 'expired' | 'network-error'
|
||||
|
||||
export type SessionStateContext = {
|
||||
accounts: SessionAccount[]
|
||||
currentAccount: SessionAccount | undefined
|
||||
@@ -44,11 +47,9 @@ export type SessionApiContext = {
|
||||
) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
/**
|
||||
* Calls `getSession` and updates select fields on the current account and
|
||||
* `BskyAgent`. This is an alternative to `resumeSession`, which updates
|
||||
* current account/agent using the `persistSessionHandler`, but is more load
|
||||
* bearing. This patches in updates without causing any side effects via
|
||||
* `persistSessionHandler`.
|
||||
* Calls `getSession` and patches the email fields of the current account.
|
||||
* Unlike `resumeSession`, this does not rotate tokens or rebuild the session,
|
||||
* so it produces no session-change side effects.
|
||||
*/
|
||||
partialRefreshSession: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||
import {createPublicAgent} from '#/state/session/agent'
|
||||
import {createPublicAgent} from '#/state/session/bridge-agent'
|
||||
import {
|
||||
type ComposerState,
|
||||
type EmbedDraft,
|
||||
|
||||
Reference in New Issue
Block a user