about summary refs log tree commit diff
path: root/src/state/session
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/session')
-rw-r--r--src/state/session/index.tsx801
-rw-r--r--src/state/session/types.ts56
-rw-r--r--src/state/session/util/index.ts177
-rw-r--r--src/state/session/util/readLastActiveAccount.ts6
4 files changed, 550 insertions, 490 deletions
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index e45aa031f..276e3b97b 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,225 +1,199 @@
 import React from 'react'
-import {
-  AtpPersistSessionHandler,
-  BSKY_LABELER_DID,
-  BskyAgent,
-} from '@atproto/api'
-import {jwtDecode} from 'jwt-decode'
+import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {networkRetry} from '#/lib/async/retry'
-import {IS_TEST_USER} from '#/lib/constants'
-import {logEvent, LogEvents, tryFetchGates} from '#/lib/statsig/statsig'
-import {hasProp} from '#/lib/type-guards'
+import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
+import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
-import {PUBLIC_BSKY_AGENT} from '#/state/queries'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 import {IS_DEV} from '#/env'
 import {emitSessionDropped} from '../events'
-import {readLabelers} from './agent-config'
-
-let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
-
-function __getAgent() {
-  return __globalAgent
-}
-
-export function useAgent() {
-  return React.useMemo(() => ({getAgent: __getAgent}), [])
-}
+import {
+  agentToSessionAccount,
+  configureModerationForAccount,
+  configureModerationForGuest,
+  createAgentAndCreateAccount,
+  createAgentAndLogin,
+  isSessionDeactivated,
+  isSessionExpired,
+} from './util'
+
+export type {SessionAccount} from '#/state/session/types'
+import {
+  SessionAccount,
+  SessionApiContext,
+  SessionStateContext,
+} from '#/state/session/types'
 
-export type SessionAccount = persisted.PersistedAccount
+export {isSessionDeactivated}
 
-export type SessionState = {
-  isInitialLoad: boolean
-  isSwitchingAccounts: boolean
-  accounts: SessionAccount[]
-  currentAccount: SessionAccount | undefined
-}
-export type StateContext = SessionState & {
-  hasSession: boolean
-}
-export type ApiContext = {
-  createAccount: (props: {
-    service: string
-    email: string
-    password: string
-    handle: string
-    inviteCode?: string
-    verificationPhone?: string
-    verificationCode?: string
-  }) => Promise<void>
-  login: (
-    props: {
-      service: string
-      identifier: string
-      password: string
-      authFactorToken?: string | undefined
-    },
-    logContext: LogEvents['account:loggedIn']['logContext'],
-  ) => Promise<void>
-  /**
-   * A full logout. Clears the `currentAccount` from session, AND removes
-   * access tokens from all accounts, so that returning as any user will
-   * require a full login.
-   */
-  logout: (
-    logContext: LogEvents['account:loggedOut']['logContext'],
-  ) => Promise<void>
-  /**
-   * A partial logout. Clears the `currentAccount` from session, but DOES NOT
-   * clear access tokens from accounts, allowing the user to return to their
-   * other accounts without logging in.
-   *
-   * Used when adding a new account, deleting an account.
-   */
-  clearCurrentAccount: () => void
-  initSession: (account: SessionAccount) => Promise<void>
-  resumeSession: (account?: SessionAccount) => Promise<void>
-  removeAccount: (account: SessionAccount) => void
-  selectAccount: (
-    account: SessionAccount,
-    logContext: LogEvents['account:loggedIn']['logContext'],
-  ) => Promise<void>
-  updateCurrentAccount: (
-    account: Partial<
-      Pick<
-        SessionAccount,
-        'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
-      >
-    >,
-  ) => void
-}
+const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
+configureModerationForGuest()
 
