rework session layer on PasswordSession + three-client pattern

- PasswordSession (@atproto/lex-password-session) is now the single auth
  core; SessionAgent bridges it to the base Agent from @atproto/api so
  the ~148 useAgent() consumer files keep working unchanged
- SessionBundle carries {session, agent, accountClient, appviewClient};
  useLexClient() now returns the authed appview client, new
  useAppviewClient()/usePdsClient() hooks
- reducer: OpaqueSessionBundle, new replaced-current-bundle action for
  network-free cross-tab same-did sync (PasswordSession is immutable, so
  the bundle is rebuilt instead of mutated in place)
- moderation: AtpAgent.configure -> Client.configure + Agent.configure,
  per-account labelers on both the bridge and the appview client
- push-token unregistration uses temporary PasswordSessions
- hard-tail migrations: SignupQueued refresh via resumeSession shim,
  useBeginAgeAssurance scoped-token raw Client, useAccountEmailState
  reads currentAccount
- PDS routing preserved on the no-network resume fast path by
  synthesizing a minimal didDoc from the persisted pdsUrl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-16 14:40:01 +03:00
parent 105e68c12c
commit bf8b6bc0c7
58 changed files with 2826 additions and 996 deletions
+29 -32
View File
@@ -1,28 +1,26 @@
import AtpAgent from '@atproto/api'
import {jwtDecode} from 'jwt-decode'
import {PasswordSession} from '@atproto/lex-password-session'
import {isJwtExpired} from '#/lib/jwt'
import {hasProp} from '#/lib/type-guards'
import * as persisted from '#/state/persisted'
import {sessionAccountToSession} from './agent'
import {
networkAwareFetch,
sessionAccountToSessionData,
SessionAgent,
} from './session-core'
import {type SessionAccount} from './types'
/*
* Canonical implementation moved to session-core.ts so that module stays
* dependency-light (this file pulls in agent.ts and, transitively, a large
* chunk of the app). Re-exported here for existing consumers.
*/
export {isSignupQueued} from './session-core'
export function readLastActiveAccount() {
const {currentAccount, accounts} = persisted.get('session')
return accounts.find(a => a.did === currentAccount?.did)
}
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
export function isSessionExpired(account: SessionAccount) {
if (account.accessJwt) {
return isJwtExpired(account.accessJwt)
@@ -32,30 +30,29 @@ export function isSessionExpired(account: SessionAccount) {
}
/**
* Creates and attempted to resumeSession for every stored session.
* Intended to be used to send push token revokations just before logout.
* Creates and resumes a throwaway session for every stored account.
* Intended to send push token revocations just before logout.
*
* Each returned {@link SessionAgent} wraps a temporary `PasswordSession`
* resumed over the network to obtain a valid access token. These sessions are
* deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist
* or race the active session. They are used once for the unregister call and
* discarded (reclaimed by GC), so we never call `logout()` on them.
*/
export async function createTemporaryAgentsAndResume(
accounts: SessionAccount[],
) {
const agents = await Promise.allSettled(
): Promise<SessionAgent[]> {
const settled = await Promise.allSettled(
accounts.map(async account => {
const agent: AtpAgent = new AtpAgent({service: account.service})
if (account.pdsUrl) {
agent.sessionManager.pdsUrl = new URL(account.pdsUrl)
}
const session = sessionAccountToSession(account)
const res = await agent.resumeSession(session)
if (!res.success) throw new Error('Failed to resume session')
agent.assertAuthenticated() // confirm auth success
return agent
const session = await PasswordSession.resume(
sessionAccountToSessionData(account),
{fetch: networkAwareFetch},
)
return new SessionAgent(session)
}),
)
return agents
return settled
.filter(x => x.status === 'fulfilled')
.map(promise => promise.value)
}