about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/App.native.tsx2
-rw-r--r--src/App.web.tsx2
-rw-r--r--src/state/queries/index.ts8
-rw-r--r--src/state/session/index.tsx473
-rw-r--r--src/state/session/types.ts65
-rw-r--r--src/state/session/util/index.ts177
-rw-r--r--src/state/session/util/readLastActiveAccount.ts6
7 files changed, 400 insertions, 333 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 4cb963fe8..569f342c2 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -18,7 +18,7 @@ import {useQueryClient} from '@tanstack/react-query'
 import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {init as initPersistedState} from '#/state/persisted'
 import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
-import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
+import {readLastActiveAccount} from '#/state/session/util'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
 import {useNotificationsListener} from 'lib/notifications/notifications'
 import {QueryProvider} from 'lib/react-query'
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 639fbfafc..226fb8d3f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,7 +8,7 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
 import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {init as initPersistedState} from '#/state/persisted'
 import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
-import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
+import {readLastActiveAccount} from '#/state/session/util'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
 import {QueryProvider} from 'lib/react-query'
 import {ThemeProvider} from 'lib/ThemeContext'
diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts
index e30528ca1..0635bf316 100644
--- a/src/state/queries/index.ts
+++ b/src/state/queries/index.ts
@@ -1,11 +1,3 @@
-import {BskyAgent} from '@atproto/api'
-
-import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
-
-export const PUBLIC_BSKY_AGENT = new BskyAgent({
-  service: PUBLIC_BSKY_SERVICE,
-})
-
 export const STALE = {
   SECONDS: {
     FIFTEEN: 1e3 * 15,
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 6cdfa145d..6173f8cca 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,100 +1,41 @@
 import React from 'react'
-import {
-  AtpPersistSessionHandler,
-  BSKY_LABELER_DID,
-  BskyAgent,
-} from '@atproto/api'
-import {jwtDecode} from 'jwt-decode'
+import {AtpPersistSessionHandler, 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,
+  SessionState,
+  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>({
+const StateContext = React.createContext<SessionStateContext>({
   isInitialLoad: true,
   isSwitchingAccounts: false,
   accounts: [],
@@ -102,7 +43,7 @@ const StateContext = React.createContext<StateContext>({
   hasSession: false,
 })
 
-const ApiContext = React.createContext<ApiContext>({
+const ApiContext = React.createContext<SessionApiContext>({
   createAccount: async () => {},
   login: async () => {},
   logout: async () => {},
@@ -114,71 +55,10 @@ const ApiContext = React.createContext<ApiContext>({
   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,
-      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()
-    }
+let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
-    /*
-     * 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,
-    })
-  }
+function __getAgent() {
+  return __globalAgent
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -214,13 +94,87 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const clearCurrentAccount = React.useCallback(() => {
     logger.warn(`session: clear current account`)
     __globalAgent = PUBLIC_BSKY_AGENT
+    configureModerationForGuest()
     setStateAndPersist(s => ({
       ...s,
       currentAccount: undefined,
     }))
   }, [setStateAndPersist])
 
-  const createAccount = React.useCallback<ApiContext['createAccount']>(
+  const createPersistSessionHandler = React.useCallback(
+    (
+      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
+        }
+
+        // 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()
+        }
+
+        /*
+         * 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,
+        })
+      }
+    },
+    [],
+  )
+
+  const createAccount = React.useCallback<SessionApiContext['createAccount']>(
     async ({
       service,
       email,
@@ -229,60 +183,22 @@ 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,
-        emailConfirmed: agent.session.emailConfirmed,
-        emailAuthFactor: agent.session.emailAuthFactor,
-        refreshJwt: agent.session.refreshJwt,
-        accessJwt: agent.session.accessJwt,
-        deactivated,
-        pdsUrl: agent.pdsUrl?.toString(),
-      }
-
-      await configureModeration(agent, account)
-
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
           agent,
@@ -302,39 +218,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       track('Create Account')
       logEvent('account:create:success', {})
     },
-    [upsertAccount, clearCurrentAccount],
+    [upsertAccount, clearCurrentAccount, createPersistSessionHandler],
   )
 
-  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 = 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,
-        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)
+      const {agent, account, fetchingGates} = await createAgentAndLogin({
+        service,
+        identifier,
+        password,
+        authFactorToken,
+      })
 
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
@@ -358,10 +253,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       track('Sign In', {resumedSession: false})
       logEvent('account:loggedIn', {logContext, withPassword: true})
     },
-    [upsertAccount, clearCurrentAccount],
+    [upsertAccount, clearCurrentAccount, createPersistSessionHandler],
   )
 
-  const logout = React.useCallback<ApiContext['logout']>(
+  const logout = React.useCallback<SessionApiContext['logout']>(
     async logContext => {
       logger.debug(`session: logout`)
       clearCurrentAccount()
@@ -380,7 +275,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     [clearCurrentAccount, setStateAndPersist],
   )
 
-  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')
@@ -405,22 +300,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
       // @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
@@ -432,7 +312,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         handle: account.handle,
       }
 
-      if (canReusePrevSession) {
+      if (isSessionExpired(account)) {
+        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
+        }
+      } else {
         logger.debug(`session: attempting to reuse previous session`)
 
         agent.session = prevSession
@@ -469,59 +368,27 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
             __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,
-          emailAuthFactor: agent.session.emailAuthFactor,
-          refreshJwt: agent.session.refreshJwt,
-          accessJwt: agent.session.accessJwt,
-          deactivated: isSessionDeactivated(agent.session.accessJwt),
-          pdsUrl: agent.pdsUrl?.toString(),
-        }
+        return sessionAccount
       }
     },
-    [upsertAccount, clearCurrentAccount],
+    [upsertAccount, clearCurrentAccount, createPersistSessionHandler],
   )
 
-  const resumeSession = React.useCallback<ApiContext['resumeSession']>(
+  const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
     async account => {
       try {
         if (account) {
@@ -539,7 +406,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     [initSession],
   )
 
-  const removeAccount = React.useCallback<ApiContext['removeAccount']>(
+  const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
     account => {
       setStateAndPersist(s => {
         return {
@@ -552,7 +419,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 
   const updateCurrentAccount = React.useCallback<
-    ApiContext['updateCurrentAccount']
+    SessionApiContext['updateCurrentAccount']
   >(
     account => {
       setStateAndPersist(s => {
@@ -588,7 +455,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     [setStateAndPersist],
   )
 
-  const selectAccount = React.useCallback<ApiContext['selectAccount']>(
+  const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
     async (account, logContext) => {
       setState(s => ({...s, isSwitchingAccounts: true}))
       try {
@@ -714,28 +581,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)
 }
@@ -762,12 +607,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..3c7e7d253
--- /dev/null
+++ b/src/state/session/types.ts
@@ -0,0 +1,65 @@
+import {LogEvents} from '#/lib/statsig/statsig'
+import {PersistedAccount} from '#/state/persisted'
+
+export type SessionAccount = PersistedAccount
+
+export type SessionState = {
+  isInitialLoad: boolean
+  isSwitchingAccounts: boolean
+  accounts: SessionAccount[]
+  currentAccount: SessionAccount | undefined
+}
+export type SessionStateContext = SessionState & {
+  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>
+  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
+}
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)
-}