-const StateContext = React.createContext<StateContext>({
-  isInitialLoad: true,
-  isSwitchingAccounts: false,
+const StateContext = React.createContext<SessionStateContext>({
   accounts: [],
   currentAccount: undefined,
   hasSession: false,
 })
 
-const ApiContext = React.createContext<ApiContext>({
+const ApiContext = React.createContext<SessionApiContext>({
   createAccount: async () => {},
   login: async () => {},
   logout: async () => {},
   initSession: async () => {},
-  resumeSession: async () => {},
   removeAccount: () => {},
-  selectAccount: async () => {},
   updateCurrentAccount: () => {},
   clearCurrentAccount: () => {},
 })
 
-function createPersistSessionHandler(
-  agent: BskyAgent,
-  account: SessionAccount,
-  persistSessionCallback: (props: {
-    expired: boolean
-    refreshedAccount: SessionAccount
-  }) => void,
-  {
-    networkErrorCallback,
-  }: {
-    networkErrorCallback?: () => void
-  } = {},
-): AtpPersistSessionHandler {
-  return function persistSession(event, session) {
-    const expired = event === 'expired' || event === 'create-failed'
-
-    if (event === 'network-error') {
-      logger.warn(`session: persistSessionHandler received network-error event`)
-      networkErrorCallback?.()
-      return
-    }
-
-    const refreshedAccount: SessionAccount = {
-      service: account.service,
-      did: session?.did || account.did,
-      handle: session?.handle || account.handle,
-      email: session?.email || account.email,
-      emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
-      deactivated: isSessionDeactivated(session?.accessJwt),
-      pdsUrl: agent.pdsUrl?.toString(),
-
-      /*
-       * Tokens are undefined if the session expires, or if creation fails for
-       * any reason e.g. tokens are invalid, network error, etc.
-       */
-      refreshJwt: session?.refreshJwt,
-      accessJwt: session?.accessJwt,
-    }
+let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
-    logger.debug(`session: persistSession`, {
-      event,
-      deactivated: refreshedAccount.deactivated,
-    })
+function __getAgent() {
+  return __globalAgent
+}
 
-    if (expired) {
-      logger.warn(`session: expired`)
-      emitSessionDropped()
-    }
+type AgentState = {
+  readonly agent: BskyAgent
+  readonly did: string | undefined
+}
 
-    /*
-     * If the session expired, or it was successfully created/updated, we want
-     * to update/persist the data.
-     *
-     * If the session creation failed, it could be a network error, or it could
-     * be more serious like an invalid token(s). We can't differentiate, so in
-     * order to allow the user to get a fresh token (if they need it), we need
-     * to persist this data and wipe their tokens, effectively logging them
-     * out.
-     */
-    persistSessionCallback({
-      expired,
-      refreshedAccount,
-    })
-  }
+type State = {
+  accounts: SessionStateContext['accounts']
+  currentAgentState: AgentState
+  needsPersist: boolean
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const isDirty = React.useRef(false)
-  const [state, setState] = React.useState<SessionState>({
-    isInitialLoad: true,
-    isSwitchingAccounts: false,
+  const [state, setState] = React.useState<State>(() => ({
     accounts: persisted.get('session').accounts,
-    currentAccount: undefined, // assume logged out to start
-  })
-
-  const setStateAndPersist = React.useCallback(
-    (fn: (prev: SessionState) => SessionState) => {
-      isDirty.current = true
-      setState(fn)
+    currentAgentState: {
+      agent: PUBLIC_BSKY_AGENT,
+      did: undefined, // assume logged out to start
     },
-    [setState],
-  )
+    needsPersist: false,
+  }))
 
-  const upsertAccount = React.useCallback(
-    (account: SessionAccount, expired = false) => {
-      setStateAndPersist(s => {
+  const clearCurrentAccount = React.useCallback(() => {
+    logger.warn(`session: clear current account`)
+    __globalAgent = PUBLIC_BSKY_AGENT
+    configureModerationForGuest()
+    setState(s => ({
+      accounts: s.accounts,
+      currentAgentState: {
+        agent: PUBLIC_BSKY_AGENT,
+        did: undefined,
+      },
+      needsPersist: true,
+    }))
+  }, [setState])
+
+  const onAgentSessionChange = React.useCallback(
+    (
+      agent: BskyAgent,
+      account: SessionAccount,
+      event: AtpSessionEvent,
+      session: AtpSessionData | undefined,
+    ) => {
+      const expired = event === 'expired' || event === 'create-failed'
+
+      if (event === 'network-error') {
+        logger.warn(
+          `session: persistSessionHandler received network-error event`,
+        )
+        logger.warn(`session: clear current account`)
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            agent: PUBLIC_BSKY_AGENT,
+            did: undefined,
+          },
+          needsPersist: true,
+        }))
+        return
+      }
+
+      // TODO: use agentToSessionAccount for this too.
+      const refreshedAccount: SessionAccount = {
+        service: account.service,
+        did: session?.did ?? account.did,
+        handle: session?.handle ?? account.handle,
+        email: session?.email ?? account.email,
+        emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed,
+        emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor,
+        deactivated: isSessionDeactivated(session?.accessJwt),
+        pdsUrl: agent.pdsUrl?.toString(),
+
+        /*
+         * Tokens are undefined if the session expires, or if creation fails for
+         * any reason e.g. tokens are invalid, network error, etc.
+         */
+        refreshJwt: session?.refreshJwt,
+        accessJwt: session?.accessJwt,
+      }
+
+      logger.debug(`session: persistSession`, {
+        event,
+        deactivated: refreshedAccount.deactivated,
+      })
+
+      if (expired) {
+        logger.warn(`session: expired`)
+        emitSessionDropped()
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            agent: PUBLIC_BSKY_AGENT,
+            did: undefined,
+          },
+          needsPersist: true,
+        }))
+      }
+
+      /*
+       * If the session expired, or it was successfully created/updated, we want
+       * to update/persist the data.
+       *
+       * If the session creation failed, it could be a network error, or it could
+       * be more serious like an invalid token(s). We can't differentiate, so in
+       * order to allow the user to get a fresh token (if they need it), we need
+       * to persist this data and wipe their tokens, effectively logging them
+       * out.
+       */
+      setState(s => {
+        const existingAccount = s.accounts.find(
+          a => a.did === refreshedAccount.did,
+        )
+        if (
+          !expired &&
+          existingAccount &&
+          refreshedAccount &&
+          JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
+        ) {
+          // Fast path without a state update.
+          return s
+        }
         return {
-          ...s,
-          currentAccount: expired ? undefined : account,
-          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          accounts: [
+            refreshedAccount,
+            ...s.accounts.filter(a => a.did !== refreshedAccount.did),
+          ],
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
+    [],
   )
 
-  const clearCurrentAccount = React.useCallback(() => {
-    logger.warn(`session: clear current account`)
-    __globalAgent = PUBLIC_BSKY_AGENT
-    setStateAndPersist(s => ({
-      ...s,
-      currentAccount: undefined,
-    }))
-  }, [setStateAndPersist])
-
-  const createAccount = React.useCallback<ApiContext['createAccount']>(
+  const createAccount = React.useCallback<SessionApiContext['createAccount']>(
     async ({
       service,
       email,
@@ -228,157 +202,109 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       inviteCode,
       verificationPhone,
       verificationCode,
-    }: any) => {
+    }) => {
       logger.info(`session: creating account`)
       track('Try Create Account')
       logEvent('account:create:begin', {})
-
-      const agent = new BskyAgent({service})
-
-      await agent.createAccount({
-        handle,
-        password,
-        email,
-        inviteCode,
-        verificationPhone,
-        verificationCode,
-      })
-
-      if (!agent.session) {
-        throw new Error(`session: createAccount failed to establish a session`)
-      }
-      const fetchingGates = tryFetchGates(
-        agent.session.did,
-        'prefer-fresh-gates',
+      const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
+        {
+          service,
+          email,
+          password,
+          handle,
+          inviteCode,
+          verificationPhone,
+          verificationCode,
+        },
       )
 
-      const deactivated = isSessionDeactivated(agent.session.accessJwt)
-      if (!deactivated) {
-        /*dont await*/ agent.upsertProfile(_existing => {
-          return {
-            displayName: '',
-
-            // HACKFIX
-            // creating a bunch of identical profile objects is breaking the relay
-            // tossing this unspecced field onto it to reduce the size of the problem
-            // -prf
-            createdAt: new Date().toISOString(),
-          }
-        })
-      }
-
-      const account: SessionAccount = {
-        service: agent.service.toString(),
-        did: agent.session.did,
-        handle: agent.session.handle,
-        email: agent.session.email!, // TODO this is always defined?
-        emailConfirmed: false,
-        refreshJwt: agent.session.refreshJwt,
-        accessJwt: agent.session.accessJwt,
-        deactivated,
-        pdsUrl: agent.pdsUrl?.toString(),
-      }
-
-      await configureModeration(agent, account)
-
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       __globalAgent = agent
       await fetchingGates
-      upsertAccount(account)
+      setState(s => {
+        return {
+          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          currentAgentState: {
+            did: account.did,
+            agent: agent,
+          },
+          needsPersist: true,
+        }
+      })
 
       logger.debug(`session: created account`, {}, logger.DebugContext.session)
       track('Create Account')
       logEvent('account:create:success', {})
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const login = React.useCallback<ApiContext['login']>(
+  const login = React.useCallback<SessionApiContext['login']>(
     async ({service, identifier, password, authFactorToken}, logContext) => {
       logger.debug(`session: login`, {}, logger.DebugContext.session)
+      const {agent, account, fetchingGates} = await createAgentAndLogin({
+        service,
+        identifier,
+        password,
+        authFactorToken,
+      })
 
-      const agent = new BskyAgent({service})
-
-      await agent.login({identifier, password, authFactorToken})
-
-      if (!agent.session) {
-        throw new Error(`session: login failed to establish a session`)
-      }
-      const fetchingGates = tryFetchGates(
-        agent.session.did,
-        'prefer-fresh-gates',
-      )
-
-      const account: SessionAccount = {
-        service: agent.service.toString(),
-        did: agent.session.did,
-        handle: agent.session.handle,
-        email: agent.session.email,
-        emailConfirmed: agent.session.emailConfirmed || false,
-        emailAuthFactor: agent.session.emailAuthFactor,
-        refreshJwt: agent.session.refreshJwt,
-        accessJwt: agent.session.accessJwt,
-        deactivated: isSessionDeactivated(agent.session.accessJwt),
-        pdsUrl: agent.pdsUrl?.toString(),
-      }
-
-      await configureModeration(agent, account)
-
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       __globalAgent = agent
       // @ts-ignore
       if (IS_DEV && isWeb) window.agent = agent
       await fetchingGates
-      upsertAccount(account)
+      setState(s => {
+        return {
+          accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
+          currentAgentState: {
+            did: account.did,
+            agent: agent,
+          },
+          needsPersist: true,
+        }
+      })
 
       logger.debug(`session: logged in`, {}, logger.DebugContext.session)
 
       track('Sign In', {resumedSession: false})
       logEvent('account:loggedIn', {logContext, withPassword: true})
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const logout = React.useCallback<ApiContext['logout']>(
+  const logout = React.useCallback<SessionApiContext['logout']>(
     async logContext => {
       logger.debug(`session: logout`)
-      clearCurrentAccount()
-      setStateAndPersist(s => {
+      logger.warn(`session: clear current account`)
+      __globalAgent = PUBLIC_BSKY_AGENT
+      configureModerationForGuest()
+      setState(s => {
         return {
-          ...s,
           accounts: s.accounts.map(a => ({
             ...a,
             refreshJwt: undefined,
             accessJwt: undefined,
           })),
+          currentAgentState: {
+            did: undefined,
+            agent: PUBLIC_BSKY_AGENT,
+          },
+          needsPersist: true,
         }
       })
       logEvent('account:loggedOut', {logContext})
     },
-    [clearCurrentAccount, setStateAndPersist],
+    [setState],
   )
 
-  const initSession = React.useCallback<ApiContext['initSession']>(
+  const initSession = React.useCallback<SessionApiContext['initSession']>(
     async account => {
       logger.debug(`session: initSession`, {}, logger.DebugContext.session)
       const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
@@ -390,55 +316,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
       }
 
-      agent.setPersistSessionHandler(
-        createPersistSessionHandler(
-          agent,
-          account,
-          ({expired, refreshedAccount}) => {
-            upsertAccount(refreshedAccount, expired)
-          },
-          {networkErrorCallback: clearCurrentAccount},
-        ),
-      )
+      agent.setPersistSessionHandler((event, session) => {
+        onAgentSessionChange(agent, account, event, session)
+      })
 
       // @ts-ignore
       if (IS_DEV && isWeb) window.agent = agent
-      await configureModeration(agent, account)
-
-      let canReusePrevSession = false
-      try {
-        if (account.accessJwt) {
-          const decoded = jwtDecode(account.accessJwt)
-          if (decoded.exp) {
-            const didExpire = Date.now() >= decoded.exp * 1000
-            if (!didExpire) {
-              canReusePrevSession = true
-            }
-          }
-        }
-      } catch (e) {
-        logger.error(`session: could not decode jwt`)
-      }
+      await configureModerationForAccount(agent, account)
+
+      const accountOrSessionDeactivated =
+        isSessionDeactivated(account.accessJwt) || account.deactivated
 
       const prevSession = {
-        accessJwt: account.accessJwt || '',
-        refreshJwt: account.refreshJwt || '',
+        accessJwt: account.accessJwt ?? '',
+        refreshJwt: account.refreshJwt ?? '',
         did: account.did,
         handle: account.handle,
-        deactivated:
-          isSessionDeactivated(account.accessJwt) || account.deactivated,
       }
 
-      if (canReusePrevSession) {
+      if (isSessionExpired(account)) {
+        logger.debug(`session: attempting to resume using previous session`)
+
+        const freshAccount = await resumeSessionWithFreshAccount()
+        __globalAgent = agent
+        await fetchingGates
+        setState(s => {
+          return {
+            accounts: [
+              freshAccount,
+              ...s.accounts.filter(a => a.did !== freshAccount.did),
+            ],
+            currentAgentState: {
+              did: freshAccount.did,
+              agent: agent,
+            },
+            needsPersist: true,
+          }
+        })
+      } else {
         logger.debug(`session: attempting to reuse previous session`)
 
         agent.session = prevSession
 
         __globalAgent = agent
         await fetchingGates
-        upsertAccount(account)
+        setState(s => {
+          return {
+            accounts: [
+              account,
+              ...s.accounts.filter(a => a.did !== account.did),
+            ],
+            currentAgentState: {
+              did: account.did,
+              agent: agent,
+            },
+            needsPersist: true,
+          }
+        })
 
-        if (prevSession.deactivated) {
+        if (accountOrSessionDeactivated) {
           // don't attempt to resume
           // use will be taken to the deactivated screen
           logger.debug(`session: reusing session for deactivated account`)
@@ -447,191 +383,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
         // Intentionally not awaited to unblock the UI:
         resumeSessionWithFreshAccount()
-          .then(freshAccount => {
-            if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
-              logger.info(
-                `session: reuse of previous session returned a fresh account, upserting`,
-              )
-              upsertAccount(freshAccount)
-            }
-          })
-          .catch(e => {
-            /*
-             * Note: `agent.persistSession` is also called when this fails, and
-             * we handle that failure via `createPersistSessionHandler`
-             */
-            logger.info(`session: resumeSessionWithFreshAccount failed`, {
-              message: e,
-            })
-
-            __globalAgent = PUBLIC_BSKY_AGENT
-          })
-      } else {
-        logger.debug(`session: attempting to resume using previous session`)
-
-        try {
-          const freshAccount = await resumeSessionWithFreshAccount()
-          __globalAgent = agent
-          await fetchingGates
-          upsertAccount(freshAccount)
-        } catch (e) {
-          /*
-           * Note: `agent.persistSession` is also called when this fails, and
-           * we handle that failure via `createPersistSessionHandler`
-           */
-          logger.info(`session: resumeSessionWithFreshAccount failed`, {
-            message: e,
-          })
-
-          __globalAgent = PUBLIC_BSKY_AGENT
-        }
       }
 
       async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
         logger.debug(`session: resumeSessionWithFreshAccount`)
 
         await networkRetry(1, () => agent.resumeSession(prevSession))
-
+        const sessionAccount = agentToSessionAccount(agent)
         /*
          * If `agent.resumeSession` fails above, it'll throw. This is just to
          * make TypeScript happy.
          */
-        if (!agent.session) {
+        if (!sessionAccount) {
           throw new Error(`session: initSession failed to establish a session`)
         }
-
-        // ensure changes in handle/email etc are captured on reload
-        return {
-          service: agent.service.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,
-          deactivated: isSessionDeactivated(agent.session.accessJwt),
-          pdsUrl: agent.pdsUrl?.toString(),
-        }
+        return sessionAccount
       }
     },
-    [upsertAccount, clearCurrentAccount],
+    [onAgentSessionChange],
   )
 
-  const resumeSession = React.useCallback<ApiContext['resumeSession']>(
-    async account => {
-      try {
-        if (account) {
-          await initSession(account)
-        }
-      } catch (e) {
-        logger.error(`session: resumeSession failed`, {message: e})
-      } finally {
-        setState(s => ({
-          ...s,
-          isInitialLoad: false,
-        }))
-      }
-    },
-    [initSession],
-  )
-
-  const removeAccount = React.useCallback<ApiContext['removeAccount']>(
+  const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
     account => {
-      setStateAndPersist(s => {
+      setState(s => {
         return {
-          ...s,
           accounts: s.accounts.filter(a => a.did !== account.did),
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
+    [setState],
   )
 
   const updateCurrentAccount = React.useCallback<
-    ApiContext['updateCurrentAccount']
+    SessionApiContext['updateCurrentAccount']
   >(
     account => {
-      setStateAndPersist(s => {
-        const currentAccount = s.currentAccount
-
+      setState(s => {
+        const currentAccount = s.accounts.find(
+          a => a.did === s.currentAgentState.did,
+        )
         // ignore, should never happen
         if (!currentAccount) return s
 
         const updatedAccount = {
           ...currentAccount,
-          handle: account.handle || currentAccount.handle,
-          email: account.email || currentAccount.email,
+          handle: account.handle ?? currentAccount.handle,
+          email: account.email ?? currentAccount.email,
           emailConfirmed:
-            account.emailConfirmed !== undefined
-              ? account.emailConfirmed
-              : currentAccount.emailConfirmed,
+            account.emailConfirmed ?? currentAccount.emailConfirmed,
           emailAuthFactor:
-            account.emailAuthFactor !== undefined
-              ? account.emailAuthFactor
-              : currentAccount.emailAuthFactor,
+            account.emailAuthFactor ?? currentAccount.emailAuthFactor,
         }
 
         return {
-          ...s,
-          currentAccount: updatedAccount,
           accounts: [
             updatedAccount,
             ...s.accounts.filter(a => a.did !== currentAccount.did),
           ],
+          currentAgentState: s.currentAgentState,
+          needsPersist: true,
         }
       })
     },
-    [setStateAndPersist],
-  )
-
-  const selectAccount = React.useCallback<ApiContext['selectAccount']>(
-    async (account, logContext) => {
-      setState(s => ({...s, isSwitchingAccounts: true}))
-      try {
-        await initSession(account)
-        setState(s => ({...s, isSwitchingAccounts: false}))
-        logEvent('account:loggedIn', {logContext, withPassword: false})
-      } catch (e) {
-        // reset this in case of error
-        setState(s => ({...s, isSwitchingAccounts: false}))
-        // but other listeners need a throw
-        throw e
-      }
-    },
-    [setState, initSession],
+    [setState],
   )
 
   React.useEffect(() => {
-    if (isDirty.current) {
-      isDirty.current = false
+    if (state.needsPersist) {
+      state.needsPersist = false
       persisted.write('session', {
         accounts: state.accounts,
-        currentAccount: state.currentAccount,
+        currentAccount: state.accounts.find(
+          a => a.did === state.currentAgentState.did,
+        ),
       })
     }
   }, [state])
 
   React.useEffect(() => {
     return persisted.onUpdate(() => {
-      const session = persisted.get('session')
+      const persistedSession = persisted.get('session')
 
       logger.debug(`session: persisted onUpdate`, {})
+      setState(s => ({
+        accounts: persistedSession.accounts,
+        currentAgentState: s.currentAgentState,
+        needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
+      }))
+
+      const selectedAccount = persistedSession.accounts.find(
+        a => a.did === persistedSession.currentAccount?.did,
+      )
 
-      if (session.currentAccount && session.currentAccount.refreshJwt) {
-        if (session.currentAccount?.did !== state.currentAccount?.did) {
+      if (selectedAccount && selectedAccount.refreshJwt) {
+        if (selectedAccount.did !== state.currentAgentState.did) {
           logger.debug(`session: persisted onUpdate, switching accounts`, {
             from: {
-              did: state.currentAccount?.did,
-              handle: state.currentAccount?.handle,
+              did: state.currentAgentState.did,
             },
             to: {
-              did: session.currentAccount.did,
-              handle: session.currentAccount.handle,
+              did: selectedAccount.did,
             },
           })
 
-          initSession(session.currentAccount)
+          initSession(selectedAccount)
         } else {
           logger.debug(`session: persisted onUpdate, updating session`, {})
 
@@ -641,9 +498,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
            * already persisted, and we'll get a loop between tabs.
            */
           // @ts-ignore we checked for `refreshJwt` above
-          __globalAgent.session = session.currentAccount
+          __globalAgent.session = selectedAccount
+          // TODO: This needs a setState.
         }
-      } else if (!session.currentAccount && state.currentAccount) {
+      } else if (!selectedAccount && state.currentAgentState.did) {
         logger.debug(
           `session: persisted onUpdate, logging out`,
           {},
@@ -656,21 +514,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
          * handled by `persistSession` (which nukes this accounts tokens only),
          * or by a `logout` call  which nukes all accounts tokens)
          */
-        clearCurrentAccount()
+        logger.warn(`session: clear current account`)
+        __globalAgent = PUBLIC_BSKY_AGENT
+        configureModerationForGuest()
+        setState(s => ({
+          accounts: s.accounts,
+          currentAgentState: {
+            did: undefined,
+            agent: PUBLIC_BSKY_AGENT,
+          },
+          needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
+        }))
       }
-
-      setState(s => ({
-        ...s,
-        accounts: session.accounts,
-        currentAccount: session.currentAccount,
-      }))
     })
-  }, [state, setState, clearCurrentAccount, initSession])
+  }, [state, setState, initSession])
 
   const stateContext = React.useMemo(
     () => ({
-      ...state,
-      hasSession: !!state.currentAccount,
+      accounts: state.accounts,
+      currentAccount: state.accounts.find(
+        a => a.did === state.currentAgentState.did,
+      ),
+      hasSession: !!state.currentAgentState.did,
     }),
     [state],
   )
@@ -681,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     }),
@@ -692,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       login,
       logout,
       initSession,
-      resumeSession,
       removeAccount,
-      selectAccount,
       updateCurrentAccount,
       clearCurrentAccount,
     ],
@@ -707,28 +568,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
-async function configureModeration(agent: BskyAgent, account: SessionAccount) {
-  if (IS_TEST_USER(account.handle)) {
-    const did = (
-      await agent
-        .resolveHandle({handle: 'mod-authority.test'})
-        .catch(_ => undefined)
-    )?.data.did
-    if (did) {
-      console.warn('USING TEST ENV MODERATION')
-      BskyAgent.configure({appLabelers: [did]})
-    }
-  } else {
-    BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
-    const labelerDids = await readLabelers(account.did).catch(_ => {})
-    if (labelerDids) {
-      agent.configureLabelersHeader(
-        labelerDids.filter(did => did !== BSKY_LABELER_DID),
-      )
-    }
-  }
-}
-
 export function useSession() {
   return React.useContext(StateContext)
 }
@@ -755,12 +594,6 @@ export function useRequireAuth() {
   )
 }
 
-export function isSessionDeactivated(accessJwt: string | undefined) {
-  if (accessJwt) {
-    const sessData = jwtDecode(accessJwt)
-    return (
-      hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
-    )
-  }
-  return false
+export function useAgent() {
+  return React.useMemo(() => ({getAgent: __getAgent}), [])
 }
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
new file mode 100644
index 000000000..b3252f777
--- /dev/null
+++ b/src/state/session/types.ts
@@ -0,0 +1,56 @@
+import {LogEvents} from '#/lib/statsig/statsig'
+import {PersistedAccount} from '#/state/persisted'
+
+export type SessionAccount = PersistedAccount
+
+export type SessionStateContext = {
+  accounts: SessionAccount[]
+  currentAccount: SessionAccount | undefined
+  hasSession: boolean
+}
+export type SessionApiContext = {
+  createAccount: (props: {
+    service: string
+    email: string
+    password: string
+    handle: string
+    inviteCode?: string
+    verificationPhone?: string
+    verificationCode?: string
+  }) => Promise<void>
+  login: (
+    props: {
+      service: string
+      identifier: string
+      password: string
+      authFactorToken?: string | undefined
+    },
+    logContext: LogEvents['account:loggedIn']['logContext'],
+  ) => Promise<void>
+  /**
+   * A full logout. Clears the `currentAccount` from session, AND removes
+   * access tokens from all accounts, so that returning as any user will
+   * require a full login.
+   */
+  logout: (
+    logContext: LogEvents['account:loggedOut']['logContext'],
+  ) => Promise<void>
+  /**
+   * A partial logout. Clears the `currentAccount` from session, but DOES NOT
+   * clear access tokens from accounts, allowing the user to return to their
+   * other accounts without logging in.
+   *
+   * Used when adding a new account, deleting an account.
+   */
+  clearCurrentAccount: () => void
+  initSession: (account: SessionAccount) => Promise<void>
+  removeAccount: (account: SessionAccount) => void
+  updateCurrentAccount: (
+    account: Partial<
+      Pick<
+        SessionAccount,
+        'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
+      >
+    >,
+  ) => void
+}
diff --git a/src/state/session/util/index.ts b/src/state/session/util/index.ts
new file mode 100644
index 000000000..e3e246f7b
--- /dev/null
+++ b/src/state/session/util/index.ts
@@ -0,0 +1,177 @@
+import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
+import {jwtDecode} from 'jwt-decode'
+
+import {IS_TEST_USER} from '#/lib/constants'
+import {tryFetchGates} from '#/lib/statsig/statsig'
+import {hasProp} from '#/lib/type-guards'
+import {logger} from '#/logger'
+import * as persisted from '#/state/persisted'
+import {readLabelers} from '../agent-config'
+import {SessionAccount, SessionApiContext} from '../types'
+
+export function isSessionDeactivated(accessJwt: string | undefined) {
+  if (accessJwt) {
+    const sessData = jwtDecode(accessJwt)
+    return (
+      hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
+    )
+  }
+  return false
+}
+
+export function readLastActiveAccount() {
+  const {currentAccount, accounts} = persisted.get('session')
+  return accounts.find(a => a.did === currentAccount?.did)
+}
+
+export function agentToSessionAccount(
+  agent: BskyAgent,
+): SessionAccount | undefined {
+  if (!agent.session) return undefined
+
+  return {
+    service: agent.service.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,
+    deactivated: isSessionDeactivated(agent.session.accessJwt),
+    pdsUrl: agent.pdsUrl?.toString(),
+  }
+}
+
+export function configureModerationForGuest() {
+  switchToBskyAppLabeler()
+}
+
+export async function configureModerationForAccount(
+  agent: BskyAgent,
+  account: SessionAccount,
+) {
+  switchToBskyAppLabeler()
+  if (IS_TEST_USER(account.handle)) {
+    await trySwitchToTestAppLabeler(agent)
+  }
+
+  const labelerDids = await readLabelers(account.did).catch(_ => {})
+  if (labelerDids) {
+    agent.configureLabelersHeader(
+      labelerDids.filter(did => did !== BSKY_LABELER_DID),
+    )
+  } else {
+    // If there are no headers in the storage, we'll not send them on the initial requests.
+    // If we wanted to fix this, we could block on the preferences query here.
+  }
+}
+
+function switchToBskyAppLabeler() {
+  BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
+}
+
+async function trySwitchToTestAppLabeler(agent: BskyAgent) {
+  const did = (
+    await agent
+      .resolveHandle({handle: 'mod-authority.test'})
+      .catch(_ => undefined)
+  )?.data.did
+  if (did) {
+    console.warn('USING TEST ENV MODERATION')
+    BskyAgent.configure({appLabelers: [did]})
+  }
+}
+
+export function isSessionExpired(account: SessionAccount) {
+  try {
+    if (account.accessJwt) {
+      const decoded = jwtDecode(account.accessJwt)
+      if (decoded.exp) {
+        const didExpire = Date.now() >= decoded.exp * 1000
+        return didExpire
+      }
+    }
+  } catch (e) {
+    logger.error(`session: could not decode jwt`)
+  }
+  return true
+}
+
+export async function createAgentAndLogin({
+  service,
+  identifier,
+  password,
+  authFactorToken,
+}: {
+  service: string
+  identifier: string
+  password: string
+  authFactorToken?: string
+}) {
+  const agent = new BskyAgent({service})
+  await agent.login({identifier, password, authFactorToken})
+
+  const account = agentToSessionAccount(agent)
+  if (!agent.session || !account) {
+    throw new Error(`session: login failed to establish a session`)
+  }
+
+  const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
+  await configureModerationForAccount(agent, account)
+
+  return {
+    agent,
+    account,
+    fetchingGates,
+  }
+}
+
+export async function createAgentAndCreateAccount({
+  service,
+  email,
+  password,
+  handle,
+  inviteCode,
+  verificationPhone,
+  verificationCode,
+}: Parameters<SessionApiContext['createAccount']>[0]) {
+  const agent = new BskyAgent({service})
+  await agent.createAccount({
+    email,
+    password,
+    handle,
+    inviteCode,
+    verificationPhone,
+    verificationCode,
+  })
+
+  const account = agentToSessionAccount(agent)!
+  if (!agent.session || !account) {
+    throw new Error(`session: createAccount failed to establish a session`)
+  }
+
+  const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
+
+  if (!account.deactivated) {
+    /*dont await*/ agent.upsertProfile(_existing => {
+      return {
+        displayName: '',
+
+        // HACKFIX
+        // creating a bunch of identical profile objects is breaking the relay
+        // tossing this unspecced field onto it to reduce the size of the problem
+        // -prf
+        createdAt: new Date().toISOString(),
+      }
+    })
+  }
+
+  await configureModerationForAccount(agent, account)
+
+  return {
+    agent,
+    account,
+    fetchingGates,
+  }
+}
diff --git a/src/state/session/util/readLastActiveAccount.ts b/src/state/session/util/readLastActiveAccount.ts
deleted file mode 100644
index e0768b8a8..000000000
--- a/src/state/session/util/readLastActiveAccount.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import * as persisted from '#/state/persisted'
-
-export function readLastActiveAccount() {
-  const {currentAccount, accounts} = persisted.get('session')
-  return accounts.find(a => a.did === currentAccount?.did)
-}