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:
Samuel Newman
2026-07-31 19:46:27 +03:00
parent 9f40ba0a0b
commit abdf1abf4b
12 changed files with 2467 additions and 1033 deletions
-5
View File
@@ -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
View File
@@ -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}
+2 -7
View File
@@ -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(
+242
View File
@@ -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
View File
@@ -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
}
+13 -10
View File
@@ -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 {
+60 -63
View File
@@ -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 {
+364
View File
@@ -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)?.()
}
+6 -5
View File
@@ -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>
}
+1 -1
View File
@@ -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,