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
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/state/session/agent.ts": {
|
|
||||||
"typescript/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/state/shell/color-mode.tsx": {
|
"src/state/shell/color-mode.tsx": {
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 2
|
"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 {
|
import {
|
||||||
Agent as BaseAgent,
|
Agent as BaseAgent,
|
||||||
type AppBskyActorProfile,
|
|
||||||
AtpAgent,
|
|
||||||
type AtprotoServiceType,
|
type AtprotoServiceType,
|
||||||
type AtpSessionData,
|
|
||||||
type AtpSessionEvent,
|
|
||||||
type Did,
|
type Did,
|
||||||
type Un$Typed,
|
|
||||||
} from '@atproto/api'
|
} 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 type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
||||||
|
|
||||||
export function createPublicAgent() {
|
/**
|
||||||
configureModerationForGuest() // Side effect but only relevant for tests
|
* A bare `Agent` that applies a service-proxy header on construction.
|
||||||
|
*
|
||||||
const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
* Used for the unauthenticated, service-specific calls that cannot go through
|
||||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
* the session agent (PDS detection, password reset, handle availability).
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Agent extends BaseAgent {
|
export class Agent extends BaseAgent {
|
||||||
constructor(
|
constructor(
|
||||||
proxyHeader: ProxyHeaderValue | null,
|
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. */
|
||||||
* Build the logged-out agent used for public/guest browsing.
|
export function createPublicAgent() {
|
||||||
*
|
|
||||||
* Temporary name: it exists alongside `createPublicAgent` in `./agent` until
|
|
||||||
* the provider is switched over to the bridge.
|
|
||||||
*/
|
|
||||||
export function createPublicBridgeAgent() {
|
|
||||||
configureModerationForGuest() // Side effect but only relevant for tests
|
configureModerationForGuest() // Side effect but only relevant for tests
|
||||||
|
|
||||||
const agent = new BskyAppAgent(
|
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,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} 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 * as persisted from '#/state/persisted'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -16,15 +17,19 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
|||||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
import {
|
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||||
agentToSessionAccount,
|
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||||
type BskyAppAgent,
|
|
||||||
createAgentAndCreateAccount,
|
|
||||||
createAgentAndLogin,
|
|
||||||
createAgentAndResume,
|
|
||||||
sessionAccountToSession,
|
|
||||||
} from './agent'
|
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
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'
|
export {isSignupQueued} from './session-data'
|
||||||
import {addSessionDebugLog} from './logging'
|
import {addSessionDebugLog} from './logging'
|
||||||
export type {SessionAccount} from '#/state/session/types'
|
export type {SessionAccount} from '#/state/session/types'
|
||||||
@@ -47,8 +52,11 @@ const StateContext = createContext<SessionStateContext>({
|
|||||||
})
|
})
|
||||||
StateContext.displayName = 'SessionStateContext'
|
StateContext.displayName = 'SessionStateContext'
|
||||||
|
|
||||||
const AgentContext = createContext<AtpAgent | null>(null)
|
/** Active account bundle, or the public bundle when logged out. */
|
||||||
AgentContext.displayName = 'SessionAgentContext'
|
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
BundleContext.displayName = 'SessionBundleContext'
|
||||||
|
|
||||||
const ApiContext = createContext<SessionApiContext>({
|
const ApiContext = createContext<SessionApiContext>({
|
||||||
createAccount: async () => {},
|
createAccount: async () => {},
|
||||||
@@ -92,7 +100,7 @@ class SessionStore {
|
|||||||
const persistedData = {
|
const persistedData = {
|
||||||
accounts: nextState.accounts,
|
accounts: nextState.accounts,
|
||||||
currentAccount: nextState.accounts.find(
|
currentAccount: nextState.accounts.find(
|
||||||
a => a.did === nextState.currentAgentState.did,
|
a => a.did === nextState.currentBundleState.did,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
||||||
@@ -110,15 +118,107 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const state = useSyncExternalStore(store.subscribe, store.getState)
|
const state = useSyncExternalStore(store.subscribe, store.getState)
|
||||||
const onboardingDispatch = useOnboardingDispatch()
|
const onboardingDispatch = useOnboardingDispatch()
|
||||||
|
|
||||||
const onAgentSessionChange = useCallback(
|
// Refresh-token generations that have already failed during expiry rescue.
|
||||||
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
const failedExpiryTokensRef = useRef<Map<string, Set<string>>>(new Map())
|
||||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
/*
|
||||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
* 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()
|
emitSessionDropped()
|
||||||
}
|
}
|
||||||
|
// Bundle identity prevents stale sessions from changing the active account.
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'received-agent-event',
|
type: 'received-session-event',
|
||||||
agent,
|
bundle,
|
||||||
refreshedAccount,
|
refreshedAccount,
|
||||||
accountDid,
|
accountDid,
|
||||||
sessionEvent,
|
sessionEvent,
|
||||||
@@ -126,15 +226,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
},
|
},
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
|
onSessionChangeRef.current = onSessionChange
|
||||||
|
|
||||||
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
||||||
async (params, metrics) => {
|
async (params, metrics) => {
|
||||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
ax.metric('account:create:begin', {})
|
ax.metric('account:create:begin', {})
|
||||||
const {agent, account} = await createAgentAndCreateAccount(
|
const {bundle, account} = await createSessionBundleAndCreateAccount(
|
||||||
params,
|
params,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
@@ -142,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
@@ -150,16 +251,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
||||||
},
|
},
|
||||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
[ax, store, onSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const login = useCallback<SessionApiContext['login']>(
|
const login = useCallback<SessionApiContext['login']>(
|
||||||
async (params, logContext) => {
|
async (params, logContext) => {
|
||||||
addSessionDebugLog({type: 'method:start', method: 'login'})
|
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndLogin(
|
const {bundle, account} = await createSessionBundleAndLogin(
|
||||||
params,
|
params,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
@@ -167,7 +268,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
@@ -177,7 +278,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
||||||
},
|
},
|
||||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
[ax, store, onSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const logoutCurrentAccount = useCallback<
|
const logoutCurrentAccount = useCallback<
|
||||||
@@ -196,17 +297,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{
|
{
|
||||||
session: utils.accountToSessionMetadata(
|
session: utils.accountToSessionMetadata(
|
||||||
prevState.accounts.find(
|
prevState.accounts.find(
|
||||||
a => a.did === prevState.currentAgentState.did,
|
a => a.did === prevState.currentBundleState.did,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||||
if (prevState.currentAgentState.did) {
|
if (prevState.currentBundleState.did) {
|
||||||
clearAgeAssuranceServerDataForDid({
|
clearAgeAssuranceServerDataForDid({
|
||||||
did: prevState.currentAgentState.did,
|
did: prevState.currentBundleState.did,
|
||||||
})
|
})
|
||||||
void clearPersistedQueryStorage(prevState.currentAgentState.did)
|
void clearPersistedQueryStorage(prevState.currentBundleState.did)
|
||||||
}
|
}
|
||||||
// reset onboarding flow on logout
|
// reset onboarding flow on logout
|
||||||
onboardingDispatch({type: 'skip'})
|
onboardingDispatch({type: 'skip'})
|
||||||
@@ -230,7 +331,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{
|
{
|
||||||
session: utils.accountToSessionMetadata(
|
session: utils.accountToSessionMetadata(
|
||||||
prevState.accounts.find(
|
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,
|
account: storedAccount,
|
||||||
})
|
})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndResume(
|
const {bundle, account} = await createSessionBundleAndResume(
|
||||||
storedAccount,
|
storedAccount,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
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
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||||
@@ -273,19 +387,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
onboardingDispatch({type: 'skip'})
|
onboardingDispatch({type: 'skip'})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
[store, onSessionChange, cancelPendingTask, onboardingDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
const partialRefreshSession = useCallback<
|
const partialRefreshSession = useCallback<
|
||||||
SessionApiContext['partialRefreshSession']
|
SessionApiContext['partialRefreshSession']
|
||||||
>(async () => {
|
>(async () => {
|
||||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
const bundle = state.currentBundleState.bundle as unknown as SessionBundle
|
||||||
const signal = cancelPendingTask()
|
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
|
if (signal.aborted) return
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'partial-refresh-session',
|
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: {
|
patch: {
|
||||||
emailConfirmed: data.emailConfirmed,
|
emailConfirmed: data.emailConfirmed,
|
||||||
emailAuthFactor: data.emailAuthFactor,
|
emailAuthFactor: data.emailAuthFactor,
|
||||||
@@ -322,38 +442,90 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const syncedAccount = synced.accounts.find(
|
const syncedAccount = synced.accounts.find(
|
||||||
a => a.did === synced.currentAccount?.did,
|
a => a.did === synced.currentAccount?.did,
|
||||||
)
|
)
|
||||||
|
/*
|
||||||
|
* 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 && syncedAccount.refreshJwt) {
|
||||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
if (syncedAccount.did !== state.currentBundleState.did) {
|
||||||
/*
|
// The leader refreshes before broadcasting, so followers receive fresh tokens.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
void resumeSession(syncedAccount)
|
void resumeSession(syncedAccount)
|
||||||
} else {
|
} else {
|
||||||
const agent = state.currentAgentState.agent as AtpAgent
|
/*
|
||||||
const prevSession = agent.session
|
* PasswordSession cannot be patched in place. Rebuild from the tokens
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
* the leader already refreshed, then dispose the previous bundle.
|
||||||
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
|
*/
|
||||||
addSessionDebugLog({
|
const prevBundle = state.currentBundleState.bundle as unknown as
|
||||||
type: 'agent:patch',
|
| SessionBundle
|
||||||
agent,
|
| PublicSessionBundle
|
||||||
prevSession,
|
// Avoid replacing the live bundle for an unrelated account update.
|
||||||
nextSession: agent.session,
|
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(
|
const stateContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
accounts: state.accounts,
|
accounts: state.accounts,
|
||||||
currentAccount: state.accounts.find(
|
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],
|
[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
|
// @ts-expect-error window type is not declared, debug only
|
||||||
// eslint-disable-next-line react-hooks/immutability
|
// 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 currentBundleRef = useRef(bundle)
|
||||||
const currentAgentRef = useRef(agent)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentAgentRef.current !== agent) {
|
if (currentBundleRef.current !== bundle) {
|
||||||
// Read the previous value and immediately advance the pointer.
|
const prevBundle = currentBundleRef.current
|
||||||
const prevAgent = currentAgentRef.current
|
currentBundleRef.current = bundle
|
||||||
currentAgentRef.current = agent
|
addSessionDebugLog({
|
||||||
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
type: 'bundle:switch',
|
||||||
// We never reuse agents so let's fully neutralize the previous one.
|
prevBundle,
|
||||||
// This ensures it won't try to consume any refresh tokens.
|
nextBundle: bundle,
|
||||||
prevAgent.dispose()
|
})
|
||||||
|
// Replaced bundles must never consume another refresh token.
|
||||||
|
disposeBundle(prevBundle)
|
||||||
}
|
}
|
||||||
}, [agent])
|
}, [bundle])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentContext.Provider value={agent}>
|
<BundleContext.Provider value={bundle}>
|
||||||
<StateContext.Provider value={stateContext}>
|
<StateContext.Provider value={stateContext}>
|
||||||
<ApiContext.Provider value={api}>
|
<ApiContext.Provider value={api}>
|
||||||
<AnalyticsContext
|
<AnalyticsContext
|
||||||
@@ -411,7 +588,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
</AnalyticsContext>
|
</AnalyticsContext>
|
||||||
</ApiContext.Provider>
|
</ApiContext.Provider>
|
||||||
</StateContext.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 {
|
export function useAgent(): AtpAgent {
|
||||||
const agent = useContext(AgentContext)
|
const bundle = useContext(BundleContext)
|
||||||
if (!agent) {
|
if (!bundle) {
|
||||||
throw Error('useAgent() must be below <SessionProvider>.')
|
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 Schema} from '../persisted'
|
||||||
import {type Action, type State} from './reducer'
|
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
|
type Reducer = (state: State, action: Action) => State
|
||||||
|
|
||||||
@@ -46,15 +44,20 @@ type Log =
|
|||||||
data: Schema['session']
|
data: Schema['session']
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent:switch'
|
type: 'bundle:switch'
|
||||||
prevAgent: object
|
prevBundle: object
|
||||||
nextAgent: object
|
nextBundle: object
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent:patch'
|
/*
|
||||||
agent: object
|
* Dev-only bundle-swap log. The bundle is treated as an opaque object
|
||||||
prevSession: AtpSessionData | undefined
|
* (the reducer never reads its internals); the session snapshots are
|
||||||
nextSession: AtpSessionData | undefined
|
* plain objects captured for debugging.
|
||||||
|
*/
|
||||||
|
type: 'bundle:patch'
|
||||||
|
bundle: object
|
||||||
|
prevSession: object | undefined
|
||||||
|
nextSession: object | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
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 {unregisterPushToken} from '#/lib/notifications/notifications'
|
||||||
import {logger} from '#/lib/notifications/util'
|
import {logger} from '#/lib/notifications/util'
|
||||||
import {createPublicAgent} from './agent'
|
|
||||||
import {wrapSessionReducerForLogging} from './logging'
|
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'
|
import {createTemporaryAgentsAndResume} from './util'
|
||||||
|
|
||||||
// A hack so that the reducer can't read anything from the agent.
|
// Keep session internals outside the reducer's static view of a bundle.
|
||||||
// From the reducer's point of view, it should be a completely opaque object.
|
type OpaqueSessionBundle = {
|
||||||
type OpaqueBskyAgent = {
|
|
||||||
readonly service: URL
|
readonly service: URL
|
||||||
readonly api: unknown
|
|
||||||
readonly app: unknown
|
|
||||||
readonly com: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentState = {
|
type BundleState = {
|
||||||
readonly agent: OpaqueBskyAgent
|
readonly bundle: OpaqueSessionBundle
|
||||||
readonly did: string | undefined
|
readonly did: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
readonly accounts: SessionAccount[]
|
readonly accounts: SessionAccount[]
|
||||||
readonly currentAgentState: AgentState
|
readonly currentBundleState: BundleState
|
||||||
needsPersist: boolean // Mutated in an effect.
|
needsPersist: boolean // Cleared after persistence is scheduled.
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| {
|
| {
|
||||||
type: 'received-agent-event'
|
type: 'received-session-event'
|
||||||
agent: OpaqueBskyAgent
|
bundle: OpaqueSessionBundle
|
||||||
accountDid: string
|
accountDid: string
|
||||||
refreshedAccount: SessionAccount | undefined
|
refreshedAccount: SessionAccount | undefined
|
||||||
sessionEvent: AtpSessionEvent
|
sessionEvent: AtpSessionEvent
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'switched-to-account'
|
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
|
newAccount: SessionAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -61,9 +61,9 @@ export type Action =
|
|||||||
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPublicAgentState(): AgentState {
|
function createPublicBundleState(): BundleState {
|
||||||
return {
|
return {
|
||||||
agent: createPublicAgent(),
|
bundle: createPublicSessionBundle(),
|
||||||
did: undefined,
|
did: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,22 +71,20 @@ function createPublicAgentState(): AgentState {
|
|||||||
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
||||||
return {
|
return {
|
||||||
accounts: persistedAccounts,
|
accounts: persistedAccounts,
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: false,
|
needsPersist: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reducer = (state: State, action: Action): State => {
|
let reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'received-agent-event': {
|
case 'received-session-event': {
|
||||||
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
const {bundle, accountDid, refreshedAccount, sessionEvent} = action
|
||||||
if (
|
if (bundle !== state.currentBundleState.bundle) {
|
||||||
refreshedAccount === undefined &&
|
/*
|
||||||
agent !== state.currentAgentState.agent
|
* Stale bundles must neither log out the current account nor restore
|
||||||
) {
|
* tokens after logout or an account switch.
|
||||||
// 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.
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
if (sessionEvent === 'network-error') {
|
if (sessionEvent === 'network-error') {
|
||||||
@@ -98,7 +96,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
!existingAccount ||
|
!existingAccount ||
|
||||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||||
) {
|
) {
|
||||||
// Fast path without a state update.
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -118,26 +115,40 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
currentAgentState: refreshedAccount
|
currentBundleState: refreshedAccount
|
||||||
? state.currentAgentState
|
? state.currentBundleState
|
||||||
: createPublicAgentState(), // Log out if expired.
|
: createPublicBundleState(), // Log out if expired.
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'switched-to-account': {
|
case 'switched-to-account': {
|
||||||
const {newAccount, newAgent} = action
|
const {newAccount, newBundle} = action
|
||||||
return {
|
return {
|
||||||
accounts: [
|
accounts: [
|
||||||
newAccount,
|
newAccount,
|
||||||
...state.accounts.filter(a => a.did !== newAccount.did),
|
...state.accounts.filter(a => a.did !== newAccount.did),
|
||||||
],
|
],
|
||||||
currentAgentState: {
|
currentBundleState: {
|
||||||
did: newAccount.did,
|
did: newAccount.did,
|
||||||
agent: newAgent,
|
bundle: newBundle,
|
||||||
},
|
},
|
||||||
needsPersist: true,
|
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': {
|
case 'removed-account': {
|
||||||
const {accountDid} = action
|
const {accountDid} = action
|
||||||
|
|
||||||
@@ -159,16 +170,16 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: state.accounts.filter(a => a.did !== accountDid),
|
accounts: state.accounts.filter(a => a.did !== accountDid),
|
||||||
currentAgentState:
|
currentBundleState:
|
||||||
state.currentAgentState.did === accountDid
|
state.currentBundleState.did === accountDid
|
||||||
? createPublicAgentState() // Log out if removing the current one.
|
? createPublicBundleState() // Log out if removing the current one.
|
||||||
: state.currentAgentState,
|
: state.currentBundleState,
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'logged-out-current-account': {
|
case 'logged-out-current-account': {
|
||||||
const {currentAgentState} = state
|
const {currentBundleState} = state
|
||||||
const accountDid = currentAgentState.did
|
const accountDid = currentBundleState.did
|
||||||
// side effect
|
// side effect
|
||||||
const account = state.accounts.find(a => a.did === accountDid)
|
const account = state.accounts.find(a => a.did === accountDid)
|
||||||
if (account && accountDid) {
|
if (account && accountDid) {
|
||||||
@@ -195,7 +206,7 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
}
|
}
|
||||||
: a,
|
: a,
|
||||||
),
|
),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +227,7 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
refreshJwt: undefined,
|
refreshJwt: undefined,
|
||||||
accessJwt: undefined,
|
accessJwt: undefined,
|
||||||
})),
|
})),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,33 +235,19 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
const {syncedAccounts, syncedCurrentDid} = action
|
const {syncedAccounts, syncedCurrentDid} = action
|
||||||
return {
|
return {
|
||||||
accounts: syncedAccounts,
|
accounts: syncedAccounts,
|
||||||
currentAgentState:
|
currentBundleState:
|
||||||
syncedCurrentDid === state.currentAgentState.did
|
syncedCurrentDid === state.currentBundleState.did
|
||||||
? state.currentAgentState
|
? state.currentBundleState
|
||||||
: createPublicAgentState(), // Log out if different user.
|
: createPublicBundleState(), // Log out if different user.
|
||||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'partial-refresh-session': {
|
case 'partial-refresh-session': {
|
||||||
const {accountDid, patch} = action
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentAgentState: {
|
|
||||||
...state.currentAgentState,
|
|
||||||
agent,
|
|
||||||
},
|
|
||||||
accounts: state.accounts.map(a => {
|
accounts: state.accounts.map(a => {
|
||||||
if (a.did === accountDid) {
|
if (a.did === accountDid) {
|
||||||
return {
|
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
|
export type SessionAccount = PersistedAccount
|
||||||
|
|
||||||
|
/** Session-change events understood by the reducer and logging hooks. */
|
||||||
|
export type AtpSessionEvent = 'update' | 'expired' | 'network-error'
|
||||||
|
|
||||||
export type SessionStateContext = {
|
export type SessionStateContext = {
|
||||||
accounts: SessionAccount[]
|
accounts: SessionAccount[]
|
||||||
currentAccount: SessionAccount | undefined
|
currentAccount: SessionAccount | undefined
|
||||||
@@ -44,11 +47,9 @@ export type SessionApiContext = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
removeAccount: (account: SessionAccount) => void
|
removeAccount: (account: SessionAccount) => void
|
||||||
/**
|
/**
|
||||||
* Calls `getSession` and updates select fields on the current account and
|
* Calls `getSession` and patches the email fields of the current account.
|
||||||
* `BskyAgent`. This is an alternative to `resumeSession`, which updates
|
* Unlike `resumeSession`, this does not rotate tokens or rebuild the session,
|
||||||
* current account/agent using the `persistSessionHandler`, but is more load
|
* so it produces no session-change side effects.
|
||||||
* bearing. This patches in updates without causing any side effects via
|
|
||||||
* `persistSessionHandler`.
|
|
||||||
*/
|
*/
|
||||||
partialRefreshSession: () => Promise<void>
|
partialRefreshSession: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {mimeToExt} from '#/lib/media/video/util'
|
|||||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||||
import {createPublicAgent} from '#/state/session/agent'
|
import {createPublicAgent} from '#/state/session/bridge-agent'
|
||||||
import {
|
import {
|
||||||
type ComposerState,
|
type ComposerState,
|
||||||
type EmbedDraft,
|
type EmbedDraft,
|
||||||
|
|||||||
Reference in New Issue
Block a user