From abdf1abf4b9d81688a79e4deec7b68f39401fa9c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:46:27 +0300 Subject: [PATCH] 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 --- oxlint-suppressions.json | 5 - .../session/__tests__/session-core-test.ts | 929 ++++++++++++++ src/state/session/__tests__/session-test.ts | 1091 +++++++++-------- src/state/session/agent.ts | 373 +----- src/state/session/bridge-agent.ts | 9 +- src/state/session/create-account.ts | 242 ++++ src/state/session/index.tsx | 328 +++-- src/state/session/logging.ts | 23 +- src/state/session/reducer.ts | 123 +- src/state/session/session-core.ts | 364 ++++++ src/state/session/types.ts | 11 +- src/view/com/composer/drafts/state/api.ts | 2 +- 12 files changed, 2467 insertions(+), 1033 deletions(-) create mode 100644 src/state/session/__tests__/session-core-test.ts create mode 100644 src/state/session/create-account.ts create mode 100644 src/state/session/session-core.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index ae3513a254..197bd43b1a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1456,11 +1456,6 @@ "count": 1 } }, - "src/state/session/agent.ts": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "src/state/shell/color-mode.tsx": { "typescript/no-floating-promises": { "count": 2 diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts new file mode 100644 index 0000000000..278847c03c --- /dev/null +++ b/src/state/session/__tests__/session-core-test.ts @@ -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>() +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 { + return { + id: did, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: pdsUrl, + }, + ], + } +} + +function makeSessionData(overrides: Partial = {}): 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 { + 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 + > = {}, +) { + 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 => { + 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 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>[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>() + 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>() + const onUpdated = + jest.fn>() + 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>() + const onUpdateFailure = + jest.fn>() + 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, + ) { + 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') + }) + }) +}) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index eebcfcf8d2..5e067fdb0b 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -1,8 +1,10 @@ -import {AtpAgent} from '@atproto/api' +import {type AtpAgent} from '@atproto/api' +import {type SessionData} from '@atproto/lex-password-session' import {describe, expect, it, jest} from '@jest/globals' -import {agentToSessionAccountOrThrow} from '../agent' import {type Action, getInitialState, reducer, type State} from '../reducer' +import {sessionDataToSessionAccount} from '../session-core' +import {type SessionAccount} from '../types' jest.mock('jwt-decode', () => ({ jwtDecode(_token: string) { @@ -21,14 +23,42 @@ jest.mock('#/lib/notifications/notifications', () => ({ }, })) +// Reuse a bundle within each test: session events are scoped by bundle identity. +function makeBundle(service: string) { + return {service: new URL(service)} +} + +function makeAccount( + service: string, + session: { + active: boolean + did: string + handle: string + accessJwt: string + refreshJwt: string + email?: string + emailAuthFactor?: boolean + emailConfirmed?: boolean + }, +): SessionAccount { + const account = sessionDataToSessionAccount( + session as unknown as SessionData, + service, + ) + if (!account) { + throw new Error('Expected an account') + } + return account +} + describe('session', () => { it('can log in and out', () => { let state = getInitialState([]) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -37,22 +67,22 @@ describe('session', () => { } `) - const agent = new AtpAgent({service: 'https://alice.com'}) - agent.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent, - newAccount: agentToSessionAccountOrThrow(agent), + newBundle: aliceBundle, + newAccount: aliceAccount, }, ]) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') @@ -76,8 +106,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -92,7 +122,7 @@ describe('session', () => { }, ]) // Should keep the account but clear out the tokens. - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].accessJwt).toBe(undefined) @@ -116,8 +146,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -130,26 +160,26 @@ describe('session', () => { it('switches to the latest account, stores all of them', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } + }) state = run(state, [ { // Switch to Alice. type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') - expect(state.currentAgentState.did).toBe('alice-did') - expect(state.currentAgentState.agent).toBe(agent1) + expect(state.currentBundleState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -169,8 +199,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -179,28 +209,28 @@ describe('session', () => { } `) - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { + const bobBundle = makeBundle('https://bob.com') + const bobAccount = makeAccount('https://bob.com', { active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', refreshJwt: 'bob-refresh-jwt-1', - } + }) state = run(state, [ { // Switch to Bob. type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: bobAccount, }, ]) expect(state.accounts.length).toBe(2) // Bob should float upwards. expect(state.accounts[0].did).toBe('bob-did') expect(state.accounts[1].did).toBe('alice-did') - expect(state.currentAgentState.did).toBe('bob-did') - expect(state.currentAgentState.agent).toBe(agent2) + expect(state.currentBundleState.did).toBe('bob-did') + expect(state.currentBundleState.bundle).toBe(bobBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -235,8 +265,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -245,28 +275,28 @@ describe('session', () => { } `) - const agent3 = new AtpAgent({service: 'https://alice.com'}) - agent3.sessionManager.session = { + const aliceBundle2 = makeBundle('https://alice.com') + const aliceAccount2 = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-2', refreshJwt: 'alice-refresh-jwt-2', - } + }) state = run(state, [ { // Switch back to Alice. type: 'switched-to-account', - newAgent: agent3, - newAccount: agentToSessionAccountOrThrow(agent3), + newBundle: aliceBundle2, + newAccount: aliceAccount2, }, ]) expect(state.accounts.length).toBe(2) // Alice should float upwards. expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].handle).toBe('alice-updated.test') - expect(state.currentAgentState.did).toBe('alice-did') - expect(state.currentAgentState.agent).toBe(agent3) + expect(state.currentBundleState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle2) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -301,8 +331,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -311,26 +341,26 @@ describe('session', () => { } `) - const agent4 = new AtpAgent({service: 'https://jay.com'}) - agent4.sessionManager.session = { + const jayBundle = makeBundle('https://jay.com') + const jayAccount = makeAccount('https://jay.com', { active: true, did: 'jay-did', handle: 'jay.test', accessJwt: 'jay-access-jwt-1', refreshJwt: 'jay-refresh-jwt-1', - } + }) state = run(state, [ { // Switch to Jay. type: 'switched-to-account', - newAgent: agent4, - newAccount: agentToSessionAccountOrThrow(agent4), + newBundle: jayBundle, + newAccount: jayAccount, }, ]) expect(state.accounts.length).toBe(3) expect(state.accounts[0].did).toBe('jay-did') - expect(state.currentAgentState.did).toBe('jay-did') - expect(state.currentAgentState.agent).toBe(agent4) + expect(state.currentBundleState.did).toBe('jay-did') + expect(state.currentBundleState.bundle).toBe(jayBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -380,8 +410,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://jay.com/", }, "did": "jay-did", @@ -397,7 +427,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(3) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) // All tokens should be gone. expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) @@ -454,8 +484,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -468,25 +498,25 @@ describe('session', () => { it('can log back in after logging out', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { @@ -496,7 +526,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -516,8 +546,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -526,25 +556,25 @@ describe('session', () => { } `) - const agent2 = new AtpAgent({service: 'https://alice.com'}) - agent2.sessionManager.session = { + const aliceBundle2 = makeBundle('https://alice.com') + const aliceAccount2 = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-2', refreshJwt: 'alice-refresh-jwt-2', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: aliceBundle2, + newAccount: aliceAccount2, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -564,8 +594,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -578,25 +608,25 @@ describe('session', () => { it('can remove active account', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { @@ -605,12 +635,12 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(0) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -623,36 +653,36 @@ describe('session', () => { it('can remove inactive account', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { + }) + const bobBundle = makeBundle('https://bob.com') + const bobAccount = makeAccount('https://bob.com', { active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', refreshJwt: 'bob-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: aliceAccount, }, { type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: bobAccount, }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { @@ -661,7 +691,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -681,8 +711,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -698,51 +728,51 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(0) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) }) it('can log out of the current account', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { + const aliceBundle = makeBundle('https://alice.com') + const aliceAccount = makeAccount('https://alice.com', { active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { + const bobBundle = makeBundle('https://bob.com') + const bobAccount = makeAccount('https://bob.com', { active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', refreshJwt: 'bob-refresh-jwt-1', - } + }) state = run(state, [ { type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: bobAccount, }, ]) expect(state.accounts.length).toBe(2) expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { @@ -754,7 +784,7 @@ describe('session', () => { expect(state.accounts[0].refreshJwt).toBe(undefined) expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -789,8 +819,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -803,40 +833,38 @@ describe('session', () => { it('updates stored account with refreshed tokens', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-2', - refreshJwt: 'alice-refresh-jwt-2', - email: 'alice@foo.bar', - emailAuthFactor: false, - emailConfirmed: false, - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + email: 'alice@foo.bar', + emailAuthFactor: false, + emailConfirmed: false, + }), sessionEvent: 'update', }, ]) @@ -845,7 +873,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -865,8 +893,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -875,22 +903,21 @@ describe('session', () => { } `) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-3', - refreshJwt: 'alice-refresh-jwt-3', - email: 'alice@foo.baz', - emailAuthFactor: true, - emailConfirmed: true, - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-3', + refreshJwt: 'alice-refresh-jwt-3', + email: 'alice@foo.baz', + emailAuthFactor: true, + emailConfirmed: true, + }), sessionEvent: 'update', }, ]) @@ -899,7 +926,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-3') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -919,8 +946,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -929,22 +956,21 @@ describe('session', () => { } `) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-4', - refreshJwt: 'alice-refresh-jwt-4', - email: 'alice@foo.baz', - emailAuthFactor: false, - emailConfirmed: false, - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-4', + refreshJwt: 'alice-refresh-jwt-4', + email: 'alice@foo.baz', + emailAuthFactor: false, + emailConfirmed: false, + }), sessionEvent: 'update', }, ]) @@ -953,7 +979,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-4') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-4') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -973,8 +999,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -987,37 +1013,35 @@ describe('session', () => { it('bails out of update on identical objects', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-2', - refreshJwt: 'alice-refresh-jwt-2', - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }), sessionEvent: 'update', }, ]) @@ -1027,28 +1051,33 @@ describe('session', () => { const lastState = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }), sessionEvent: 'update', }, ]) expect(lastState === state).toBe(true) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-3', - refreshJwt: 'alice-refresh-jwt-3', - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-3', + refreshJwt: 'alice-refresh-jwt-3', + }), sessionEvent: 'update', }, ]) @@ -1056,127 +1085,86 @@ describe('session', () => { expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') }) - it('accepts updates from a stale agent', () => { + it('ignores updates from a stale bundle', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } - - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { - active: true, - did: 'bob-did', - handle: 'bob.test', - accessJwt: 'bob-access-jwt-1', - refreshJwt: 'bob-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') + const bobBundle = makeBundle('https://bob.com') state = run(state, [ { // Switch to Alice. type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, { // Switch to Bob. type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: makeAccount('https://bob.com', { + active: true, + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice-updated.test', - accessJwt: 'alice-access-jwt-2', - refreshJwt: 'alice-refresh-jwt-2', - email: 'alice@foo.bar', - emailAuthFactor: false, - emailConfirmed: false, - } + /* + * An 'update' from the stale (background) Alice bundle is now dropped + * ENTIRELY - identical state object, no token write. A refresh completing + * after switching away must not resurrect fresh tokens into the + * switched-away account entry. + */ + const beforeStaleUpdate = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + email: 'alice@foo.bar', + emailAuthFactor: false, + emailConfirmed: false, + }), sessionEvent: 'update', }, ]) - expect(state.accounts.length).toBe(2) + expect(beforeStaleUpdate === state).toBe(true) expect(state.accounts[1].did).toBe('alice-did') - // Should update Alice's tokens because otherwise they'll be stale. - expect(state.accounts[1].handle).toBe('alice-updated.test') - expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-2') - expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-2') - expect(printState(state)).toMatchInlineSnapshot(` - { - "accounts": [ - { - "accessJwt": "bob-access-jwt-1", - "active": true, - "did": "bob-did", - "email": undefined, - "emailAuthFactor": false, - "emailConfirmed": false, - "handle": "bob.test", - "isSelfHosted": true, - "pdsUrl": undefined, - "refreshJwt": "bob-refresh-jwt-1", - "service": "https://bob.com/", - "signupQueued": false, - "status": undefined, - }, - { - "accessJwt": "alice-access-jwt-2", - "active": true, - "did": "alice-did", - "email": "alice@foo.bar", - "emailAuthFactor": false, - "emailConfirmed": false, - "handle": "alice-updated.test", - "isSelfHosted": true, - "pdsUrl": undefined, - "refreshJwt": "alice-refresh-jwt-2", - "service": "https://alice.com/", - "signupQueued": false, - "status": undefined, - }, - ], - "currentAgentState": { - "agent": { - "service": "https://bob.com/", - }, - "did": "bob-did", - }, - "needsPersist": true, - } - `) + // Alice's stored tokens are untouched (the stale update did not land). + expect(state.accounts[1].handle).toBe('alice.test') + expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') - agent2.sessionManager.session = { - active: true, - did: 'bob-did', - handle: 'bob-updated.test', - accessJwt: 'bob-access-jwt-2', - refreshJwt: 'bob-refresh-jwt-2', - } state = run(state, [ { - // Update Bob. - type: 'received-agent-event', + // Update Bob (the current bundle) - this still applies. + type: 'received-session-event', accountDid: 'bob-did', - agent: agent2, - refreshedAccount: agentToSessionAccountOrThrow(agent2), + bundle: bobBundle, + refreshedAccount: makeAccount('https://bob.com', { + active: true, + did: 'bob-did', + handle: 'bob-updated.test', + accessJwt: 'bob-access-jwt-2', + refreshJwt: 'bob-refresh-jwt-2', + }), sessionEvent: 'update', }, ]) @@ -1205,23 +1193,23 @@ describe('session', () => { "status": undefined, }, { - "accessJwt": "alice-access-jwt-2", + "accessJwt": "alice-access-jwt-1", "active": true, "did": "alice-did", - "email": "alice@foo.bar", + "email": undefined, "emailAuthFactor": false, "emailConfirmed": false, - "handle": "alice-updated.test", + "handle": "alice.test", "isSelfHosted": true, "pdsUrl": undefined, - "refreshJwt": "alice-refresh-jwt-2", + "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", "signupQueued": false, "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -1230,14 +1218,13 @@ describe('session', () => { } `) - // Ignore other events for inactive agent. + // Ignore other events for the inactive bundle too (network-error, expired). const lastState = state - agent1.sessionManager.session = undefined state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'network-error', }, @@ -1245,9 +1232,9 @@ describe('session', () => { expect(lastState === state).toBe(true) state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'expired', }, @@ -1255,37 +1242,128 @@ describe('session', () => { expect(lastState === state).toBe(true) }) - it('ignores updates from a removed agent', () => { + it('drops an update from a stale bundle even when its account entry still exists (no resurrection)', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') + state = run(state, [ + { + type: 'switched-to-account', + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), + }, + ]) + expect(state.currentBundleState.did).toBe('alice-did') - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { - active: true, - did: 'bob-did', - handle: 'bob.test', - accessJwt: 'bob-access-jwt-1', - refreshJwt: 'bob-refresh-jwt-1', - } + // Alice logs out: her account entry stays, but tokens are cleared and the + // current bundle becomes the public (logged-out) bundle. + state = run(state, [{type: 'logged-out-current-account'}]) + expect(state.currentBundleState.did).toBe(undefined) + expect(state.accounts[0].did).toBe('alice-did') + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + + /* + * A refresh that was already in flight on the (now stale) Alice bundle + * completes and delivers fresh tokens. It must NOT resurrect them into the + * soft-logged-out account entry - the bundle no longer matches the current + * (public) bundle, so the event is dropped entirely. + */ + const afterLogout = state + state = run(state, [ + { + type: 'received-session-event', + accountDid: 'alice-did', + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }), + sessionEvent: 'update', + }, + ]) + expect(afterLogout === state).toBe(true) + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) + }) + + it('applies an update from the current bundle', () => { + let state = getInitialState([]) + + const aliceBundle = makeBundle('https://alice.com') + state = run(state, [ + { + type: 'switched-to-account', + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), + }, + ]) + + state = run(state, [ + { + type: 'received-session-event', + accountDid: 'alice-did', + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }), + sessionEvent: 'update', + }, + ]) + // The current bundle's update lands and rotates the stored tokens. + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') + expect(state.currentBundleState.did).toBe('alice-did') + }) + + it('ignores updates from a removed bundle', () => { + let state = getInitialState([]) + + const aliceBundle = makeBundle('https://alice.com') + const bobBundle = makeBundle('https://bob.com') state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, { type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: makeAccount('https://bob.com', { + active: true, + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + }), }, { type: 'removed-account', @@ -1293,58 +1371,55 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-2', - refreshJwt: 'alice-refresh-jwt-2', - } state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, - refreshedAccount: agentToSessionAccountOrThrow(agent1), + bundle: aliceBundle, + refreshedAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }), sessionEvent: 'update', }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('bob-did') expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') }) it('ignores network errors', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') state = run(state, [ { // Switch to Alice. type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') - agent1.sessionManager.session = undefined state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'network-error', }, @@ -1352,7 +1427,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1372,8 +1447,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -1386,31 +1461,29 @@ describe('session', () => { it('resets tokens on expired event', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') - agent1.sessionManager.session = undefined state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: agent1, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'expired', }, @@ -1418,7 +1491,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1438,74 +1511,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { - "service": "https://public.api.bsky.app/", - }, - "did": undefined, - }, - "needsPersist": true, - } - `) - }) - - it('resets tokens on created-failed event', () => { - let state = getInitialState([]) - - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } - state = run(state, [ - { - type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), - }, - ]) - expect(state.accounts.length).toBe(1) - expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') - - agent1.sessionManager.session = undefined - state = run(state, [ - { - type: 'received-agent-event', - accountDid: 'alice-did', - agent: agent1, - refreshedAccount: undefined, - sessionEvent: 'create-failed', - }, - ]) - expect(state.accounts.length).toBe(1) - expect(state.accounts[0].accessJwt).toBe(undefined) - expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) - expect(printState(state)).toMatchInlineSnapshot(` - { - "accounts": [ - { - "accessJwt": undefined, - "active": true, - "did": "alice-did", - "email": undefined, - "emailAuthFactor": false, - "emailConfirmed": false, - "handle": "alice.test", - "isSelfHosted": true, - "pdsUrl": undefined, - "refreshJwt": undefined, - "service": "https://alice.com/", - "signupQueued": false, - "status": undefined, - }, - ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -1518,59 +1525,53 @@ describe('session', () => { it('replaces local accounts with synced accounts', () => { let state = getInitialState([]) - const agent1 = new AtpAgent({service: 'https://alice.com'}) - agent1.sessionManager.session = { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - } - const agent2 = new AtpAgent({service: 'https://bob.com'}) - agent2.sessionManager.session = { - active: true, - did: 'bob-did', - handle: 'bob.test', - accessJwt: 'bob-access-jwt-1', - refreshJwt: 'bob-refresh-jwt-1', - } + const aliceBundle = makeBundle('https://alice.com') + const bobBundle = makeBundle('https://bob.com') state = run(state, [ { type: 'switched-to-account', - newAgent: agent1, - newAccount: agentToSessionAccountOrThrow(agent1), + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), }, { type: 'switched-to-account', - newAgent: agent2, - newAccount: agentToSessionAccountOrThrow(agent2), + newBundle: bobBundle, + newAccount: makeAccount('https://bob.com', { + active: true, + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + }), }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') - const anotherTabAgent1 = new AtpAgent({service: 'https://jay.com'}) - anotherTabAgent1.sessionManager.session = { - active: true, - did: 'jay-did', - handle: 'jay.test', - accessJwt: 'jay-access-jwt-1', - refreshJwt: 'jay-refresh-jwt-1', - } - const anotherTabAgent2 = new AtpAgent({service: 'https://alice.com'}) - anotherTabAgent2.sessionManager.session = { - active: true, - did: 'bob-did', - handle: 'bob.test', - accessJwt: 'bob-access-jwt-2', - refreshJwt: 'bob-refresh-jwt-2', - } state = run(state, [ { type: 'synced-accounts', syncedAccounts: [ - agentToSessionAccountOrThrow(anotherTabAgent1), - agentToSessionAccountOrThrow(anotherTabAgent2), + makeAccount('https://jay.com', { + active: true, + did: 'jay-did', + handle: 'jay.test', + accessJwt: 'jay-access-jwt-1', + refreshJwt: 'jay-refresh-jwt-1', + }), + makeAccount('https://alice.com', { + active: true, + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-2', + refreshJwt: 'bob-refresh-jwt-2', + }), ], syncedCurrentDid: 'bob-did', }, @@ -1580,8 +1581,8 @@ describe('session', () => { expect(state.accounts[1].did).toBe('bob-did') expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2') // Keep Bob logged in. - // (We patch up agent.session outside the reducer for this to work.) - expect(state.currentAgentState.did).toBe('bob-did') + // (The bundle is rebuilt from the synced tokens outside the reducer.) + expect(state.currentBundleState.did).toBe('bob-did') expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { @@ -1617,8 +1618,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -1627,18 +1628,18 @@ describe('session', () => { } `) - const anotherTabAgent3 = new AtpAgent({service: 'https://clarence.com'}) - anotherTabAgent3.sessionManager.session = { - active: true, - did: 'clarence-did', - handle: 'clarence.test', - accessJwt: 'clarence-access-jwt-2', - refreshJwt: 'clarence-refresh-jwt-2', - } state = run(state, [ { type: 'synced-accounts', - syncedAccounts: [agentToSessionAccountOrThrow(anotherTabAgent3)], + syncedAccounts: [ + makeAccount('https://clarence.com', { + active: true, + did: 'clarence-did', + handle: 'clarence.test', + accessJwt: 'clarence-access-jwt-2', + refreshJwt: 'clarence-refresh-jwt-2', + }), + ], syncedCurrentDid: 'clarence-did', }, ]) @@ -1646,7 +1647,7 @@ describe('session', () => { expect(state.accounts[0].did).toBe('clarence-did') // Log out because we have no matching user. // (In practice, we'll resume this session outside the reducer.) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { @@ -1667,8 +1668,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -1677,6 +1678,94 @@ describe('session', () => { } `) }) + + it('replaces the current bundle on same-did cross-tab sync', () => { + let state = getInitialState([]) + + const aliceBundle = makeBundle('https://alice.com') + state = run(state, [ + { + type: 'switched-to-account', + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + }), + }, + ]) + expect(state.currentBundleState.bundle).toBe(aliceBundle) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + + // A fresh bundle rebuilt from synced tokens (no network). + const aliceBundle2 = makeBundle('https://alice.com') + const aliceAccount2 = makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + }) + state = run(state, [ + { + type: 'replaced-current-bundle', + newBundle: aliceBundle2, + newAccount: aliceAccount2, + }, + ]) + // The bundle is swapped in place, the did is preserved. + expect(state.currentBundleState.bundle).toBe(aliceBundle2) + expect(state.currentBundleState.did).toBe('alice-did') + // The matching account entry is replaced with the synced one. + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('alice-did') + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') + // Synced from another tab - don't persist. + expect(state.needsPersist).toBe(false) + }) + + it('does not touch the bundle on partial-refresh-session', () => { + let state = getInitialState([]) + + const aliceBundle = makeBundle('https://alice.com') + state = run(state, [ + { + type: 'switched-to-account', + newBundle: aliceBundle, + newAccount: makeAccount('https://alice.com', { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + email: 'alice@foo.bar', + emailAuthFactor: false, + emailConfirmed: false, + }), + }, + ]) + expect(state.currentBundleState.bundle).toBe(aliceBundle) + expect(state.accounts[0].emailConfirmed).toBe(false) + expect(state.accounts[0].emailAuthFactor).toBe(false) + + state = run(state, [ + { + type: 'partial-refresh-session', + accountDid: 'alice-did', + patch: {emailConfirmed: true, emailAuthFactor: true}, + }, + ]) + // The account email fields are patched. + expect(state.accounts[0].emailConfirmed).toBe(true) + expect(state.accounts[0].emailAuthFactor).toBe(true) + // The bundle is untouched - no session mutation, same reference. + expect(state.currentBundleState.bundle).toBe(aliceBundle) + expect(state.currentBundleState.did).toBe('alice-did') + expect(state.needsPersist).toBe(true) + }) }) function run(initialState: State, actions: Action[]): State { @@ -1690,9 +1779,9 @@ function run(initialState: State, actions: Action[]): State { function printState(state: State) { return { accounts: state.accounts, - currentAgentState: { - agent: {service: state.currentAgentState.agent.service}, - did: state.currentAgentState.did, + currentBundleState: { + bundle: {service: state.currentBundleState.bundle.service}, + did: state.currentBundleState.did, }, needsPersist: state.needsPersist, } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 2c1ad89ab0..7246454ed2 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,324 +1,17 @@ import { Agent as BaseAgent, - type AppBskyActorProfile, - AtpAgent, type AtprotoServiceType, - type AtpSessionData, - type AtpSessionEvent, type Did, - type Un$Typed, } from '@atproto/api' -import {TID} from '@atproto/common-web' - -import {networkRetry} from '#/lib/async/retry' -import { - BLUESKY_PROXY_HEADER, - BSKY_SERVICE, - DISCOVER_SAVED_FEED, - IS_PROD_SERVICE, - PUBLIC_BSKY_SERVICE, - TIMELINE_SAVED_FEED, -} from '#/lib/constants' -import {logger} from '#/logger' -import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' -import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' -import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' -import { - prefetchAgeAssuranceServerData, - setBirthdateForDid, - setCreatedAtForDid, -} from '#/ageAssurance/data' -import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' -import {features} from '#/analytics' -import {addSessionErrorLog} from './logging' -import { - configureModerationForAccount, - configureModerationForGuest, -} from './moderation' -import {networkAwareFetch} from './network' -import { - isSessionExpired, - isSignupQueued, - sessionAccountToSession, -} from './session-data' -import {type SessionAccount} from './types' - -export {sessionAccountToSession} from './session-data' export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}` -export function createPublicAgent() { - configureModerationForGuest() // Side effect but only relevant for tests - - const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - return agent -} - -export async function createAgentAndResume( - storedAccount: SessionAccount, - onSessionChange: ( - agent: AtpAgent, - did: string, - event: AtpSessionEvent, - ) => void, -) { - const agent = new BskyAppAgent({service: storedAccount.service}) - if (storedAccount.pdsUrl) { - agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) - } - const gates = features.refresh({ - strategy: 'prefer-low-latency', - }) - configureModerationForAccount(agent, storedAccount) - const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) - if (isSessionExpired(storedAccount)) { - await networkRetry(1, () => agent.resumeSession(prevSession)) - } else { - agent.sessionManager.session = prevSession - } - - // after session is attached - const aa = prefetchAgeAssuranceServerData({agent}) - - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - - return agent.prepare({ - resolvers: [gates, aa], - onSessionChange, - }) -} - -export async function createAgentAndLogin( - { - service, - identifier, - password, - authFactorToken, - }: { - service: string - identifier: string - password: string - authFactorToken?: string - }, - onSessionChange: ( - agent: AtpAgent, - did: string, - event: AtpSessionEvent, - ) => void, -) { - const agent = new BskyAppAgent({service}) - await agent.login({ - identifier, - password, - authFactorToken, - allowTakendown: true, - }) - - const account = agentToSessionAccountOrThrow(agent) - const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - configureModerationForAccount(agent, account) - const aa = prefetchAgeAssuranceServerData({agent}) - - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - - return agent.prepare({ - resolvers: [gates, aa], - onSessionChange, - }) -} - -export async function createAgentAndCreateAccount( - { - service, - email, - password, - handle, - birthDate, - inviteCode, - verificationPhone, - verificationCode, - }: { - service: string - email: string - password: string - handle: string - birthDate: Date - inviteCode?: string - verificationPhone?: string - verificationCode?: string - }, - onSessionChange: ( - agent: AtpAgent, - did: string, - event: AtpSessionEvent, - ) => void, -) { - const agent = new BskyAppAgent({service}) - await agent.createAccount({ - email, - password, - handle, - inviteCode, - verificationPhone, - verificationCode, - }) - const account = agentToSessionAccountOrThrow(agent) - const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - configureModerationForAccount(agent, account) - - const createdAt = new Date().toISOString() - const birthdate = birthDate.toISOString() - - /* - * Since we have a race with account creation, profile creation, and AA - * state, set these values locally to ensure sync reads. Values are written - * to the server in the next step, so on subsequent reloads, the server will - * be the source of truth. - */ - setCreatedAtForDid({did: account.did, createdAt}) - setBirthdateForDid({did: account.did, birthdate}) - snoozeBirthdateUpdateAllowedForDid(account.did) - // do this last - const aa = prefetchAgeAssuranceServerData({agent}) - - // Not awaited so that we can still get into onboarding. - // This is OK because we won't let you toggle adult stuff until you set the date. - if (IS_PROD_SERVICE(service)) { - void Promise.allSettled([ - networkRetry(3, () => { - return agent.setPersonalDetails({ - birthDate: birthdate, - }) - }).catch(e => { - logger.info(`createAgentAndCreateAccount: failed to set birthDate`) - throw e - }), - networkRetry(3, () => { - return agent.upsertProfile(prev => { - const next: Un$Typed = 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 = prev || {} - next.createdAt = prev?.createdAt || new Date().toISOString() - return next - }) - }).catch(e => { - logger.info( - `createAgentAndCreateAccount: failed to set initial profile`, - ) - throw e - }), - ]).then(promises => { - const rejected = promises.filter(p => p.status === 'rejected') - if (rejected.length > 0) { - logger.error( - `session: createAgentAndCreateAccount failed to save personal details and feeds`, - ) - } - }) - } - - try { - // snooze first prompt after signup, defer to next prompt - snoozeEmailConfirmationPrompt() - } catch (e: any) { - logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`}) - } - - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - - return agent.prepare({ - resolvers: [gates, aa], - onSessionChange, - }) -} - -export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount { - const account = agentToSessionAccount(agent) - if (!account) { - throw Error('Expected an active session') - } - return account -} - -export function agentToSessionAccount( - agent: AtpAgent, -): SessionAccount | undefined { - if (!agent.session) { - return undefined - } - return { - service: agent.serviceUrl.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - emailAuthFactor: agent.session.emailAuthFactor || false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - signupQueued: isSignupQueued(agent.session.accessJwt), - active: agent.session.active, - status: agent.session.status, - pdsUrl: agent.pdsUrl?.toString(), - isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), - } -} - +/** + * A bare `Agent` that applies a service-proxy header on construction. + * + * Used for the unauthenticated, service-specific calls that cannot go through + * the session agent (PDS detection, password reset, handle availability). + */ export class Agent extends BaseAgent { constructor( proxyHeader: ProxyHeaderValue | null, @@ -330,57 +23,3 @@ export class Agent extends BaseAgent { } } } - -// Not exported. Use factories above to create it. -// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do. -// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it -// feels safer to just let those run as-is and set the header afterward. -class BskyAppAgent extends AtpAgent { - persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = - undefined - - constructor({service}: {service: string}) { - super({ - service, - fetch: networkAwareFetch, - persistSession: (event: AtpSessionEvent) => { - if (this.persistSessionHandler) { - this.persistSessionHandler(event) - } - }, - }) - } - - async prepare({ - resolvers, - onSessionChange, - }: { - // Not awaited in the calling code so we can delay blocking on them. - resolvers: Promise[] - 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} diff --git a/src/state/session/bridge-agent.ts b/src/state/session/bridge-agent.ts index 58f1d0b64f..a8bd24fb7c 100644 --- a/src/state/session/bridge-agent.ts +++ b/src/state/session/bridge-agent.ts @@ -358,13 +358,8 @@ export class BskyAppAgent extends AtpAgent { } } -/** - * Build the logged-out agent used for public/guest browsing. - * - * Temporary name: it exists alongside `createPublicAgent` in `./agent` until - * the provider is switched over to the bridge. - */ -export function createPublicBridgeAgent() { +/** Build the logged-out agent used for public/guest browsing. */ +export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests const agent = new BskyAppAgent( diff --git a/src/state/session/create-account.ts b/src/state/session/create-account.ts new file mode 100644 index 0000000000..18ccbeef5e --- /dev/null +++ b/src/state/session/create-account.ts @@ -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[] = [ + 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 = 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, + 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( + description: string, + retries: number, + task: () => Promise, +) { + return networkRetry(retries, task).catch(e => { + logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`) + throw e + }) +} + +async function reportPostSignupFailures(tasks: Promise[]) { + const results = await Promise.allSettled(tasks) + if (results.some(result => result.status === 'rejected')) { + logger.error( + `session: createSessionBundleAndCreateAccount failed to save post-signup settings`, + ) + } +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index c1720ddb23..2881b5e792 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -8,7 +8,8 @@ import { useState, useSyncExternalStore, } from 'react' -import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' +import {type AtpAgent} from '@atproto/api' +import {type SessionData} from '@atproto/lex-password-session' import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' @@ -16,15 +17,19 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' import {emitSessionDropped} from '../events' -import { - agentToSessionAccount, - type BskyAppAgent, - createAgentAndCreateAccount, - createAgentAndLogin, - createAgentAndResume, - sessionAccountToSession, -} from './agent' +import {createSessionBundleAndCreateAccount} from './create-account' +import {pickExpiryRescueCandidate} from './expiry-rescue' import {type Action, getInitialState, reducer, type State} from './reducer' +import { + type AtpSessionEvent, + createSessionBundleAndLogin, + createSessionBundleAndResume, + createSessionBundleFromStoredAccount, + disposeBundle, + type PublicSessionBundle, + type SessionBundle, + sessionDataToSessionAccount, +} from './session-core' export {isSignupQueued} from './session-data' import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' @@ -47,8 +52,11 @@ const StateContext = createContext({ }) StateContext.displayName = 'SessionStateContext' -const AgentContext = createContext(null) -AgentContext.displayName = 'SessionAgentContext' +/** Active account bundle, or the public bundle when logged out. */ +const BundleContext = createContext( + null, +) +BundleContext.displayName = 'SessionBundleContext' const ApiContext = createContext({ createAccount: async () => {}, @@ -92,7 +100,7 @@ class SessionStore { const persistedData = { accounts: nextState.accounts, currentAccount: nextState.accounts.find( - a => a.did === nextState.currentAgentState.did, + a => a.did === nextState.currentBundleState.did, ), } addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) @@ -110,15 +118,107 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const state = useSyncExternalStore(store.subscribe, store.getState) const onboardingDispatch = useOnboardingDispatch() - const onAgentSessionChange = useCallback( - (agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { - const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away. - if (sessionEvent === 'expired' || sessionEvent === 'create-failed') { + // Refresh-token generations that have already failed during expiry rescue. + const failedExpiryTokensRef = useRef>>(new Map()) + /* + * Rescued bundles need this callback for their own events. A ref avoids a + * self-reference in the callback's dependency list. + */ + const onSessionChangeRef = useRef< + | (( + bundle: SessionBundle, + accountDid: string, + sessionEvent: AtpSessionEvent, + sessionData?: SessionData, + ) => void) + | null + >(null) + + const onSessionChange = useCallback( + ( + bundle: SessionBundle, + accountDid: string, + sessionEvent: AtpSessionEvent, + sessionData?: SessionData, + ) => { + if (sessionEvent === 'update' && sessionData) { + failedExpiryTokensRef.current.get(accountDid)?.clear() + } + + /* + * PasswordSession invokes its hooks before updating its live getter. Use + * the delivered payload so a refresh persists the newly rotated tokens. + */ + const refreshedAccount = + sessionEvent === 'update' && sessionData + ? sessionDataToSessionAccount(sessionData, sessionData.service) + : undefined + + /* + * A stale tab may expire a token after another tab has already rotated it. + * Prefer a newer persisted or reducer generation over logging every tab + * out. Failed generations are recorded and bounded to guarantee that a + * repeatedly expiring session eventually falls through to logout. + */ + if (sessionEvent === 'expired') { + const current = store.getState() + const currentBundle = current.currentBundleState.bundle as unknown as + | SessionBundle + | PublicSessionBundle + const dyingRefreshJwt = sessionData?.refreshJwt + // Stale bundle events are handled by the reducer's identity guard. + if ( + currentBundle === bundle && + current.currentBundleState.did === accountDid && + dyingRefreshJwt + ) { + let failedSet = failedExpiryTokensRef.current.get(accountDid) + if (!failedSet) { + failedSet = new Set() + failedExpiryTokensRef.current.set(accountDid, failedSet) + } + failedSet.add(dyingRefreshJwt) + + const persistedCandidate = persisted + .readLatest('session') + .accounts.find(a => a.did === accountDid) + const reducerCandidate = current.accounts.find( + a => a.did === accountDid, + ) + const candidate = pickExpiryRescueCandidate({ + dyingRefreshJwt, + candidates: [persistedCandidate, reducerCandidate], + failedRefreshJwts: failedSet, + }) + + if (candidate) { + const rebuilt = createSessionBundleFromStoredAccount( + candidate, + onSessionChangeRef.current!, + ) + if (rebuilt) { + store.dispatch({ + type: 'replaced-current-bundle', + newBundle: rebuilt.bundle, + newAccount: rebuilt.account, + }) + return + } + } + } + } + + // Only the current bundle may report that its session was dropped. + if ( + sessionEvent === 'expired' && + store.getState().currentBundleState.bundle === bundle + ) { emitSessionDropped() } + // Bundle identity prevents stale sessions from changing the active account. store.dispatch({ - type: 'received-agent-event', - agent, + type: 'received-session-event', + bundle, refreshedAccount, accountDid, sessionEvent, @@ -126,15 +226,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [store], ) + onSessionChangeRef.current = onSessionChange const createAccount = useCallback( async (params, metrics) => { addSessionDebugLog({type: 'method:start', method: 'createAccount'}) const signal = cancelPendingTask() ax.metric('account:create:begin', {}) - const {agent, account} = await createAgentAndCreateAccount( + const {bundle, account} = await createSessionBundleAndCreateAccount( params, - onAgentSessionChange, + onSessionChange, ) if (signal.aborted) { @@ -142,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } store.dispatch({ type: 'switched-to-account', - newAgent: agent, + newBundle: bundle, newAccount: account, }) ax.metric('account:create:success', metrics, { @@ -150,16 +251,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) }, - [ax, store, onAgentSessionChange, cancelPendingTask], + [ax, store, onSessionChange, cancelPendingTask], ) const login = useCallback( async (params, logContext) => { addSessionDebugLog({type: 'method:start', method: 'login'}) const signal = cancelPendingTask() - const {agent, account} = await createAgentAndLogin( + const {bundle, account} = await createSessionBundleAndLogin( params, - onAgentSessionChange, + onSessionChange, ) if (signal.aborted) { @@ -167,7 +268,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } store.dispatch({ type: 'switched-to-account', - newAgent: agent, + newBundle: bundle, newAccount: account, }) ax.metric( @@ -177,7 +278,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) addSessionDebugLog({type: 'method:end', method: 'login', account}) }, - [ax, store, onAgentSessionChange, cancelPendingTask], + [ax, store, onSessionChange, cancelPendingTask], ) const logoutCurrentAccount = useCallback< @@ -196,17 +297,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) { { session: utils.accountToSessionMetadata( prevState.accounts.find( - a => a.did === prevState.currentAgentState.did, + a => a.did === prevState.currentBundleState.did, ), ), }, ) addSessionDebugLog({type: 'method:end', method: 'logout'}) - if (prevState.currentAgentState.did) { + if (prevState.currentBundleState.did) { clearAgeAssuranceServerDataForDid({ - did: prevState.currentAgentState.did, + did: prevState.currentBundleState.did, }) - void clearPersistedQueryStorage(prevState.currentAgentState.did) + void clearPersistedQueryStorage(prevState.currentBundleState.did) } // reset onboarding flow on logout onboardingDispatch({type: 'skip'}) @@ -230,7 +331,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { { session: utils.accountToSessionMetadata( prevState.accounts.find( - a => a.did === prevState.currentAgentState.did, + a => a.did === prevState.currentBundleState.did, ), ), }, @@ -254,17 +355,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account: storedAccount, }) const signal = cancelPendingTask() - const {agent, account} = await createAgentAndResume( + const {bundle, account} = await createSessionBundleAndResume( storedAccount, - onAgentSessionChange, + onSessionChange, ) if (signal.aborted) { + // The factory returns an armed bundle, so a superseded resume must dispose it. + disposeBundle(bundle) + return + } + /* + * A cross-tab logout may clear or remove the account while resume is in + * flight. Check the account entry rather than the current did so ordinary + * account switching remains valid. + */ + const latest = store.getState() + const latestEntry = latest.accounts.find(a => a.did === account.did) + if (!latestEntry || !latestEntry.refreshJwt) { + disposeBundle(bundle) return } store.dispatch({ type: 'switched-to-account', - newAgent: agent, + newBundle: bundle, newAccount: account, }) addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) @@ -273,19 +387,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) { onboardingDispatch({type: 'skip'}) } }, - [store, onAgentSessionChange, cancelPendingTask, onboardingDispatch], + [store, onSessionChange, cancelPendingTask, onboardingDispatch], ) const partialRefreshSession = useCallback< SessionApiContext['partialRefreshSession'] >(async () => { - const agent = state.currentAgentState.agent as BskyAppAgent + const bundle = state.currentBundleState.bundle as unknown as SessionBundle const signal = cancelPendingTask() - const {data} = await agent.com.atproto.server.getSession() + /* getSession targets the PDS; only the persisted account fields are patched. */ + const {data} = await bundle.agent.com.atproto.server.getSession() if (signal.aborted) return store.dispatch({ type: 'partial-refresh-session', - accountDid: agent.session!.did, + /* + * Read the did off the response rather than the session: the bundle may + * have been disposed while the request was in flight, and the live + * getters throw in that state. + */ + accountDid: data.did, patch: { emailConfirmed: data.emailConfirmed, emailAuthFactor: data.emailAuthFactor, @@ -322,38 +442,90 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const syncedAccount = synced.accounts.find( a => a.did === synced.currentAccount?.did, ) + /* + * Cancel pending work when another tab logs out the account this tab + * considers current. Do not cancel unrelated work between logged-out tabs. + */ + const syncedDid = syncedAccount?.refreshJwt + ? syncedAccount.did + : undefined + if ( + syncedDid === undefined && + state.currentBundleState.did !== undefined + ) { + cancelPendingTask() + } if (syncedAccount && syncedAccount.refreshJwt) { - if (syncedAccount.did !== state.currentAgentState.did) { - /* - * Web handling: if leader tab has switched to a diff account that is - * stale, it will refresh the session before triggering the update to - * follower tabs. Follower tabs will therefore receive the fresh - * session. See APP-1960, or ask Eric. - */ + if (syncedAccount.did !== state.currentBundleState.did) { + // The leader refreshes before broadcasting, so followers receive fresh tokens. void resumeSession(syncedAccount) } else { - const agent = state.currentAgentState.agent as AtpAgent - const prevSession = agent.session - // eslint-disable-next-line react-compiler/react-compiler - agent.sessionManager.session = sessionAccountToSession(syncedAccount) - addSessionDebugLog({ - type: 'agent:patch', - agent, - prevSession, - nextSession: agent.session, + /* + * PasswordSession cannot be patched in place. Rebuild from the tokens + * the leader already refreshed, then dispose the previous bundle. + */ + const prevBundle = state.currentBundleState.bundle as unknown as + | SessionBundle + | PublicSessionBundle + // Avoid replacing the live bundle for an unrelated account update. + const live = + prevBundle.session && !prevBundle.session.destroyed + ? prevBundle.session.session + : undefined + if ( + live && + live.accessJwt === syncedAccount.accessJwt && + live.refreshJwt === syncedAccount.refreshJwt + ) { + return + } + const rebuilt = createSessionBundleFromStoredAccount( + syncedAccount, + onSessionChange, + newBundle => { + const current = store.getState() + const latestAccount = current.accounts.find( + account => account.did === syncedAccount.did, + ) + const isCurrent = + current.currentBundleState.bundle === prevBundle && + latestAccount?.accessJwt === syncedAccount.accessJwt && + latestAccount?.refreshJwt === syncedAccount.refreshJwt + if (isCurrent) { + addSessionDebugLog({ + type: 'bundle:patch', + bundle: newBundle, + prevSession: + prevBundle.session && !prevBundle.session.destroyed + ? prevBundle.session.session + : undefined, + nextSession: newBundle.session.session, + }) + } + return isCurrent + }, + ) + if (!rebuilt) { + return + } + const {bundle: newBundle, account: newAccount} = rebuilt + store.dispatch({ + type: 'replaced-current-bundle', + newBundle, + newAccount, }) } } }) - }, [store, state, resumeSession]) + }, [store, state, resumeSession, onSessionChange, cancelPendingTask]) const stateContext = useMemo( () => ({ accounts: state.accounts, currentAccount: state.accounts.find( - a => a.did === state.currentAgentState.did, + a => a.did === state.currentBundleState.did, ), - hasSession: !!state.currentAgentState.did, + hasSession: !!state.currentBundleState.did, }), [state], ) @@ -379,26 +551,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ], ) + const bundle = state.currentBundleState.bundle as unknown as + | SessionBundle + | PublicSessionBundle + // @ts-expect-error window type is not declared, debug only // eslint-disable-next-line react-hooks/immutability - if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent + if (__DEV__ && IS_WEB) window.agent = bundle.agent - const agent = state.currentAgentState.agent as BskyAppAgent - const currentAgentRef = useRef(agent) + const currentBundleRef = useRef(bundle) useEffect(() => { - if (currentAgentRef.current !== agent) { - // Read the previous value and immediately advance the pointer. - const prevAgent = currentAgentRef.current - currentAgentRef.current = agent - addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) - // We never reuse agents so let's fully neutralize the previous one. - // This ensures it won't try to consume any refresh tokens. - prevAgent.dispose() + if (currentBundleRef.current !== bundle) { + const prevBundle = currentBundleRef.current + currentBundleRef.current = bundle + addSessionDebugLog({ + type: 'bundle:switch', + prevBundle, + nextBundle: bundle, + }) + // Replaced bundles must never consume another refresh token. + disposeBundle(prevBundle) } - }, [agent]) + }, [bundle]) return ( - + ) { - + ) } @@ -453,10 +630,13 @@ export function useRequireAuth() { ) } +/** + * The active session's agent, or the public agent when logged out. + */ export function useAgent(): AtpAgent { - const agent = useContext(AgentContext) - if (!agent) { + const bundle = useContext(BundleContext) + if (!bundle) { throw Error('useAgent() must be below .') } - return agent + return bundle.agent } diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts index da017d823f..73cd1fdb34 100644 --- a/src/state/session/logging.ts +++ b/src/state/session/logging.ts @@ -1,8 +1,6 @@ -import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api' - import {type Schema} from '../persisted' import {type Action, type State} from './reducer' -import {type SessionAccount} from './types' +import {type AtpSessionEvent, type SessionAccount} from './types' type Reducer = (state: State, action: Action) => State @@ -46,15 +44,20 @@ type Log = data: Schema['session'] } | { - type: 'agent:switch' - prevAgent: object - nextAgent: object + type: 'bundle:switch' + prevBundle: object + nextBundle: object } | { - type: 'agent:patch' - agent: object - prevSession: AtpSessionData | undefined - nextSession: AtpSessionData | undefined + /* + * Dev-only bundle-swap log. The bundle is treated as an opaque object + * (the reducer never reads its internals); the session snapshots are + * plain objects captured for debugging. + */ + type: 'bundle:patch' + bundle: object + prevSession: object | undefined + nextSession: object | undefined } export function wrapSessionReducerForLogging(reducer: Reducer): Reducer { diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index d22dd4a021..8571539f87 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -1,43 +1,43 @@ -import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' - import {unregisterPushToken} from '#/lib/notifications/notifications' import {logger} from '#/lib/notifications/util' -import {createPublicAgent} from './agent' import {wrapSessionReducerForLogging} from './logging' -import {type SessionAccount} from './types' +import {createPublicSessionBundle} from './session-core' +import {type AtpSessionEvent, type SessionAccount} from './types' import {createTemporaryAgentsAndResume} from './util' -// A hack so that the reducer can't read anything from the agent. -// From the reducer's point of view, it should be a completely opaque object. -type OpaqueBskyAgent = { +// Keep session internals outside the reducer's static view of a bundle. +type OpaqueSessionBundle = { readonly service: URL - readonly api: unknown - readonly app: unknown - readonly com: unknown } -type AgentState = { - readonly agent: OpaqueBskyAgent +type BundleState = { + readonly bundle: OpaqueSessionBundle readonly did: string | undefined } export type State = { readonly accounts: SessionAccount[] - readonly currentAgentState: AgentState - needsPersist: boolean // Mutated in an effect. + readonly currentBundleState: BundleState + needsPersist: boolean // Cleared after persistence is scheduled. } export type Action = | { - type: 'received-agent-event' - agent: OpaqueBskyAgent + type: 'received-session-event' + bundle: OpaqueSessionBundle accountDid: string refreshedAccount: SessionAccount | undefined sessionEvent: AtpSessionEvent } | { type: 'switched-to-account' - newAgent: OpaqueBskyAgent + newBundle: OpaqueSessionBundle + newAccount: SessionAccount + } + | { + // Replace an immutable session from synced or rescued tokens without rebroadcasting. + type: 'replaced-current-bundle' + newBundle: OpaqueSessionBundle newAccount: SessionAccount } | { @@ -61,9 +61,9 @@ export type Action = patch: Pick } -function createPublicAgentState(): AgentState { +function createPublicBundleState(): BundleState { return { - agent: createPublicAgent(), + bundle: createPublicSessionBundle(), did: undefined, } } @@ -71,22 +71,20 @@ function createPublicAgentState(): AgentState { export function getInitialState(persistedAccounts: SessionAccount[]): State { return { accounts: persistedAccounts, - currentAgentState: createPublicAgentState(), + currentBundleState: createPublicBundleState(), needsPersist: false, } } let reducer = (state: State, action: Action): State => { switch (action.type) { - case 'received-agent-event': { - const {agent, accountDid, refreshedAccount, sessionEvent} = action - if ( - refreshedAccount === undefined && - agent !== state.currentAgentState.agent - ) { - // If the session got cleared out (e.g. due to expiry or network error) but - // this account isn't the active one, don't clear it out at this time. - // This way, if the problem is transient, it'll work on next resume. + case 'received-session-event': { + const {bundle, accountDid, refreshedAccount, sessionEvent} = action + if (bundle !== state.currentBundleState.bundle) { + /* + * Stale bundles must neither log out the current account nor restore + * tokens after logout or an account switch. + */ return state } if (sessionEvent === 'network-error') { @@ -98,7 +96,6 @@ let reducer = (state: State, action: Action): State => { !existingAccount || JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) ) { - // Fast path without a state update. return state } return { @@ -118,26 +115,40 @@ let reducer = (state: State, action: Action): State => { return a } }), - currentAgentState: refreshedAccount - ? state.currentAgentState - : createPublicAgentState(), // Log out if expired. + currentBundleState: refreshedAccount + ? state.currentBundleState + : createPublicBundleState(), // Log out if expired. needsPersist: true, } } case 'switched-to-account': { - const {newAccount, newAgent} = action + const {newAccount, newBundle} = action return { accounts: [ newAccount, ...state.accounts.filter(a => a.did !== newAccount.did), ], - currentAgentState: { + currentBundleState: { did: newAccount.did, - agent: newAgent, + bundle: newBundle, }, needsPersist: true, } } + case 'replaced-current-bundle': { + const {newBundle, newAccount} = action + return { + ...state, + currentBundleState: { + did: state.currentBundleState.did, + bundle: newBundle, + }, + accounts: state.accounts.map(a => + a.did === newAccount.did ? newAccount : a, + ), + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + } + } case 'removed-account': { const {accountDid} = action @@ -159,16 +170,16 @@ let reducer = (state: State, action: Action): State => { return { accounts: state.accounts.filter(a => a.did !== accountDid), - currentAgentState: - state.currentAgentState.did === accountDid - ? createPublicAgentState() // Log out if removing the current one. - : state.currentAgentState, + currentBundleState: + state.currentBundleState.did === accountDid + ? createPublicBundleState() // Log out if removing the current one. + : state.currentBundleState, needsPersist: true, } } case 'logged-out-current-account': { - const {currentAgentState} = state - const accountDid = currentAgentState.did + const {currentBundleState} = state + const accountDid = currentBundleState.did // side effect const account = state.accounts.find(a => a.did === accountDid) if (account && accountDid) { @@ -195,7 +206,7 @@ let reducer = (state: State, action: Action): State => { } : a, ), - currentAgentState: createPublicAgentState(), + currentBundleState: createPublicBundleState(), needsPersist: true, } } @@ -216,7 +227,7 @@ let reducer = (state: State, action: Action): State => { refreshJwt: undefined, accessJwt: undefined, })), - currentAgentState: createPublicAgentState(), + currentBundleState: createPublicBundleState(), needsPersist: true, } } @@ -224,33 +235,19 @@ let reducer = (state: State, action: Action): State => { const {syncedAccounts, syncedCurrentDid} = action return { accounts: syncedAccounts, - currentAgentState: - syncedCurrentDid === state.currentAgentState.did - ? state.currentAgentState - : createPublicAgentState(), // Log out if different user. + currentBundleState: + syncedCurrentDid === state.currentBundleState.did + ? state.currentBundleState + : createPublicBundleState(), // Log out if different user. needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. } } case 'partial-refresh-session': { const {accountDid, patch} = action - const agent = state.currentAgentState.agent as AtpAgent - - /* - * Only mutating values that are safe. Be very careful with this. - */ - if (agent.session) { - agent.session.emailConfirmed = - patch.emailConfirmed ?? agent.session.emailConfirmed - agent.session.emailAuthFactor = - patch.emailAuthFactor ?? agent.session.emailAuthFactor - } + // PasswordSession has no setter; consumers read these fields from the account. return { ...state, - currentAgentState: { - ...state.currentAgentState, - agent, - }, accounts: state.accounts.map(a => { if (a.did === accountDid) { return { diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts new file mode 100644 index 0000000000..b77a3e08bc --- /dev/null +++ b/src/state/session/session-core.ts @@ -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 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)?.() +} diff --git a/src/state/session/types.ts b/src/state/session/types.ts index 8a9afba42c..ca1bca62f1 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -3,6 +3,9 @@ import {type Metrics} from '#/analytics/metrics' export type SessionAccount = PersistedAccount +/** Session-change events understood by the reducer and logging hooks. */ +export type AtpSessionEvent = 'update' | 'expired' | 'network-error' + export type SessionStateContext = { accounts: SessionAccount[] currentAccount: SessionAccount | undefined @@ -44,11 +47,9 @@ export type SessionApiContext = { ) => Promise removeAccount: (account: SessionAccount) => void /** - * Calls `getSession` and updates select fields on the current account and - * `BskyAgent`. This is an alternative to `resumeSession`, which updates - * current account/agent using the `persistSessionHandler`, but is more load - * bearing. This patches in updates without causing any side effects via - * `persistSessionHandler`. + * Calls `getSession` and patches the email fields of the current account. + * Unlike `resumeSession`, this does not rotate tokens or rebuild the session, + * so it produces no session-change side effects. */ partialRefreshSession: () => Promise } diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 12ed4acf5a..b8fddc618f 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -11,7 +11,7 @@ import {mimeToExt} from '#/lib/media/video/util' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {type ComposerImage} from '#/state/gallery' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' -import {createPublicAgent} from '#/state/session/agent' +import {createPublicAgent} from '#/state/session/bridge-agent' import { type ComposerState, type EmbedDraft,