Compare commits

...

22 Commits

Author SHA1 Message Date
Samuel Newman a2129885c6 fix lockfile 2026-08-13 21:41:05 +03:00
Samuel Newman 7f6db6352d fix lockfile 2026-08-13 21:41:05 +03:00
Samuel Newman b1a7cc7998 fix lockfile 2026-08-13 21:41:05 +03:00
Samuel Newman 69101a9348 take named options in makeSessionHooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 131d4815e6 keep the stored pds url when a refresh delivers no did doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 517439abd6 expand session test coverage, fix abort-test contamination
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman fb494881de redact session debug logs, fix stale bundle read and render-phase ref write
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 6df3986c2b surface lex error messages in cleanError
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 5ffee15c4f reject no-op refreshes, mirror the inner session's pds routing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 374e1ba3de dispose the bundle and fail loudly when prep destroys the session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman b96d85387d rm subpar comment 2026-08-13 21:41:05 +03:00
Samuel Newman 1e6106e865 pin the matchXrpcError pattern for the lexicon codegen followup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:05 +03:00
Samuel Newman 3e42c659c9 clean up migration-context comments
Comments should explain the code as it is, not narrate the change that produced
it. Drops "the old X" / "now" / "no longer" framing and a stale version pin in
favour of concrete names and the live constraints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 9f381485dc dispose superseded bundles on aborted login and signup
`createAccount` and `login` abandoned the factory's returned bundle when a
newer task had already aborted them. The factories return an ARMED bundle, so
that session kept auto-refreshing and rotating refresh tokens server-side for
an account the app was no longer tracking; for signup the void-fired
post-signup writes kept its agent live too. Dispose in both abort branches,
matching `resumeSession`.

Also document why disposal of a replaced bundle is deferred to the post-commit
effect rather than done inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 21d67da9a9 match lex errors with instanceof, drop getErrorName
`getErrorName` erased the typed error classes by flattening everything to a
string code. Anchor on the classes instead: `LexAuthFactorError` for the login
2FA branch, `XrpcResponseError` for the signup error codes.

`@atproto/lex-client` stays a direct dependency because `XrpcResponseError` is
used at runtime for `instanceof`. It is already in the runtime graph via
`@atproto/lex-password-session`, so the direct declaration adds no bundle
weight; it makes the import legal under pnpm's strict layout and pins the
version so both packages share one `LexError` identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 1d279dc894 adapt error classification and email state to the lex session core
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman abdf1abf4b rebuild the session lifecycle on passwordsession
Replace the AtpAgent-owned session lifecycle with PasswordSession-backed
bundles, dispatched through the bridge agent. The provider, reducer and
factories now hold a `{session, agent, service}` bundle whose identity gates
session events, so a stale session can no longer log out the current account
or restore its tokens after a switch.

Behavioural changes that come with the new auth core:
- token rotation is read from the hook payload (PasswordSession fires before
  committing its live getter), so refreshes persist the new tokens
- replaced bundles are disposed rather than mutated, since PasswordSession has
  no in-place patch; cross-tab syncs rebuild instead
- the expiry rescue path prefers a newer persisted generation over logging
  every tab out

Post-signup writes keep main's agent.* call style; createAccount synthesizes
the email/active fields the thinner lex output omits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 9f40ba0a0b add the passwordsession bridge agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 427b98e154 extract session-data, network, and expiry-rescue modules
Session-account conversion, the network-aware fetch wrapper, and the
expiry-rescue candidate picker were tangled into agent.ts and util.ts.
Pulling them into agent-agnostic modules lets the upcoming session
layer share them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 045f3965ec move labeler cache to mmkv, make moderation config sync
The per-account labeler cache was the only async dependency in
configureModerationForAccount. MMKV-backed sync reads let session
setup apply labelers in the same tick. No AsyncStorage backfill: the
cache is rewritten on every preferences fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman e79d8e8e52 add persisted.readLatest for cross-tab session reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
Samuel Newman 65faee2836 add @atproto/lex-password-session and lex-client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:41:04 +03:00
35 changed files with 6871 additions and 2397 deletions
-5
View File
@@ -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
+2
View File
@@ -98,6 +98,8 @@
"dependencies": {
"@atproto/api": "0.20.39",
"@atproto/common-web": "0.5.7",
"@atproto/lex-client": "0.3.0",
"@atproto/lex-password-session": "0.1.8",
"@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
+1594 -1247
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useAgent, useSessionApi} from '#/state/session'
import {useSession, useSessionApi} from '#/state/session'
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
export type AccountEmailState = {
@@ -12,24 +12,29 @@ export type AccountEmailState = {
export const accountEmailStateQueryKey = ['accountEmailState'] as const
export function useAccountEmailState() {
const agent = useAgent()
/*
* Read from the account rather than the agent's session: `partialRefreshSession`
* patches the email fields on the account in the reducer (`PasswordSession`
* has no setter for them), so the account is the fresh source of truth.
*/
const {currentAccount} = useSession()
const {partialRefreshSession} = useSessionApi()
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
!!agent.session?.emailConfirmed,
!!currentAccount?.emailConfirmed,
)
const state: AccountEmailState = useMemo(
() => ({
isEmailVerified: !!agent.session?.emailConfirmed,
email2FAEnabled: !!agent.session?.emailAuthFactor,
isEmailVerified: !!currentAccount?.emailConfirmed,
email2FAEnabled: !!currentAccount?.emailAuthFactor,
}),
[agent.session],
[currentAccount],
)
/**
* Only here to refetch on focus, when necessary
*/
useQuery({
enabled: !!agent.session,
enabled: !!currentAccount,
/**
* Only refetch if the email verification s incomplete.
*/
+126
View File
@@ -0,0 +1,126 @@
import {XRPCError} from '@atproto/api'
import {LexError, XrpcResponseError} from '@atproto/lex-client'
import {beforeAll, describe, expect, it} from '@jest/globals'
import {i18n} from '@lingui/core'
import {cleanError} from '../errors'
/*
* `cleanError` returns translated copy, so a locale has to be active. With no
* catalog loaded, Lingui falls back to the source message.
*/
beforeAll(() => {
i18n.loadAndActivate({locale: 'en', messages: {}})
})
/**
* A stand-in for the method schema an `XrpcResponseError` is built against.
* The generated lexicons are not available yet, and `XrpcResponseError` only
* reads `method` back out for `matchesSchemaErrors()`, which these tests never
* call - so a minimal object is enough.
*/
const method = {
nsid: 'com.atproto.server.createAccount',
type: 'procedure',
errors: ['HandleNotAvailable'],
} as unknown as ConstructorParameters<typeof XrpcResponseError>[0]
/** An error as the lex client builds one from a JSON error response body. */
function xrpcResponseError(
error: string,
message: string,
status: number = 400,
) {
return new XrpcResponseError(method, new Response(null, {status}), {
encoding: 'application/json',
body: {error, message},
})
}
/**
* An error as the lex client builds one from a response with no XRPC error
* payload: the code is derived from the HTTP status, and the message is a
* generic overview of the response.
*/
function xrpcStatusError(status: number) {
return new XrpcResponseError(method, new Response(null, {status}), undefined)
}
describe('cleanError', () => {
it('surfaces the clean message of a lex error', () => {
const e = xrpcResponseError('HandleNotAvailable', 'Handle already taken')
// The raw stringification is class- and code-prefixed, so it must not leak.
expect(e.toString()).toBe(
'XrpcResponseError: [HandleNotAvailable] Handle already taken',
)
expect(cleanError(e)).toBe('Handle already taken')
})
it('falls back to the lexicon code when a lex error has no message', () => {
// `Error` defaults an absent message to the empty string.
const e = new LexError('InvalidRequest')
expect(e.toString()).toBe('LexError: [InvalidRequest] ')
expect(cleanError(e)).toBe('InvalidRequest')
})
it('matches the upstream-failure branch on a lex error code', () => {
// 502 maps to the space-free `UpstreamFailure` lexicon code.
const e = xrpcStatusError(502)
expect(e.error).toBe('UpstreamFailure')
expect(cleanError(e)).toBe(
'The server appears to be experiencing issues. Please try again in a few moments.',
)
})
it('matches the upstream-failure branch on a legacy XRPC error', () => {
const e = new XRPCError(502)
expect(cleanError(e)).toBe(
'The server appears to be experiencing issues. Please try again in a few moments.',
)
})
it('matches NotEnoughResources on both error shapes', () => {
expect(cleanError(xrpcStatusError(503))).toBe(
'The server appears to be experiencing issues. Please try again in a few moments.',
)
expect(cleanError(new XRPCError(503))).toBe(
'The server appears to be experiencing issues. Please try again in a few moments.',
)
})
it('matches the app-password branch on a lex error message', () => {
const e = xrpcResponseError('InvalidToken', 'Bad token scope')
expect(cleanError(e)).toBe(
'This feature is not available while using an App Password. Please sign in with your main password.',
)
})
it('matches the network-error branch on a lex error message', () => {
const e = xrpcResponseError('InternalServerError', 'Failed to fetch', 500)
expect(cleanError(e)).toBe(
'Unable to connect. Please check your internet connection and try again.',
)
})
it('surfaces the authentication-required code of a lex error', () => {
/*
* The lex client derives `AuthenticationRequired` from a 401 with no XRPC
* payload, where `@atproto/api` used the spaced "Authentication Required".
* Neither is special-cased in `cleanError`, so what matters is that the
* class- and code-prefixed stringification does not reach the user.
*/
const e = xrpcStatusError(401)
expect(e.error).toBe('AuthenticationRequired')
expect(cleanError(e)).toBe('Upstream server responded with a 401 error')
})
it('strips a leading "Error: " from a plain error', () => {
expect(cleanError(new Error('Something broke'))).toBe('Something broke')
})
it('passes strings through', () => {
expect(cleanError('Something broke')).toBe('Something broke')
expect(cleanError('')).toBe('')
expect(cleanError(undefined)).toBe('')
})
})
+35 -4
View File
@@ -1,17 +1,51 @@
import {XRPCError} from '@atproto/api'
import {LexError} from '@atproto/lex-client'
import {t} from '@lingui/core/macro'
/**
* The text to show the user when no special case applies.
*
* A `LexError` stringifies as `Class: [ErrorCode] message` (for example
* `XrpcResponseError: [HandleNotAvailable] Handle already taken`), so its
* `toString()` is never fit for display. Its `message` is the server's
* human-readable text, so prefer that and fall back to the lexicon code for the
* errors that carry no message.
*
* Everything else keeps the historical behaviour of stringifying and dropping a
* leading `Error: `.
*/
function toDisplayString(e: unknown, str: string): string {
if (e instanceof LexError) {
return e.message || e.error
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
return str
}
export function cleanError(e: unknown): string {
if (!e) {
return ''
}
/*
* Match against the full stringification rather than the display text: it
* contains the error name and the lexicon code as well as the message, so a
* single check covers both error shapes.
*/
// oxlint-disable-next-line typescript/no-base-to-string
const str = typeof e === 'string' ? e : e.toString()
if (isNetworkError(str)) {
return t`Unable to connect. Please check your internet connection and try again.`
}
/*
* `@atproto/api` names these with spaces ("Upstream Failure"); lexicon error
* codes are space-free ("UpstreamFailure"). Match both while the app throws
* both shapes.
*/
if (
str.includes('Upstream Failure') ||
str.includes('UpstreamFailure') ||
str.includes('NotEnoughResources') ||
str.includes('pipethrough network error')
) {
@@ -41,10 +75,7 @@ export function cleanError(e: unknown): string {
if (str.includes('Unable to resolve handle')) {
return t`Unable to resolve handle`
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
return str
return toDisplayString(e, str)
}
const NETWORK_ERRORS = [
+7 -8
View File
@@ -1,9 +1,7 @@
import {useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native'
import {
ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {LexAuthFactorError} from '@atproto/lex-password-session'
import {Trans, useLingui} from '@lingui/react/macro'
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
@@ -142,10 +140,11 @@ export const LoginForm = ({
} catch (err) {
const errMsg = String(err)
setIsProcessing(false)
if (
err instanceof
ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
) {
/*
* `LexAuthFactorError` is what `PasswordSession.login` throws when the
* server demands an email 2FA token.
*/
if (err instanceof LexAuthFactorError) {
setIsAuthFactorTokenNeeded(true)
} else {
onAttemptFailed()
+32 -13
View File
@@ -1,9 +1,7 @@
import {createContext, useCallback, useContext} from 'react'
import {LayoutAnimation} from 'react-native'
import {
ComAtprotoServerCreateAccount,
type ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {XrpcResponseError} from '@atproto/lex-client'
import {useLingui} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator'
@@ -260,14 +258,35 @@ export const useSignupContext = () => useContext(SignupContext)
* failure is unexpected and should be reported to Sentry.
*/
function classifyExpectedSignupError(e: unknown): string | undefined {
if (e instanceof ComAtprotoServerCreateAccount.InvalidHandleError)
return 'InvalidHandle'
if (e instanceof ComAtprotoServerCreateAccount.HandleNotAvailableError)
return 'HandleNotAvailable'
if (e instanceof ComAtprotoServerCreateAccount.InvalidPasswordError)
return 'InvalidPassword'
if (e instanceof ComAtprotoServerCreateAccount.UnsupportedDomainError)
return 'UnsupportedDomain'
/*
* TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these
* codes are compared as plain strings and a typo silently never matches.
* Once the generated lexicons land, replace this (and every multi-code error
* site) with a shared helper that narrows against the method schema:
*
* function matchXrpcError<M extends Procedure | Query>(
* e: unknown,
* method: Main<M>,
* ): InferMethodError<M> | undefined
*
* switch (matchXrpcError(e, com.atproto.server.createAccount)) {
* case 'InvalidHandle': ...
* }
*
* The return type is the method's declared-errors union, so a typo'd case is
* a compile error, and undeclared codes fall through to `undefined`. The
* helper should also match `e.method.nsid` so a declared code from a
* different call cannot match, mirroring the old per-method error classes.
*/
if (e instanceof XrpcResponseError) {
switch (e.error) {
case 'InvalidHandle':
case 'HandleNotAvailable':
case 'InvalidPassword':
case 'UnsupportedDomain':
return e.error
}
}
/* the server sends no typed error for this case */
if (String(e).includes('Email already taken')) return 'EmailTaken'
if (isNetworkError(e)) return 'NetworkError'
@@ -357,7 +376,7 @@ export function useSubmitSignup() {
} catch (err) {
const e = err as Error
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') {
dispatch({
type: 'setError',
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
+12
View File
@@ -31,6 +31,18 @@ export function get<K extends keyof Schema>(key: K): Schema[K] {
}
get satisfies PersistedApi['get']
/**
* Native is single-instance: there is no other tab that could have written
* newer data behind our back, so the in-memory `_state` is already the truth
* and a synchronous fresh read is impossible anyway (AsyncStorage is async).
* This mirrors {@link get}; the web implementation is the one that actually
* re-reads the store.
*/
export function readLatest<K extends keyof Schema>(key: K): Schema[K] {
return _state[key]
}
readLatest satisfies PersistedApi['readLatest']
export async function write<K extends keyof Schema>(
key: K,
value: Schema[K],
+23
View File
@@ -39,6 +39,29 @@ export function get<K extends keyof Schema>(key: K): Schema[K] {
}
get satisfies PersistedApi['get']
/**
* Force a fresh synchronous re-read of localStorage and return the requested
* key from it, WITHOUT adopting it as `_state`.
*
* This exists for the cross-tab expiry-rescue path. A frozen tab may not have
* processed a queued broadcast yet, so {@link get} (and persisted's in-memory
* `_state`) can be stale even though another tab already wrote healthy tokens
* to storage. Reading through storage directly here is the only way to see the
* true cross-tab-latest tokens on web.
*
* Crucially we do NOT adopt into `_state`. {@link readFromStorage} memoizes by
* raw string and returns the same object reference for unchanged data, so
* adopting here would make the later queued broadcast/storage event for that
* same write see `next === _state` and suppress its listener notification -
* leaving non-current-account changes (removals, other tokens, metadata) stale
* indefinitely. Leaving `_state` alone lets that queued event still fire.
*/
export function readLatest<K extends keyof Schema>(key: K): Schema[K] {
const next = readFromStorage()
return next?.[key] ?? _state[key]
}
readLatest satisfies PersistedApi['readLatest']
// eslint-disable-next-line @typescript-eslint/require-await
export async function write<K extends keyof Schema>(
key: K,
+9
View File
@@ -3,6 +3,15 @@ import {type Schema} from './schema'
export type PersistedApi = {
init(): Promise<void>
get<K extends keyof Schema>(key: K): Schema[K]
/**
* Like {@link get}, but on web forces a fresh synchronous re-read of the
* backing store before returning (without adopting it as the in-memory
* state). This exists for the cross-tab expiry-rescue path: a frozen tab may
* not have processed a queued broadcast yet, so {@link get} can be stale
* while another tab has already written healthy tokens to storage. On native
* it is identical to {@link get} (single-instance, no other writer).
*/
readLatest<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
onUpdate<K extends keyof Schema>(
key: K,
+2 -2
View File
@@ -21,7 +21,7 @@ import {
} from '#/state/queries/preferences/types'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config'
import {saveLabelers} from '#/state/session/moderation'
import {useAgeAssurance} from '#/ageAssurance'
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
@@ -53,7 +53,7 @@ export function usePreferencesQuery() {
const res = await agent.getPreferences()
// save to local storage to ensure there are labels on initial requests
void saveLabelers(
saveLabelers(
agent.did,
res.moderationPrefs.labelers.map(l => l.did),
)
@@ -0,0 +1,573 @@
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {sessionAccountToSessionData} from '../session-data'
import {type SessionAccount} from '../types'
import {
asFetch,
DID,
DIDDOC_PDS_HOST,
HANDLE,
json,
makeAccount,
makeDidDoc,
makeMockFetch,
type MockFetch,
PDS_HOST,
SERVICE,
urlsOf,
} from './mock-fetch'
/**
* Build the manager + agent pair under test.
*
* The mock fetch is installed in both places it can be reached from: as the
* inner `PasswordSession`'s fetch (the authenticated path) and, via
* `setFetch`, as the manager's own fetch (the unauthenticated bypass path,
* which would otherwise use the real network-aware fetch).
*/
function setup({
account = makeAccount(),
didDoc,
pdsUrl,
fetchMock = makeMockFetch(),
sessionOptions,
}: {
account?: SessionAccount
didDoc?: SessionData['didDoc']
pdsUrl?: string
fetchMock?: MockFetch
sessionOptions?: PasswordSessionOptions
} = {}) {
const data: SessionData = {
...sessionAccountToSessionData(account),
...(didDoc ? {didDoc} : {}),
}
const inner = new PasswordSession(data, {
fetch: asFetch(fetchMock),
...sessionOptions,
})
const manager = new PasswordSessionManager(inner, {
service: account.service,
pdsUrl,
})
manager.setFetch(asFetch(fetchMock))
const agent = new BskyAppAgent(manager)
return {inner, manager, agent, fetchMock}
}
function setupPublic(fetchMock: MockFetch = makeMockFetch()) {
const manager = new PasswordSessionManager(null, {service: SERVICE})
manager.setFetch(asFetch(fetchMock))
return {manager, agent: new BskyAppAgent(manager), fetchMock}
}
describe('PasswordSessionManager getters', () => {
it('reads live SessionData through .session', () => {
const {agent} = setup()
expect(agent.session?.did).toBe(DID)
expect(agent.session?.handle).toBe(HANDLE)
expect(agent.session?.email).toBe('alice@example.com')
expect(agent.session?.emailConfirmed).toBe(true)
expect(agent.did).toBe(DID)
expect(agent.hasSession).toBe(true)
})
it('defaults active to true when the payload omits it', () => {
const {agent} = setup({account: makeAccount({active: undefined})})
expect(agent.session?.active).toBe(true)
})
it('exposes serviceUrl from the constructor service', () => {
const {agent} = setup()
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
})
it('derives pdsUrl/dispatchUrl from the didDoc', () => {
const {agent} = setup({didDoc: makeDidDoc(PDS_HOST)})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when there is no didDoc', () => {
const {agent} = setup({pdsUrl: PDS_HOST})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`)
})
it('prefers the didDoc PDS over the stored pdsUrl', () => {
const {agent} = setup({
didDoc: makeDidDoc(DIDDOC_PDS_HOST),
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
})
it('dispatchUrl falls back to serviceUrl with no PDS at all', () => {
const {agent} = setup()
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('ignores an unparseable stored pdsUrl', () => {
const {agent} = setup({pdsUrl: 'not a url'})
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('matches the inner session on didDocs a strict validator would reject', () => {
/*
* No `id` on the document and a non-canonical service `type`: enough for
* isValidDidDoc/getPdsEndpoint to bail, but PasswordSession still routes
* here, so the bridge must agree or dispatchUrl lies about where requests
* go (and service-auth aud gets minted for the wrong host).
*/
const {agent} = setup({
didDoc: {
service: [
{
id: '#atproto_pds',
type: 'SomethingElse',
serviceEndpoint: DIDDOC_PDS_HOST,
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when the didDoc has no PDS service', () => {
const {agent} = setup({
didDoc: {
id: DID,
service: [
{
id: '#bsky_notif',
type: 'BskyNotificationService',
serviceEndpoint: DIDDOC_PDS_HOST,
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when the PDS endpoint does not parse', () => {
const {agent} = setup({
didDoc: {
id: DID,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: 'not a url',
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
})
})
describe('PasswordSessionManager.session identity', () => {
it('is stable across consecutive reads', () => {
const {agent} = setup()
expect(agent.session).toBe(agent.session)
})
it('is a new object after a refresh rotates tokens', async () => {
const {agent} = setup()
const before = agent.session
expect(before?.accessJwt).toBe('access-jwt')
await agent.sessionManager.refreshSession()
const after = agent.session
expect(after).not.toBe(before)
expect(after?.accessJwt).toBe('access-jwt-2')
expect(after).toBe(agent.session)
})
it('rejects writes to .session', () => {
const {agent} = setup()
expect(() => {
/* the whole point of the accessor: writes must not silently drift */
agent.sessionManager.session = agent.session
}).toThrow('read-only')
})
it('rejects writes to .pdsUrl', () => {
const {agent} = setup()
expect(() => {
agent.sessionManager.pdsUrl = new URL(PDS_HOST)
}).toThrow('read-only')
})
})
describe('PasswordSessionManager.fetchHandler routing', () => {
it('dispatches to the stored PDS before a refresh, then to the didDoc PDS', async () => {
const {manager, fetchMock} = setup({pdsUrl: PDS_HOST})
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${PDS_HOST}/xrpc/app.bsky.actor.getProfile`,
)
/* the refresh response carries a didDoc pointing at a different host */
await manager.refreshSession()
expect(manager.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${DIDDOC_PDS_HOST}/xrpc/app.bsky.actor.getProfile`,
)
})
it('attaches the session bearer token', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setup({fetchMock})
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen[0].get('authorization')).toBe('Bearer access-jwt')
})
it('bypasses the inner session when authorization is pre-set', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'com.atproto.server.describeServer': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setup({fetchMock, pdsUrl: PDS_HOST})
/*
* PasswordSession throws TypeError on a pre-set authorization header, so
* this path must never reach it.
*/
await expect(
manager.fetchHandler('/xrpc/com.atproto.server.describeServer', {
headers: {authorization: 'Bearer caller-supplied'},
}),
).resolves.toBeDefined()
expect(seen.length).toBe(1)
/* the caller's header survives, and there is exactly one of them */
expect(seen[0].get('authorization')).toBe('Bearer caller-supplied')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${PDS_HOST}/xrpc/com.atproto.server.describeServer`,
)
})
})
describe('BskyAppAgent namespace requests', () => {
it('carries proxy, labeler and bearer headers to the dispatch host', async () => {
const seen: {url: string; headers: Headers}[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (url, init) => {
seen.push({url, headers: new Headers(init.headers)})
return json({did: DID, handle: HANDLE})
},
})
const {agent} = setup({fetchMock, pdsUrl: PDS_HOST})
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
agent.configureLabelers(['did:plc:custom-labeler'])
/*
* The request headers (what we assert) are captured by the fetch mock
* before the agent parses the response body. Response-body lexicon
* validation can throw in the jest environment (a multiformats CID mock
* quirk unrelated to the header composition under test), so we ignore any
* parse error here.
*/
await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].url.startsWith(`${PDS_HOST}/xrpc/`)).toBe(true)
expect(seen[0].headers.get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
)
expect(seen[0].headers.get('atproto-accept-labelers')).toContain(
'did:plc:custom-labeler',
)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('PasswordSessionManager.refreshSession', () => {
it('returns an old-shaped XRPC envelope with fresh tokens', async () => {
const {manager, fetchMock} = setup()
const res = await manager.refreshSession()
expect(res.success).toBe(true)
expect(res.data.accessJwt).toBe('access-jwt-2')
expect(res.data.refreshJwt).toBe('refresh-jwt-2')
expect(res.data.did).toBe(DID)
expect(res.data.handle).toBe(HANDLE)
expect(
urlsOf(fetchMock).some(u =>
u.includes('com.atproto.server.refreshSession'),
),
).toBe(true)
})
it('throws when there is no live session', async () => {
const {manager} = setupPublic()
await expect(manager.refreshSession()).rejects.toThrow(
'No session to refresh',
)
})
})
describe('PasswordSessionManager.resumeSession', () => {
const staleData = {
accessJwt: 'stale-access',
refreshJwt: 'stale-refresh',
handle: 'stale.test',
did: DID,
active: true,
}
it('ignores its argument and returns fresh tokens from a refresh', async () => {
const {manager} = setup()
const res = await manager.resumeSession(staleData)
expect(res.data.accessJwt).toBe('access-jwt-2')
expect(res.data.refreshJwt).toBe('refresh-jwt-2')
expect(manager.session?.accessJwt).toBe('access-jwt-2')
})
it('is reachable through the agent and does not install the stale data', async () => {
const {agent} = setup()
await agent.resumeSession(staleData)
expect(agent.session?.accessJwt).toBe('access-jwt-2')
expect(agent.session?.handle).toBe(HANDLE)
})
})
describe('PasswordSessionManager unsupported methods', () => {
it('refuses login()', async () => {
const {agent} = setup()
await expect(
agent.login({identifier: HANDLE, password: 'hunter2'}),
).rejects.toThrow('Not supported on PasswordSessionManager')
})
it('refuses createAccount()', async () => {
const {agent} = setup()
await expect(
agent.createAccount({handle: HANDLE, email: 'a@b.c', password: 'x'}),
).rejects.toThrow('Not supported on PasswordSessionManager')
})
})
describe('PasswordSessionManager destroyed inner session', () => {
it('getters return undefined rather than throwing after logout', async () => {
const {agent, inner} = setup()
await agent.logout()
expect(inner.destroyed).toBe(true)
/* PasswordSession.did/.session throw once destroyed; the bridge must not */
expect(() => agent.did).not.toThrow()
expect(agent.did).toBe(undefined)
expect(agent.session).toBe(undefined)
expect(agent.hasSession).toBe(false)
expect(agent.pdsUrl).toBe(undefined)
})
it('logout() is idempotent', async () => {
const {agent} = setup()
await agent.logout()
await expect(agent.logout()).resolves.toBeUndefined()
})
it('fetchHandler stops attaching auth once destroyed', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {agent, manager} = setup({fetchMock})
await agent.logout()
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
})
})
describe('BskyAppAgent.dispose', () => {
let ctx: ReturnType<typeof setup>
beforeEach(() => {
ctx = setup({pdsUrl: PDS_HOST})
})
it('makes the session read as logged out', () => {
expect(ctx.agent.session).toBeDefined()
ctx.agent.dispose()
expect(ctx.agent.session).toBe(undefined)
expect(ctx.agent.did).toBe(undefined)
expect(ctx.agent.pdsUrl).toBe(undefined)
expect(ctx.agent.hasSession).toBe(false)
})
it('routes requests through the plain unauthenticated fetch', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {agent, manager} = setup({fetchMock, pdsUrl: PDS_HOST})
agent.dispose()
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
/* dispatch falls back to the service, since pdsUrl now reads undefined */
expect(urlsOf(fetchMock).at(-1)).toBe(
`${SERVICE}/xrpc/app.bsky.actor.getProfile`,
)
})
it('leaves refreshSession unusable', async () => {
ctx.agent.dispose()
await expect(ctx.agent.sessionManager.refreshSession()).rejects.toThrow(
'No session to refresh',
)
})
})
describe('public PasswordSessionManager (no inner session)', () => {
it('reads as logged out', () => {
const {agent} = setupPublic()
expect(agent.session).toBe(undefined)
expect(agent.did).toBe(undefined)
expect(agent.hasSession).toBe(false)
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('dispatches to the service unauthenticated', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.feed.getFeed': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setupPublic(fetchMock)
await manager.fetchHandler('/xrpc/app.bsky.feed.getFeed')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
expect(urlsOf(fetchMock).at(-1)).toBe(
`${SERVICE}/xrpc/app.bsky.feed.getFeed`,
)
})
})
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
const fetchMock = makeMockFetch()
setup({fetchMock})
expect(fetchMock.mock.calls.length).toBe(0)
})
it('a refresh fires onUpdated with fresh tokens', async () => {
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const {manager} = setup({sessionOptions: {onUpdated}})
await manager.refreshSession()
expect(onUpdated).toHaveBeenCalledTimes(1)
expect(manager.session?.accessJwt).toBe('access-jwt-2')
})
it('onDeleted fires when refresh returns a declared invalid-token error', async () => {
const onDeleted =
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'ExpiredToken', message: 'Token expired'}, 400),
})
const {manager} = setup({fetchMock, sessionOptions: {onDeleted, onUpdated}})
await expect(manager.refreshSession()).rejects.toBeDefined()
expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onUpdated).not.toHaveBeenCalled()
/* and the bridge reads as logged out afterwards */
expect(manager.session).toBe(undefined)
})
it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => {
const onDeleted =
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdateFailure =
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'InternalServerError'}, 500),
})
const {manager} = setup({
fetchMock,
sessionOptions: {onDeleted, onUpdateFailure},
})
/*
* PasswordSession.refresh() resolves with the unchanged data here; the
* bridge restores the old CredentialSession contract by rejecting.
*/
await expect(manager.refreshSession()).rejects.toThrow(
'Failed to refresh session',
)
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
expect(manager.session?.accessJwt).toBe('access-jwt')
})
it('rejects on a network error rather than reporting a no-op success', async () => {
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () => {
throw new TypeError('Network request failed')
},
})
const {manager} = setup({fetchMock})
await expect(manager.refreshSession()).rejects.toThrow(
'Failed to refresh session',
)
/* the session survives, exactly as the old transient-failure path did */
expect(manager.session?.accessJwt).toBe('access-jwt')
})
it('resumeSession rejects on a transient failure too', async () => {
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'InternalServerError'}, 500),
})
const {agent} = setup({fetchMock})
await expect(agent.resumeSession(agent.session!)).rejects.toThrow(
'Failed to refresh session',
)
})
})
@@ -0,0 +1,104 @@
import {describe, expect, it} from '@jest/globals'
import {
MAX_EXPIRY_RESCUE_GENERATIONS,
pickExpiryRescueCandidate,
} from '../expiry-rescue'
import {type SessionAccount} from '../types'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: 'https://bsky.social',
did: 'did:plc:example123',
handle: 'alice.test',
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('pickExpiryRescueCandidate', () => {
it('picks a candidate whose refreshJwt differs from the dying one', () => {
const fresh = makeAccount({refreshJwt: 'refresh-jwt-2'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [fresh],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(fresh)
})
it('rejects a candidate carrying the dying refreshJwt (equally dead)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-1'})],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate with no refreshJwt', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: undefined}), undefined],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate already recorded as failed (loop guard)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-2'})],
failedRefreshJwts: new Set(['refresh-jwt-2']),
})
expect(picked).toBe(undefined)
})
it('tries candidates in order, preferring the first qualifying one', () => {
const persistedCandidate = makeAccount({
refreshJwt: 'refresh-jwt-persisted',
})
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [persistedCandidate, reducerCandidate],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(persistedCandidate)
})
it('skips an unusable first candidate and falls back to a later one', () => {
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [
makeAccount({refreshJwt: 'refresh-jwt-1'}),
reducerCandidate,
],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(reducerCandidate)
})
it('gives up once the failed-generation set hits the hard cap', () => {
const failed = new Set<string>()
for (let i = 0; i < MAX_EXPIRY_RESCUE_GENERATIONS; i++) {
failed.add(`refresh-jwt-failed-${i}`)
}
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-dying',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-brand-new'})],
failedRefreshJwts: failed,
})
expect(picked).toBe(undefined)
})
})
+126
View File
@@ -0,0 +1,126 @@
import {jest} from '@jest/globals'
import {type SessionAccount} from '../types'
/*
* Shared fixtures for the suites that drive a real `PasswordSession` over a
* stubbed network. Not a suite itself - the filename deliberately avoids the
* `-test` suffix so jest does not collect it.
*/
export const DID = 'did:plc:example123'
export const HANDLE = 'alice.test'
export const SERVICE = 'https://bsky.social'
/** A PDS host an account may be pinned to by its stored `pdsUrl`. */
export const PDS_HOST = 'https://shimeji.us-east.host.bsky.network'
/** A different PDS host, delivered by the didDoc a refresh returns. */
export const DIDDOC_PDS_HOST = 'https://morel.us-west.host.bsky.network'
export function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
}
/** A minimal valid DID document whose only service entry is a PDS. */
export function makeDidDoc(pdsUrl: string, did: string = DID) {
return {
id: did,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
export function makeAccount(
overrides: Partial<SessionAccount> = {},
): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/**
* 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.
*
* The refresh response carries both `emailConfirmed` and a `didDoc` so the
* library has no reason to make a `getSession` follow-up call, which keeps the
* recorded request list assertable. Its didDoc points at
* {@link DIDDOC_PDS_HOST}, a different host from {@link PDS_HOST}, so PDS
* re-routing after a refresh is observable.
*/
export function makeMockFetch(
overrides: Record<
string,
(url: string, init: RequestInit) => Response | Promise<Response>
> = {},
) {
return jest.fn(
/*
* PasswordSession calls fetch with a URL object (new URL(path, service));
* asFetch() below widens the mock to the full fetch signature it expects.
*/
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
const url = input instanceof URL ? input.href : input
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
const handler = nsid ? overrides[nsid] : undefined
if (handler) {
return handler(url, init)
}
if (nsid === 'com.atproto.server.refreshSession') {
return json({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
handle: HANDLE,
did: DID,
email: 'alice@example.com',
emailConfirmed: true,
didDoc: makeDidDoc(DIDDOC_PDS_HOST),
active: true,
})
}
if (nsid === 'com.atproto.server.getSession') {
return json({
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
active: true,
})
}
return json({})
},
)
}
export type MockFetch = ReturnType<typeof makeMockFetch>
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
export function asFetch(mock: MockFetch): typeof fetch {
return mock as unknown as typeof fetch
}
/** The URLs a mock fetch was called with, in order. */
export function urlsOf(mock: MockFetch): string[] {
return mock.mock.calls.map(c => (c[0] instanceof URL ? c[0].href : c[0]))
}
@@ -0,0 +1,145 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {act, render} from '@testing-library/react-native'
/*
* The provider pulls the whole app shell in through `#/state/util` and the
* account factories. These mocks cut the tree back to the session lifecycle
* itself, which is all these tests drive.
*/
jest.mock('#/state/persisted', () => {
const {
defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return {
defaults,
get: (key: keyof typeof defaults) => defaults[key],
write: () => Promise.resolve(),
readLatest: (key: keyof typeof defaults) => defaults[key],
onUpdate: () => () => {},
}
})
jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}}))
jest.mock('#/components/dialogs/Context', () => ({
useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}),
}))
jest.mock('#/analytics', () => ({
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}),
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
}))
jest.mock('#/state/shell/onboarding', () => ({
useOnboardingDispatch: () => () => {},
}))
jest.mock('#/ageAssurance/data', () => ({
clearAgeAssuranceServerDataForAll: () => {},
clearAgeAssuranceServerDataForDid: () => {},
}))
jest.mock('#/lib/persisted-query-storage', () => ({
clearPersistedQueryStorage: () => Promise.resolve(),
}))
jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({jwtDecode: () => ({})}))
/*
* The factories are stubbed so a test controls exactly when each one resolves,
* which is what lets a second call abort the first while it is in flight.
* `disposeBundle` is spied on rather than replaced wholesale: the rest of
* session-core stays real so the provider's own module graph is unchanged.
*/
const mockLogin = jest.fn<(...args: unknown[]) => Promise<unknown>>()
const mockCreateAccount = jest.fn<(...args: unknown[]) => Promise<unknown>>()
const mockDisposeBundle = jest.fn()
jest.mock('../session-core', () => ({
...jest.requireActual<object>('../session-core'),
createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args),
disposeBundle: (bundle: unknown) => mockDisposeBundle(bundle),
}))
jest.mock('../create-account', () => ({
createSessionBundleAndCreateAccount: (...args: unknown[]) =>
mockCreateAccount(...args),
}))
import {Provider, useSessionApi} from '#/state/session'
import {type SessionApiContext} from '#/state/session/types'
/** Render the provider and hand back its api context. */
function renderProvider(): SessionApiContext {
let api!: SessionApiContext
function Probe() {
api = useSessionApi()
return null
}
render(
<Provider>
<Probe />
</Provider>,
)
return api
}
/*
* Every factory returns an ARMED bundle, so a call whose result is thrown away
* because a newer call superseded it must dispose that bundle. Leaving it armed
* leaves a live session auto-refreshing and rotating refresh tokens server-side
* for an account the app is no longer tracking.
*/
describe('superseded session tasks dispose their bundle', () => {
/*
* Without this, a recorded call from an earlier test satisfies a later
* assertion. The tagged bundles below are the other half of that guard: two
* `{}` literals are structurally equal, so `toHaveBeenCalledWith` could not
* tell one test's bundle from the other's even within a cleared mock.
*/
beforeEach(() => {
mockLogin.mockReset()
mockCreateAccount.mockReset()
mockDisposeBundle.mockReset()
})
it('disposes the bundle of an aborted login', async () => {
const bundle = {tag: 'login-bundle'} as never
let resolveLogin!: (value: unknown) => void
mockLogin.mockReturnValueOnce(
new Promise(resolve => {
resolveLogin = resolve
}),
)
const api = renderProvider()
const superseded = api.login({} as never, 'LoginForm')
/* the second call aborts the first task's signal, and never settles */
mockLogin.mockReturnValueOnce(new Promise(() => {}))
void api.login({} as never, 'LoginForm')
await act(async () => {
resolveLogin({bundle, account: {did: 'did:plc:example'}})
await superseded
})
expect(mockDisposeBundle).toHaveBeenCalledWith(bundle)
})
it('disposes the bundle of an aborted createAccount', async () => {
const bundle = {tag: 'create-account-bundle'} as never
let resolveCreate!: (value: unknown) => void
mockCreateAccount.mockReturnValueOnce(
new Promise(resolve => {
resolveCreate = resolve
}),
)
const api = renderProvider()
const superseded = api.createAccount({} as never, {} as never)
mockCreateAccount.mockReturnValueOnce(new Promise(() => {}))
void api.createAccount({} as never, {} as never)
await act(async () => {
resolveCreate({bundle, account: {did: 'did:plc:example'}})
await superseded
})
expect(mockDisposeBundle).toHaveBeenCalledWith(bundle)
})
})
@@ -0,0 +1,559 @@
import {type SessionData} from '@atproto/lex-password-session'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {act, render} from '@testing-library/react-native'
import {type Schema} from '#/state/persisted/schema'
import {type SessionAccount} from '../types'
/*
* The provider pulls the whole app shell in through `#/state/util` and the
* account factories. These mocks cut the tree back to the session lifecycle
* itself, which is all these tests drive. They mirror provider-abort-test.tsx,
* plus a stateful `#/state/persisted` (this suite drives cross-tab updates and
* the expiry rescue's fresh persisted read) and an observable
* `emitSessionDropped`.
*/
const mockPersisted: {session: Schema['session']; latest: Schema['session']} = {
session: {accounts: [], currentAccount: undefined},
latest: {accounts: [], currentAccount: undefined},
}
/*
* Every registered listener is kept, not just the newest. The provider's
* subscription effect re-runs on every state change, so a callback captured
* before a dispatch is exactly the stale-closure case the shouldActivate guard
* exists to catch.
*/
const mockPersistedListeners: ((value: Schema['session']) => void)[] = []
jest.mock('#/state/persisted', () => {
const {
defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return {
defaults,
get: (key: string) =>
key === 'session'
? mockPersisted.session
: defaults[key as keyof typeof defaults],
readLatest: (key: string) =>
key === 'session'
? mockPersisted.latest
: defaults[key as keyof typeof defaults],
write: () => Promise.resolve(),
onUpdate: (_key: string, cb: (value: Schema['session']) => void) => {
mockPersistedListeners.push(cb)
return () => {}
},
}
})
jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}}))
jest.mock('#/components/dialogs/Context', () => ({
useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}),
}))
jest.mock('#/analytics', () => ({
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}),
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
}))
jest.mock('#/state/shell/onboarding', () => ({
useOnboardingDispatch: () => () => {},
}))
jest.mock('#/ageAssurance/data', () => ({
clearAgeAssuranceServerDataForAll: () => {},
clearAgeAssuranceServerDataForDid: () => {},
}))
jest.mock('#/lib/persisted-query-storage', () => ({
clearPersistedQueryStorage: () => Promise.resolve(),
}))
jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({jwtDecode: () => ({})}))
const mockEmitSessionDropped = jest.fn()
jest.mock('#/state/events', () => ({
emitSessionDropped: () => mockEmitSessionDropped(),
emitNetworkConfirmed: () => {},
emitNetworkLost: () => {},
}))
/*
* The factories are stubbed so a test controls exactly what each one returns
* and when. `createSessionBundleFromStoredAccount` is stubbed faithfully rather
* than replaced by a constant: it must still consult `shouldActivate` and
* decline to hand back a bundle when the guard rejects, because that decision
* is what these tests observe. Its disposal of a rejected bundle is pinned by
* session-core-test; here we only assert what the provider does with the
* result.
*/
const mockLogin = jest.fn<(...args: unknown[]) => Promise<unknown>>()
const mockResume = jest.fn<(...args: unknown[]) => Promise<unknown>>()
const mockDisposeBundle = jest.fn()
type Rebuild = {
account: SessionAccount
shouldActivate: boolean
bundle: FakeBundle
}
const mockRebuilds: Rebuild[] = []
const mockRebuild = jest.fn(
(
account: SessionAccount,
_onSessionChange: unknown,
shouldActivate: (
bundle: unknown,
account: SessionAccount,
) => boolean = () => true,
) => {
const bundle = makeBundle(account)
const activated = shouldActivate(bundle, account)
mockRebuilds.push({account, shouldActivate: activated, bundle})
return activated ? {bundle, account} : undefined
},
)
jest.mock('../session-core', () => ({
...jest.requireActual<object>('../session-core'),
createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args),
createSessionBundleAndResume: (...args: unknown[]) => mockResume(...args),
createSessionBundleFromStoredAccount: (...args: unknown[]) =>
// @ts-expect-error the stub's arity is checked by its own signature
mockRebuild(...args),
disposeBundle: (bundle: unknown) => mockDisposeBundle(bundle),
}))
jest.mock('../create-account', () => ({
createSessionBundleAndCreateAccount: () => new Promise(() => {}),
}))
import {Provider, useSession, useSessionApi} from '#/state/session'
import {
type OnSessionChange,
type SessionBundle,
} from '#/state/session/session-core'
import {type SessionApiContext} from '#/state/session/types'
const DID = 'did:plc:example123'
const SERVICE = 'https://bsky.social/'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: 'alice.test',
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt-1',
accessJwt: 'access-jwt-1',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/*
* The provider only ever reads `bundle.agent` (for context) and
* `bundle.session.destroyed` / `bundle.session.session` (for the cross-tab
* token comparison), and otherwise treats a bundle as an opaque identity. A
* literal with those fields is enough, and keeps a real PasswordSession - with
* its network and refresh machinery - out of a suite about provider dispatch.
*/
type FakeBundle = {
session: {destroyed: boolean; session: SessionData}
agent: object
service: URL
}
function makeBundle(account: SessionAccount): FakeBundle {
return {
session: {
destroyed: false,
session: {
accessJwt: account.accessJwt ?? '',
refreshJwt: account.refreshJwt ?? '',
/* SessionData types these as branded strings; the values are fixtures */
handle: account.handle as `${string}.${string}`,
did: account.did as `did:${string}:${string}`,
active: true,
service: account.service,
},
},
agent: {},
service: new URL(account.service),
}
}
type Harness = {
api: SessionApiContext
/** The provider's own onSessionChange, as handed to a session factory. */
onSessionChange: OnSessionChange
currentAccount: () => SessionAccount | undefined
hasSession: () => boolean
}
/**
* Render the provider, log an account in through the stubbed login factory, and
* hand back the api plus the `onSessionChange` the factory received. Firing
* that callback is how a test synthesizes a session event from a live bundle.
*/
async function renderLoggedIn(
account: SessionAccount,
bundle: FakeBundle,
): Promise<Harness> {
let api!: SessionApiContext
let session!: ReturnType<typeof useSession>
function Probe() {
api = useSessionApi()
session = useSession()
return null
}
render(
<Provider>
<Probe />
</Provider>,
)
let captured!: OnSessionChange
mockLogin.mockImplementationOnce((...args: unknown[]) => {
captured = args[1] as OnSessionChange
return Promise.resolve({bundle, account})
})
await act(async () => {
await api.login({} as never, 'LoginForm')
})
return {
api,
onSessionChange: captured,
currentAccount: () => session.currentAccount,
hasSession: () => session.hasSession,
}
}
/** The dying payload PasswordSession threads through its `onDeleted` hook. */
function dyingData(refreshJwt: string): SessionData {
return {
accessJwt: 'dead-access-jwt',
refreshJwt,
handle: 'alice.test',
did: DID,
active: true,
service: SERVICE,
}
}
/** The rotated payload PasswordSession threads through its `onUpdated` hook. */
function refreshedData(
refreshJwt: string,
didDoc?: SessionData['didDoc'],
): SessionData {
return {
accessJwt: 'fresh-access-jwt',
refreshJwt,
handle: 'alice.test',
did: DID,
active: true,
service: SERVICE,
...(didDoc ? {didDoc} : {}),
}
}
/** A minimal valid DID document whose only service entry is a PDS. */
function makeDidDoc(pdsUrl: string): SessionData['didDoc'] {
return {
id: DID,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
beforeEach(() => {
mockPersisted.session = {accounts: [], currentAccount: undefined}
mockPersisted.latest = {accounts: [], currentAccount: undefined}
mockPersistedListeners.length = 0
mockRebuilds.length = 0
mockLogin.mockReset()
mockResume.mockReset()
mockRebuild.mockClear()
mockDisposeBundle.mockReset()
mockEmitSessionDropped.mockReset()
})
/*
* A stale tab can expire a refresh token that another tab has already rotated
* past. Logging every tab out on that event is the known-worst failure in this
* subsystem, so the provider first looks for a newer token generation and
* rebuilds onto it, only falling through to logout when there is nothing left
* to try.
*/
describe('expiry rescue', () => {
it('rebuilds onto a fresher persisted generation instead of logging out', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {onSessionChange, hasSession, currentAccount} = await renderLoggedIn(
account,
bundle,
)
/* another tab already rotated to generation 2 and wrote it to storage */
const fresher = makeAccount({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
})
mockPersisted.latest = {accounts: [fresher], currentAccount: fresher}
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'expired',
dyingData('refresh-jwt-1'),
)
})
/* the rescue rebuilt onto the fresher generation ... */
expect(mockRebuilds.length).toBe(1)
expect(mockRebuilds[0].account.refreshJwt).toBe('refresh-jwt-2')
/* ... and adopted it, without ever reporting the session as dropped */
expect(hasSession()).toBe(true)
expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2')
expect(mockEmitSessionDropped).not.toHaveBeenCalled()
})
it('drops the session and logs out when there is no fresher generation', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {onSessionChange, hasSession, currentAccount} = await renderLoggedIn(
account,
bundle,
)
/* storage agrees the dying token is the newest one anybody has */
mockPersisted.latest = {accounts: [account], currentAccount: account}
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'expired',
dyingData('refresh-jwt-1'),
)
})
expect(mockRebuilds.length).toBe(0)
expect(mockEmitSessionDropped).toHaveBeenCalledTimes(1)
expect(hasSession()).toBe(false)
/* the reducer cleared the dead credentials rather than keeping them */
expect(currentAccount()).toBe(undefined)
})
it('does not retry a generation that already failed', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {onSessionChange, hasSession} = await renderLoggedIn(account, bundle)
const gen2 = makeAccount({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
})
mockPersisted.latest = {accounts: [gen2], currentAccount: gen2}
/* generation 1 dies and is rescued onto generation 2 */
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'expired',
dyingData('refresh-jwt-1'),
)
})
expect(mockRebuilds.length).toBe(1)
const rescued = mockRebuilds[0].bundle
/*
* Generation 2 dies too, and a stale tab has meanwhile written generation 1
* back to storage. It differs from the dying token, so only the record of
* its earlier failure can reject it.
*/
mockPersisted.latest = {accounts: [account], currentAccount: account}
act(() => {
onSessionChange(
rescued as unknown as SessionBundle,
DID,
'expired',
dyingData('refresh-jwt-2'),
)
})
expect(mockRebuilds.length).toBe(1)
expect(mockEmitSessionDropped).toHaveBeenCalledTimes(1)
expect(hasSession()).toBe(false)
})
})
/*
* A refresh payload only carries a didDoc when the server sends one, but
* `pdsUrl` is never derived from the login service. If the provider does not
* thread the stored value through, an ordinary refresh persists
* `pdsUrl: undefined` and the next cold start routes pre-refresh requests to
* the entryway instead of the account's PDS.
*/
describe('refresh persistence', () => {
const PDS_HOST = 'https://shimeji.us-east.host.bsky.network'
const DIDDOC_PDS_HOST = 'https://morel.us-west.host.bsky.network'
it('keeps the stored pdsUrl when the refresh carries no didDoc', async () => {
const account = makeAccount({pdsUrl: `${PDS_HOST}/`})
const bundle = makeBundle(account)
const {onSessionChange, currentAccount} = await renderLoggedIn(
account,
bundle,
)
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'update',
refreshedData('refresh-jwt-2'),
)
})
expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2')
expect(currentAccount()?.pdsUrl).toBe(`${PDS_HOST}/`)
})
it('prefers the didDoc endpoint over the stored pdsUrl', async () => {
const account = makeAccount({pdsUrl: `${PDS_HOST}/`})
const bundle = makeBundle(account)
const {onSessionChange, currentAccount} = await renderLoggedIn(
account,
bundle,
)
act(() => {
onSessionChange(
bundle as unknown as SessionBundle,
DID,
'update',
refreshedData('refresh-jwt-2', makeDidDoc(DIDDOC_PDS_HOST)),
)
})
expect(currentAccount()?.pdsUrl).toBe(`${DIDDOC_PDS_HOST}/`)
})
})
/** Deliver a cross-tab `persisted` update to the provider's newest listener. */
function emitSynced(session: Schema['session']) {
mockPersistedListeners[mockPersistedListeners.length - 1](session)
}
/*
* A `PasswordSession` cannot be patched in place, so adopting tokens another
* tab refreshed means rebuilding the bundle. Doing that for every broadcast
* would churn the agent (and the React tree under it) constantly, so the
* provider rebuilds only when the tokens actually moved, and guards the swap
* against the store having advanced underneath it.
*/
describe('cross-tab sync', () => {
it('short-circuits an update carrying the tokens the live session already has', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {hasSession} = await renderLoggedIn(account, bundle)
act(() => {
emitSynced({accounts: [account], currentAccount: account})
})
/* identical tokens: nothing to adopt, so no rebuild */
expect(mockRebuilds.length).toBe(0)
expect(hasSession()).toBe(true)
})
it('rebuilds onto tokens another tab rotated', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {currentAccount} = await renderLoggedIn(account, bundle)
const rotated = makeAccount({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
})
act(() => {
emitSynced({accounts: [rotated], currentAccount: rotated})
})
expect(mockRebuilds.length).toBe(1)
expect(mockRebuilds[0].account.refreshJwt).toBe('refresh-jwt-2')
expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2')
})
it('declines to activate a rebuild once the store has moved past the bundle it was built for', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
await renderLoggedIn(account, bundle)
const gen2 = makeAccount({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
})
const gen3 = makeAccount({
accessJwt: 'access-jwt-3',
refreshJwt: 'refresh-jwt-3',
})
/*
* Two broadcasts land back to back inside one act(), so React does not
* commit (and the effect does not re-subscribe) between them: the second
* runs the listener registered while the ORIGINAL bundle was current, even
* though the store has since advanced to the generation-2 rebuild.
*/
const listener = mockPersistedListeners[mockPersistedListeners.length - 1]
act(() => {
listener({accounts: [gen2], currentAccount: gen2})
listener({accounts: [gen3], currentAccount: gen3})
})
expect(mockRebuilds.length).toBe(2)
expect(mockRebuilds[0].shouldActivate).toBe(true)
/* the stale closure's bundle is no longer current, so the swap is refused */
expect(mockRebuilds[1].account.refreshJwt).toBe('refresh-jwt-3')
expect(mockRebuilds[1].shouldActivate).toBe(false)
})
it('cancels pending work when another tab logs the account out', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {api} = await renderLoggedIn(account, bundle)
/* a resume is in flight and will resolve only after the cross-tab logout */
const resumedBundle = makeBundle(account)
let finishResume!: (value: unknown) => void
mockResume.mockReturnValueOnce(
new Promise(resolve => {
finishResume = resolve
}),
)
const pending = api.resumeSession(account)
const loggedOut = makeAccount({accessJwt: undefined, refreshJwt: undefined})
act(() => {
emitSynced({accounts: [loggedOut], currentAccount: loggedOut})
})
await act(async () => {
finishResume({bundle: resumedBundle, account})
await pending
})
/* the superseded resume disposed its bundle rather than signing back in */
expect(mockDisposeBundle).toHaveBeenCalledWith(resumedBundle)
expect(mockRebuilds.length).toBe(0)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const PREFIX = 'agent-labelers'
export async function saveLabelers(did: string, value: string[]) {
await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))
}
export async function readLabelers(did: string): Promise<string[] | undefined> {
const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)
return rawData ? JSON.parse(rawData) : undefined
}
+6 -398
View File
@@ -1,338 +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 {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {type SessionAccount} from './types'
import {isSessionExpired, isSignupQueued} from './util'
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',
})
const moderation = 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, moderation, 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'})
const moderation = configureModerationForAccount(agent, account)
const aa = prefetchAgeAssuranceServerData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare({
resolvers: [gates, moderation, 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'})
const moderation = configureModerationForAccount(agent, account)
const createdAt = new Date().toISOString()
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: account.did, createdAt})
setBirthdateForDid({did: account.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(account.did)
// do this last
const aa = prefetchAgeAssuranceServerData({agent})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthdate,
})
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
networkRetry(1, () => {
return agent.overwriteSavedFeeds([
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
throw e
}),
// wait for AA data to load first, then check state
aa.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
} else {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthDate.toISOString(),
})
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString()
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
}
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e: any) {
logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
}
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount {
const account = agentToSessionAccount(agent)
if (!account) {
throw Error('Expected an active session')
}
return account
}
export function agentToSessionAccount(
agent: AtpAgent,
): SessionAccount | undefined {
if (!agent.session) {
return undefined
}
return {
service: agent.serviceUrl.toString(),
did: agent.session.did,
handle: agent.session.handle,
email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
signupQueued: isSignupQueued(agent.session.accessJwt),
active: agent.session.active,
status: agent.session.status,
pdsUrl: agent.pdsUrl?.toString(),
isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
}
}
export function sessionAccountToSession(
account: SessionAccount,
): AtpSessionData {
return {
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
accessJwt: account.accessJwt ?? '',
did: account.did,
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle,
refreshJwt: account.refreshJwt ?? '',
/**
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
*/
active: account.active ?? true,
status: account.status,
}
}
/**
* 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,
@@ -344,74 +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.
let realFetch = globalThis.fetch
class BskyAppAgent extends AtpAgent {
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
undefined
constructor({service}: {service: string}) {
super({
service,
async fetch(...args) {
let success = false
try {
const result = await realFetch(...args)
success = true
return result
} catch (e) {
success = false
throw e
} finally {
if (success) {
emitNetworkConfirmed()
} else {
emitNetworkLost()
}
}
},
persistSession: (event: AtpSessionEvent) => {
if (this.persistSessionHandler) {
this.persistSessionHandler(event)
}
},
})
}
async prepare({
resolvers,
onSessionChange,
}: {
// Not awaited in the calling code so we can delay blocking on them.
resolvers: Promise<unknown>[]
onSessionChange: (
agent: AtpAgent,
did: string,
event: AtpSessionEvent,
) => void
}) {
// There's nothing else left to do, so block on them here.
await Promise.all(resolvers)
// Now the agent is ready.
const account = agentToSessionAccountOrThrow(this)
this.persistSessionHandler = event => {
onSessionChange(this, account.did, event)
if (event !== 'create' && event !== 'update') {
addSessionErrorLog(account.did, event)
}
}
return {account, agent: this}
}
dispose() {
this.sessionManager.session = undefined
this.persistSessionHandler = undefined
}
}
export type {BskyAppAgent}
+442
View File
@@ -0,0 +1,442 @@
import {
AtpAgent,
type AtpAgentLoginOpts,
type AtpSessionData,
type ComAtprotoServerCreateAccount,
type ComAtprotoServerCreateSession,
type ComAtprotoServerRefreshSession,
CredentialSession,
} from '@atproto/api'
import {
type PasswordSession,
type SessionData,
} from '@atproto/lex-password-session'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {configureModerationForGuest} from './moderation'
import {networkAwareFetch} from './network'
const UNSUPPORTED =
'Not supported on PasswordSessionManager; use the session factories in session-core'
/**
* Convert live `PasswordSession` session data into the `AtpSessionData` shape
* that `CredentialSession.session` consumers expect.
*
* The only real adaptation is `active`: `AtpSessionData` requires it, while the
* lexicon payload leaves it optional (absent means active, per the lexicon
* docs).
*/
function toAtpSessionData(d: SessionData): AtpSessionData {
return {
refreshJwt: d.refreshJwt,
accessJwt: d.accessJwt,
handle: d.handle,
did: d.did,
email: d.email,
emailConfirmed: d.emailConfirmed,
emailAuthFactor: d.emailAuthFactor,
active: d.active ?? true,
status: d.status,
}
}
/**
* Parse a URL without throwing.
*/
function parseUrl(input: string): URL | undefined {
try {
return new URL(input)
} catch {
return undefined
}
}
/**
* Read a property off an unknown value the way JS optional chaining would,
* without narrowing assumptions about the shape of a `LexMap`.
*/
function prop(value: unknown, key: string): unknown {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)[key]
: undefined
}
/**
* The PDS endpoint declared by a DID document, or `undefined`.
*
* This deliberately mirrors `extractPdsUrl` in `@atproto/lex-password-session`,
* which is the predicate `PasswordSession` uses to route its own requests: the
* first service entry whose `id` ends with `#atproto_pds`, taking its
* `serviceEndpoint` if it parses as a URL. It is looser than the
* `isValidDidDoc` + `getPdsEndpoint` pair from `@atproto/common-web` (no doc
* schema validation, no `type` check), and that is the point - a stricter
* predicate here would let `dispatchUrl` disagree with the host requests
* actually go to, which in turn mints service-auth tokens (video upload) for
* the wrong audience.
*/
function extractPdsUrl(didDoc: SessionData['didDoc']): URL | undefined {
const services = prop(didDoc, 'service')
if (!Array.isArray(services)) {
return undefined
}
/*
* `find`, not a scan: the inner session stops at the first `#atproto_pds`
* entry and gives up if its endpoint does not parse, rather than falling
* through to a later entry.
*/
const pds = services.find(service => {
const id = prop(service, 'id')
return typeof id === 'string' && id.endsWith('#atproto_pds')
})
const endpoint = prop(pds, 'serviceEndpoint')
return typeof endpoint === 'string' ? parseUrl(endpoint) : undefined
}
/**
* A `CredentialSession` whose auth core is a `PasswordSession`.
*
* This is the compat shim that lets a `PasswordSession` sit under `AtpAgent`:
* every call site that reads `agent.session`, `agent.pdsUrl`,
* `agent.dispatchUrl`, `agent.did` or calls `agent.resumeSession()` keeps
* working, while the actual tokens, refresh serialization and PDS routing live
* in the `PasswordSession` underneath.
*
* A `null` inner session means "logged out" - the public/guest agent. In that
* mode requests still go out (unauthenticated) through the inherited `fetch`.
*/
export class PasswordSessionManager extends CredentialSession {
#inner: PasswordSession | null
#storedPdsUrl: URL | undefined
#disposed = false
/*
* Identity caches for the two pull-through accessors. Each holds the
* `SessionData` it was derived from so a repeated read returns the very same
* object (see the note on identity stability below). Keying on the whole
* `SessionData` rather than the individual field works because
* `PasswordSession` replaces the object wholesale on every rotation.
*/
#sessionSource: SessionData | undefined
#sessionValue: AtpSessionData | undefined
#pdsSource: SessionData | undefined
#pdsValue: URL | undefined
constructor(
inner: PasswordSession | null,
{service, pdsUrl}: {service: string; pdsUrl?: string},
) {
/*
* `persistSession` is deliberately undefined: the inner `PasswordSession`
* owns persistence through its own hooks, and none of the inherited methods
* that would call this handler survive the overrides below.
*/
super(new URL(service), networkAwareFetch, undefined)
this.#inner = inner
this.#storedPdsUrl = pdsUrl ? parseUrl(pdsUrl) : undefined
/*
* `session` and `pdsUrl` are pull-through accessors over the inner session
* rather than mirrored values, installed here with `defineProperty` for two
* reasons.
*
* Why accessors at all: a mirror has to be written on every token rotation,
* and any missed write silently serves stale tokens. Pulling through cannot
* drift.
*
* Why `defineProperty` and not `get session()` in the class body: the
* parent declares `session` and `pdsUrl` as *properties*, and TypeScript
* rejects overriding a property with an accessor (TS2611). Installing them
* at runtime sidesteps that, and it is safe as long as
* `CredentialSession`'s emitted constructor does not assign either one
* (both are declaration-only), so there is nothing to clobber and no
* ordering hazard.
*
* For the same reason this class must NOT redeclare `session`/`pdsUrl` as
* fields: under `useDefineForClassFields` semantics (target esnext) a field
* declaration emits an own-property definition that would overwrite these
* accessors with `undefined`.
*/
Object.defineProperty(this, 'session', {
configurable: true,
get: () => this.#readSession(),
set: () => {
throw new Error('PasswordSessionManager.session is read-only')
},
})
Object.defineProperty(this, 'pdsUrl', {
configurable: true,
get: () => this.#readPdsUrl(),
set: () => {
throw new Error('PasswordSessionManager.pdsUrl is read-only')
},
})
}
/**
* The inner session's live data, or `undefined` when there is nothing to read
* from.
*
* `PasswordSession`'s `session`/`did`/`handle` getters *throw* `Logged out`
* once the session has been destroyed. Every read in this class funnels
* through here so that failure mode can never escape into the app, which
* reads `agent.session` from render paths.
*/
#liveData(): SessionData | undefined {
if (this.#disposed || !this.#inner || this.#inner.destroyed) {
return undefined
}
return this.#inner.session
}
/**
* The `session` accessor's implementation.
*
* Identity-cached on the source `SessionData`: consecutive reads with no
* intervening token rotation return the same object, and a rotation produces
* a new one. `CredentialSession` declares `session` as a plain field, so
* consumers are entitled to treat it as a value whose identity changes only
* when the session does; this class is read from render paths, and returning
* a freshly allocated object on every read would break that expectation for
* any memo, dependency array or reference comparison built on top of it.
*/
#readSession(): AtpSessionData | undefined {
const live = this.#liveData()
if (!live) {
this.#sessionSource = undefined
this.#sessionValue = undefined
return undefined
}
if (live !== this.#sessionSource) {
this.#sessionSource = live
this.#sessionValue = toAtpSessionData(live)
}
return this.#sessionValue
}
/**
* The `pdsUrl` accessor's implementation.
*
* The DID document's PDS endpoint wins when there is one, derived with
* {@link extractPdsUrl} so this agrees exactly with the inner session's own
* routing. Before the first refresh delivers a didDoc (the non-expired resume
* fast path, which makes no network call) we fall back to the `pdsUrl`
* persisted on the account, so the very first requests still reach the right
* host - entryway accounts have `service: bsky.social` but live on a
* different PDS.
*
* Identity-cached on the didDoc for the same reason as `session`.
*/
#readPdsUrl(): URL | undefined {
const live = this.#liveData()
if (!live) {
this.#pdsSource = undefined
this.#pdsValue = undefined
return undefined
}
if (live !== this.#pdsSource) {
this.#pdsSource = live
this.#pdsValue = extractPdsUrl(live.didDoc) ?? this.#storedPdsUrl
}
return this.#pdsValue
}
/*
* `did`, `hasSession` and `dispatchUrl` are deliberately NOT overridden: the
* inherited getters read `this.session` / `this.pdsUrl`, which resolve
* through the accessors above, so they are already live.
*/
override async fetchHandler(
url: string,
init?: RequestInit,
): Promise<Response> {
/*
* Absolutizing against `dispatchUrl` routes to the stored PDS on the resume
* fast path and to the didDoc PDS from the first refresh onwards, since
* `PasswordSession` resolves an already absolute URL against its own base
* as a no-op.
*/
const target = new URL(url, this.dispatchUrl)
const inner = this.#disposed ? null : this.#inner
/*
* A caller that set its own `authorization` header bypasses the inner
* session entirely. This is mandatory: `PasswordSession.fetchHandler`
* throws `TypeError` on a pre-set authorization header rather than
* deferring to it.
*
* Bypassing also means these requests get no refresh-on-401 retry, since
* that lives in `PasswordSession.fetchHandler`. That is intentional: the
* caller supplied its own credential (a service-auth token, say), so
* rotating the session's tokens would not make the request any more likely
* to succeed on a retry.
*/
if (
!inner ||
inner.destroyed ||
new Headers(init?.headers).has('authorization')
) {
return (0, this.fetch)(target, init)
}
/*
* `init ?? {}` because `PasswordSession.fetchHandler` reads `init.headers`
* unguarded, while the inherited signature makes `init` optional.
*/
return inner.fetchHandler(target.href, init ?? {})
}
/**
* Refresh the session, rejecting if nothing was refreshed.
*
* This restores the contract of the `CredentialSession.refreshSession` this
* class replaces, which rejected on any refresh failure.
* `PasswordSession.refresh()` does not: on a transient failure (a 500, a
* network error) it reports through `onUpdateFailure` and then *resolves*
* with the unchanged session data, reserving rejection for the cases where
* the session is definitively gone. Callers here read resolution as "tokens
* rotated" - `SignupQueued` refreshes and then re-checks the token scope, and
* the various verification dialogs refresh and then close - so a resolved
* no-op would silently loop or report success.
*
* The signal is the identity of the returned `SessionData`, not a field
* comparison: `PasswordSession` builds a brand new object on every successful
* rotation and returns the existing one untouched on a transient failure, so
* identity separates the two exactly. Comparing against the data captured
* immediately before the call also gets concurrent refreshes right - if
* another caller's refresh rotated the tokens while ours was queued behind it
* (`PasswordSession` serializes refreshes), the data we get back still
* differs from what we captured, which is a success for our caller.
*/
override async refreshSession(): Promise<ComAtprotoServerRefreshSession.Response> {
const inner = this.#disposed ? null : this.#inner
if (!inner || inner.destroyed) {
throw new Error('No session to refresh')
}
const before = this.#liveData()
const data = await inner.refresh()
if (data === before) {
throw new Error('Failed to refresh session')
}
/*
* Re-shape the lex payload into the `@atproto/api` XRPC response envelope.
* `headers` is empty because the inner session does not surface response
* headers, and no caller in this app reads them off a refresh.
*/
return {
success: true,
headers: {},
data: {
accessJwt: data.accessJwt,
refreshJwt: data.refreshJwt,
handle: data.handle,
did: data.did,
didDoc: data.didDoc,
email: data.email,
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
active: data.active,
status: data.status,
},
}
}
/**
* Force a refresh, ignoring the passed-in session data.
*
* The inner session already owns its tokens, so there is nothing to install;
* every call site in the app uses `resumeSession` as "refresh my session
* now". The returned envelope is the refresh one, which is structurally a
* superset of `ComAtprotoServerGetSession.Response` (the shape `AtpAgent`
* advertises), so both layers stay type-correct.
*
* It inherits {@link PasswordSessionManager.refreshSession}'s contract, so it
* rejects rather than resolving when no tokens were rotated.
*/
override resumeSession(
_session: AtpSessionData,
): Promise<ComAtprotoServerRefreshSession.Response> {
return this.refreshSession()
}
override async logout(): Promise<void> {
const inner = this.#inner
if (!inner || inner.destroyed) {
return
}
try {
await inner.logout()
} catch {
/* matches the parent, which swallows delete-session failures */
}
}
override login(
_opts: AtpAgentLoginOpts,
): Promise<ComAtprotoServerCreateSession.Response> {
return Promise.reject(new Error(UNSUPPORTED))
}
override createAccount(
_data: ComAtprotoServerCreateAccount.InputSchema,
_opts?: ComAtprotoServerCreateAccount.CallOptions,
): Promise<ComAtprotoServerCreateAccount.Response> {
return Promise.reject(new Error(UNSUPPORTED))
}
/**
* Detach this manager from its inner session.
*
* All reads then behave as logged out and requests fall back to the
* unauthenticated `fetch` path. The inner session is left alone: it may still
* be shared, and logging out is a separate, explicit operation.
*/
dispose() {
this.#disposed = true
}
}
/*
* Declaration merging to narrow the inherited `sessionManager` (typed as
* `CredentialSession` by `AtpAgent`) to the manager `BskyAppAgent` actually
* receives. A `declare` class field would be the direct way to say this, but
* babel's TypeScript transform rejects `declare` fields in this config, and a
* `get sessionManager()` override is forbidden because the parent declares it
* as a property (TS2611). The merge is sound: the constructor passes the
* manager straight to `super`, which assigns it.
*/
// eslint-disable-next-line typescript/no-unsafe-declaration-merging
export interface BskyAppAgent {
readonly sessionManager: PasswordSessionManager
}
/**
* The app's `AtpAgent`, backed by a `PasswordSession`.
*
* Everything interesting lives in {@link PasswordSessionManager}; this exists
* so `useAgent()` consumers keep getting a real `AtpAgent` (proxy headers,
* labeler headers, the `app`/`com`/`chat` namespaces) and so the agent can be
* disposed alongside its session.
*/
export class BskyAppAgent extends AtpAgent {
constructor(manager: PasswordSessionManager) {
super(manager)
}
dispose() {
this.sessionManager.dispose()
}
}
/** 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(
new PasswordSessionManager(null, {service: PUBLIC_BSKY_SERVICE}),
)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent
}
+243
View File
@@ -0,0 +1,243 @@
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,
finishPreparation,
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,
getBundle: () => bundle,
getDid: () => accountDid,
})
const session = await PasswordSession.createAccount(
{
email,
password,
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
handle: handle as `${string}.${string}`,
inviteCode,
verificationPhone,
verificationCode,
},
{...hooks, service},
)
bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill)
// Seed the hook and the deferred writes with refresh-stable account fields.
const earlyAccount = snapshotNewAccount(session, email)
accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle.agent, earlyAccount)
const createdAt = new Date().toISOString()
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: earlyAccount.did, createdAt})
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// Start the prefetch after seeding its synchronous birthdate inputs.
const aa = prefetchAgeAssuranceServerData({agent: bundle.agent})
const isProd = Boolean(IS_PROD_SERVICE(service))
const postSignupTasks: Promise<unknown>[] = [
savePersonalDetails(bundle.agent, birthdate),
initializeProfile(bundle.agent, {handle, createdAt, isProd}),
]
if (isProd) {
postSignupTasks.push(
initializeSavedFeeds(bundle.agent),
restrictChatAfterAgeAssurance(aa, bundle.agent, earlyAccount.did),
)
}
// Post-signup writes are not required to enter onboarding.
void reportPostSignupFailures(postSignupTasks)
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: failed snoozeEmailConfirmationPrompt`,
})
}
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
Promise.all([gates, aa]),
() => 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 session carries no email state or `active` flag until the
* first refresh. Synthesize those fields from the creation input so the
* persisted account does not briefly claim the email is unknown.
*/
function snapshotNewAccount(
session: PasswordSession,
email: string,
): SessionAccount {
const account = sessionDataToSessionAccount(
session.session,
session.session.service,
)
if (!account) {
throw Error('Expected an active session')
}
return {
...account,
email: account.email ?? email,
emailConfirmed: account.emailConfirmed ?? false,
emailAuthFactor: account.emailAuthFactor ?? false,
active: account.active ?? true,
}
}
function savePersonalDetails(agent: BskyAppAgent, birthDate: string) {
return retryPostSignupTask('set birthDate', 3, () =>
agent.setPersonalDetails({birthDate}),
)
}
function initializeProfile(
agent: BskyAppAgent,
{
handle,
createdAt,
isProd,
}: {
handle: string
createdAt: string
isProd: boolean
},
) {
return retryPostSignupTask('set initial profile', 3, () =>
agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
if (isProd) {
next.displayName = handle
next.createdAt = createdAt
} else {
next.createdAt = prev?.createdAt || new Date().toISOString()
}
return next
}),
)
}
function initializeSavedFeeds(agent: BskyAppAgent) {
return retryPostSignupTask('set initial feeds', 1, () =>
agent.overwriteSavedFeeds([
{...DISCOVER_SAVED_FEED, id: TID.nextStr()},
{...TIMELINE_SAVED_FEED, id: TID.nextStr()},
]),
)
}
function restrictChatAfterAgeAssurance(
ageAssurance: Promise<unknown>,
agent: BskyAppAgent,
did: string,
) {
return ageAssurance.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
})
}
function retryPostSignupTask<T>(
description: string,
retries: number,
task: () => Promise<T>,
) {
return networkRetry(retries, task).catch(e => {
logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`)
throw e
})
}
async function reportPostSignupFailures(tasks: Promise<unknown>[]) {
const results = await Promise.allSettled(tasks)
if (results.some(result => result.status === 'rejected')) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save post-signup settings`,
)
}
}
+30
View File
@@ -0,0 +1,30 @@
import {type SessionAccount} from './types'
/** Maximum failed token generations considered during one expiry rescue. */
export const MAX_EXPIRY_RESCUE_GENERATIONS = 5
/** Pick the first unfailed token generation newer than the one that expired. */
export function pickExpiryRescueCandidate({
dyingRefreshJwt,
candidates,
failedRefreshJwts,
}: {
dyingRefreshJwt: string
candidates: (SessionAccount | undefined)[]
failedRefreshJwts: ReadonlySet<string>
}): SessionAccount | undefined {
if (failedRefreshJwts.size >= MAX_EXPIRY_RESCUE_GENERATIONS) {
return undefined
}
for (const candidate of candidates) {
const refreshJwt = candidate?.refreshJwt
if (
refreshJwt &&
refreshJwt !== dyingRefreshJwt &&
!failedRefreshJwts.has(refreshJwt)
) {
return candidate
}
}
return undefined
}
+333 -86
View File
@@ -3,12 +3,14 @@ import {
useCallback,
useContext,
useEffect,
useInsertionEffect,
useMemo,
useRef,
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,17 +18,28 @@ 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'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
import {
type AtpSessionEvent,
createSessionBundleAndLogin,
createSessionBundleAndResume,
createSessionBundleFromStoredAccount,
disposeBundle,
type PublicSessionBundle,
type SessionBundle,
sessionDataToSessionAccount,
} from './session-core'
export {isSignupQueued} from './session-data'
import {
addSessionDebugLog,
getBundleId,
redactAccount,
redactPersistedSession,
redactSessionData,
redactState,
} from './logging'
export type {SessionAccount} from '#/state/session/types'
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
@@ -47,8 +60,11 @@ const StateContext = createContext<SessionStateContext>({
})
StateContext.displayName = 'SessionStateContext'
const AgentContext = createContext<AtpAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
/** Active account bundle, or the public bundle when logged out. */
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null,
)
BundleContext.displayName = 'SessionBundleContext'
const ApiContext = createContext<SessionApiContext>({
createAccount: async () => {},
@@ -68,7 +84,7 @@ class SessionStore {
constructor() {
// Careful: By the time this runs, `persisted` needs to already be filled.
const initialState = getInitialState(persisted.get('session').accounts)
addSessionDebugLog({type: 'reducer:init', state: initialState})
addSessionDebugLog({type: 'reducer:init', state: redactState(initialState)})
this.state = initialState
}
@@ -92,10 +108,13 @@ 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})
addSessionDebugLog({
type: 'persisted:broadcast',
data: redactPersistedSession(persistedData),
})
void persisted.write('session', persistedData)
}
this.listeners.forEach(listener => listener())
@@ -110,15 +129,118 @@ 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<Map<string, Set<string>>>(new Map())
/*
* Rescued bundles need this callback for their own events. A ref avoids a
* self-reference in the callback's dependency list. It is filled by the
* insertion effect below, which commits well before any session hook can
* fire: hooks are armed only after an asynchronous session factory resolves.
*/
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.
*
* A refresh payload carries no didDoc unless the server sends one, so the
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
* refresh would persist `pdsUrl: undefined` and the next cold start would
* route pre-refresh requests to the entryway instead of the PDS.
*/
const refreshedAccount =
sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount(
sessionData,
sessionData.service,
store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
)
: 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,48 +248,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
[store],
)
/*
* Writing the ref during render is forbidden under React Compiler. An
* insertion effect is the earliest commit-time slot, and the only reader
* (`onSessionChange`'s expiry-rescue path) runs from armed session hooks,
* which cannot fire before the first commit.
*/
useInsertionEffect(() => {
onSessionChangeRef.current = onSessionChange
}, [onSessionChange])
const createAccount = useCallback<SessionApiContext['createAccount']>(
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) {
// The factory returns an armed bundle, so a superseded signup must dispose it.
disposeBundle(bundle)
return
}
store.dispatch({
type: 'switched-to-account',
newAgent: agent,
newBundle: bundle,
newAccount: account,
})
ax.metric('account:create:success', metrics, {
session: utils.accountToSessionMetadata(account),
})
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
addSessionDebugLog({
type: 'method:end',
method: 'createAccount',
account: redactAccount(account),
})
},
[ax, store, onAgentSessionChange, cancelPendingTask],
[ax, store, onSessionChange, cancelPendingTask],
)
const login = useCallback<SessionApiContext['login']>(
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) {
// The factory returns an armed bundle, so a superseded login must dispose it.
disposeBundle(bundle)
return
}
store.dispatch({
type: 'switched-to-account',
newAgent: agent,
newBundle: bundle,
newAccount: account,
})
ax.metric(
@@ -175,9 +314,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{logContext, withPassword: true},
{session: utils.accountToSessionMetadata(account)},
)
addSessionDebugLog({type: 'method:end', method: 'login', account})
addSessionDebugLog({
type: 'method:end',
method: 'login',
account: redactAccount(account),
})
},
[ax, store, onAgentSessionChange, cancelPendingTask],
[ax, store, onSessionChange, cancelPendingTask],
)
const logoutCurrentAccount = useCallback<
@@ -196,17 +339,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 +373,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,
),
),
},
@@ -251,61 +394,94 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({
type: 'method:start',
method: 'resumeSession',
account: storedAccount,
account: redactAccount(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})
addSessionDebugLog({
type: 'method:end',
method: 'resumeSession',
account: redactAccount(account),
})
if (isSwitchingAccounts) {
// reset onboarding flow on switch account
onboardingDispatch({type: 'skip'})
}
},
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
[store, onSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const agent = state.currentAgentState.agent as BskyAppAgent
/*
* Read the live bundle rather than the one captured by this render: a
* dispatch that lands before the next render would otherwise leave this
* holding a disposed bundle, whose agent dispatches unauthenticated.
*/
const bundle = store.getState().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,
},
})
}, [store, state, cancelPendingTask])
}, [store, cancelPendingTask])
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
account => {
addSessionDebugLog({
type: 'method:start',
method: 'removeAccount',
account,
account: redactAccount(account),
})
cancelPendingTask()
store.dispatch({
type: 'removed-account',
accountDid: account.did,
})
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
addSessionDebugLog({
type: 'method:end',
method: 'removeAccount',
account: redactAccount(account),
})
clearAgeAssuranceServerDataForDid({did: account.did})
},
[store, cancelPendingTask],
@@ -313,7 +489,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
useEffect(() => {
return persisted.onUpdate('session', nextSession => {
const synced = nextSession
addSessionDebugLog({type: 'persisted:receive', data: synced})
addSessionDebugLog({
type: 'persisted:receive',
data: redactPersistedSession(synced),
})
store.dispatch({
type: 'synced-accounts',
syncedAccounts: synced.accounts,
@@ -322,38 +501,91 @@ 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',
bundleId: getBundleId(newBundle),
prevSession: redactSessionData(
prevBundle.session && !prevBundle.session.destroyed
? prevBundle.session.session
: undefined,
),
nextSession: redactSessionData(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 +611,38 @@ 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)
/*
* Disposal is deferred to this post-commit effect deliberately: components may
* still render against the outgoing bundle during the commit that swaps it, so
* tearing its agent down inline would pull the agent out from under them. The
* reducer's bundle-identity guard drops any events the not-yet-disposed session
* emits in that window.
*/
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',
prevBundleId: getBundleId(prevBundle),
nextBundleId: getBundleId(bundle),
})
// Replaced bundles must never consume another refresh token.
disposeBundle(prevBundle)
}
}, [agent])
}, [bundle])
return (
<AgentContext.Provider value={agent}>
<BundleContext.Provider value={bundle}>
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>
<AnalyticsContext
@@ -411,7 +655,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
</AnalyticsContext>
</ApiContext.Provider>
</StateContext.Provider>
</AgentContext.Provider>
</BundleContext.Provider>
)
}
@@ -453,10 +697,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 <SessionProvider>.')
}
return agent
return bundle.agent
}
+146 -18
View File
@@ -1,21 +1,140 @@
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
import {type SessionData} from '@atproto/lex-password-session'
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
/**
* An account reduced to the fields that are safe to ship off-device.
*
* Credentials become presence booleans and PII (email, service, pdsUrl) is
* dropped entirely. The log only ever needs to answer "which account was live,
* and did it still hold credentials?". The stubs at the bottom of this file are
* expected to be revived against Sentry or Bitdrift, so no payload type may be
* capable of carrying a live JWT in the first place.
*/
export type RedactedAccount = {
did: string
handle: string
active: boolean | undefined
status: string | undefined
signupQueued: boolean | undefined
hasAccessJwt: boolean
hasRefreshJwt: boolean
}
/** The account list plus the current did, in redacted form. */
export type RedactedSessionSnapshot = {
accounts: RedactedAccount[]
currentDid: string | undefined
}
/** Live session data reduced to identity plus credential presence. */
export type RedactedSessionData = {
did: string
handle: string
hasAccessJwt: boolean
hasRefreshJwt: boolean
}
function redact(account: SessionAccount): RedactedAccount {
return {
did: account.did,
handle: account.handle,
active: account.active,
status: account.status,
signupQueued: account.signupQueued,
hasAccessJwt: !!account.accessJwt,
hasRefreshJwt: !!account.refreshJwt,
}
}
export function redactAccount(
account: SessionAccount | undefined,
): RedactedAccount | undefined {
return account ? redact(account) : undefined
}
export function redactState(state: State): RedactedSessionSnapshot {
return {
accounts: state.accounts.map(redact),
currentDid: state.currentBundleState.did,
}
}
export function redactPersistedSession(
data: Schema['session'],
): RedactedSessionSnapshot {
return {
accounts: data.accounts.map(redact),
currentDid: data.currentAccount?.did,
}
}
export function redactSessionData(
data: SessionData | undefined,
): RedactedSessionData | undefined {
if (!data) return undefined
return {
did: data.did,
handle: data.handle,
hasAccessJwt: !!data.accessJwt,
hasRefreshJwt: !!data.refreshJwt,
}
}
/*
* Bundles are logged for identity only - which one was live, and which one
* replaced it. Logging the object itself would reach its session, and through
* it the tokens, so each is mapped to an opaque per-run id instead.
*/
const bundleIds = new WeakMap<object, string>()
const runId = Math.random().toString(36).slice(2)
let nextBundleId = 1
export function getBundleId(bundle: object): string {
let id = bundleIds.get(bundle)
if (id === undefined) {
id = runId + '::' + nextBundleId++
bundleIds.set(bundle, id)
}
return id
}
/** An action reduced to its discriminant plus the did it targets, if any. */
type RedactedAction = {
type: Action['type']
accountDid?: string
}
function redactAction(action: Action): RedactedAction {
switch (action.type) {
case 'received-session-event':
case 'removed-account':
case 'partial-refresh-session':
return {type: action.type, accountDid: action.accountDid}
case 'switched-to-account':
case 'replaced-current-bundle':
return {type: action.type, accountDid: action.newAccount.did}
case 'synced-accounts':
return {type: action.type, accountDid: action.syncedCurrentDid}
default:
return {type: action.type}
}
}
type Log =
| {
type: 'reducer:init'
state: State
state: RedactedSessionSnapshot
}
| {
type: 'reducer:call'
action: Action
prevState: State
nextState: State
action: RedactedAction
prevState: RedactedSessionSnapshot
nextState: RedactedSessionSnapshot
}
| {
type: 'method:start'
@@ -25,7 +144,7 @@ type Log =
| 'logout'
| 'resumeSession'
| 'removeAccount'
account?: SessionAccount
account?: RedactedAccount
}
| {
type: 'method:end'
@@ -35,32 +154,41 @@ type Log =
| 'logout'
| 'resumeSession'
| 'removeAccount'
account?: SessionAccount
account?: RedactedAccount
}
| {
type: 'persisted:broadcast'
data: Schema['session']
data: RedactedSessionSnapshot
}
| {
type: 'persisted:receive'
data: Schema['session']
data: RedactedSessionSnapshot
}
| {
type: 'agent:switch'
prevAgent: object
nextAgent: object
type: 'bundle:switch'
prevBundleId: string
nextBundleId: string
}
| {
type: 'agent:patch'
agent: object
prevSession: AtpSessionData | undefined
nextSession: AtpSessionData | undefined
/*
* Dev-only bundle-swap log. Bundles are identified by their opaque ids;
* the session snapshots record only identity and credential presence.
*/
type: 'bundle:patch'
bundleId: string
prevSession: RedactedSessionData | undefined
nextSession: RedactedSessionData | undefined
}
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
return function loggingWrapper(prevState: State, action: Action): State {
const nextState = reducer(prevState, action)
addSessionDebugLog({type: 'reducer:call', prevState, action, nextState})
addSessionDebugLog({
type: 'reducer:call',
prevState: redactState(prevState),
action: redactAction(action),
nextState: redactState(nextState),
})
return nextState
}
}
+32 -4
View File
@@ -1,10 +1,31 @@
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
import {IS_TEST_USER} from '#/lib/constants'
import {account as accountStorage} from '#/storage'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {readLabelers} from './agent-config'
import {type SessionAccount} from './types'
/**
* Cache an account's subscribed labeler DIDs. Called on every preferences
* fetch, so the cache is eventually consistent with the server.
*/
export function saveLabelers(did: string, value: string[]) {
accountStorage.set([did, 'labelers'], value)
}
/**
* Read the cached labeler DIDs for an account, or `undefined` if none have
* been cached yet (first session on this device) or the entry is unreadable.
*/
export function readLabelers(did: string): string[] | undefined {
try {
return accountStorage.get([did, 'labelers'])
} catch {
/* a corrupt entry fails JSON.parse inside Storage.get; treat as no cache */
return undefined
}
}
export function configureModerationForGuest() {
// This global mutation is *only* OK because this code is only relevant for testing.
// Don't add any other global behavior here!
@@ -12,7 +33,12 @@ export function configureModerationForGuest() {
configureAdditionalModerationAuthorities()
}
export async function configureModerationForAccount(
/**
* Configure global app labelers and the account's cached labeler
* subscriptions. Fully synchronous so session setup can apply labeler headers
* in the same tick, before any request goes out.
*/
export function configureModerationForAccount(
agent: AtpAgent,
account: SessionAccount,
) {
@@ -20,11 +46,12 @@ export async function configureModerationForAccount(
// Don't add any other global behavior here!
switchToBskyAppLabeler()
if (IS_TEST_USER(account.handle)) {
await trySwitchToTestAppLabeler(agent)
// Test accounts may briefly use the production authority while this resolves.
void trySwitchToTestAppLabeler(agent)
}
// The code below is actually relevant to production (and isn't global).
const labelerDids = await readLabelers(account.did).catch(_ => {})
const labelerDids = readLabelers(account.did)
if (labelerDids) {
agent.configureLabelersHeader(
labelerDids.filter(did => did !== BSKY_LABELER_DID),
@@ -41,6 +68,7 @@ function switchToBskyAppLabeler() {
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
}
/** Resolve and install the test environment's moderation authority. */
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
const did = (
await agent
+23
View File
@@ -0,0 +1,23 @@
import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events'
/*
* Captured once at module load so the wrapper below is immune to later
* monkey-patching of globalThis.fetch.
*/
const realFetch = globalThis.fetch
/**
* Fetch wrapper that reports network reachability to the app-wide event bus.
* Any resolved response (including HTTP errors) confirms the network is up; a
* thrown error (DNS failure, timeout, offline) reports it as lost.
*/
export const networkAwareFetch: typeof fetch = async (...args) => {
try {
const res = await realFetch(...args)
emitNetworkConfirmed()
return res
} catch (e) {
emitNetworkLost()
throw e
}
}
+60 -63
View File
@@ -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<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
}
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 {
+438
View File
@@ -0,0 +1,438 @@
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {logger} from '#/logger'
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
import {features} from '#/analytics'
import {
BskyAppAgent,
createPublicAgent,
PasswordSessionManager,
} from './bridge-agent'
import {addSessionErrorLog} from './logging'
import {configureModerationForAccount} from './moderation'
import {networkAwareFetch} from './network'
import {
isSessionExpired,
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-data'
import {type AtpSessionEvent, type SessionAccount} from './types'
export {networkAwareFetch} from './network'
export {
isSignupQueued,
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-data'
export type {AtpSessionEvent} from './types'
/**
* The service the bundle authenticated against.
*
* `PasswordSession`'s getters throw once the session is destroyed, so the read
* is guarded and falls back to the public service.
*/
function deriveServiceUrl(session: PasswordSession | null): URL {
return new URL(
session && !session.destroyed
? session.session.service
: PUBLIC_BSKY_SERVICE,
)
}
/** An `AtpAgent` bridged over one `PasswordSession`, the bundle's sole auth core. */
export type SessionBundle = {
session: PasswordSession
agent: BskyAppAgent
readonly service: URL
}
/**
* `PasswordSession` exposes no local (logout-free) destroy, so disposal is
* implemented by disabling its injected fetch and hooks. Keep that lifecycle
* state private and tied to bundle identity.
*/
const bundleKillSwitches = new WeakMap<SessionBundle, () => void>()
/**
* Register the lifecycle closure used by {@link disposeBundle}.
*
* Disposing also detaches the bridge agent from its session, so a stale
* bundle's `agent.session` / `agent.pdsUrl` read as `undefined` rather than
* serving tokens the app has stopped tracking.
*/
export function registerBundleKillSwitch(
bundle: SessionBundle,
kill: () => void,
) {
bundleKillSwitches.set(bundle, () => {
kill()
bundle.agent.dispose()
})
}
/**
* Wrap a session in the bridge agent.
*
* `storedPdsUrl` seeds {@link PasswordSessionManager}'s PDS routing so requests
* made before the first refresh delivers a didDoc still reach the right host.
* Once a didDoc arrives the manager prefers its endpoint.
*/
export function buildBundle(
session: PasswordSession,
storedPdsUrl?: string,
): SessionBundle {
const manager = new PasswordSessionManager(session, {
service: deriveServiceUrl(session).toString(),
pdsUrl: storedPdsUrl,
})
return {
session,
agent: new BskyAppAgent(manager),
get service() {
return deriveServiceUrl(session)
},
}
}
/**
* PasswordSession delivers `sessionData` before updating its live getter. The
* provider uses that payload for rotated tokens and expiry rescue.
*/
export type OnSessionChange = (
bundle: SessionBundle,
did: string,
event: AtpSessionEvent,
sessionData?: SessionData,
) => void
/**
* Hooks stay inert during initial session preparation. `kill()` disarms them
* and disables the injected fetch so a disposed session cannot refresh or
* dispatch.
*/
export function makeSessionHooks({
onSessionChange,
getBundle,
getDid,
}: {
onSessionChange: OnSessionChange
/** Deferred: hooks are created before the bundle exists. */
getBundle: () => SessionBundle
/** Deferred: hooks are created before the bundle exists. */
getDid: () => string
}) {
let armed = false
let killed = false
const dispatch = (event: AtpSessionEvent, sessionData?: SessionData) => {
if (!armed) {
return
}
/*
* A hook must never throw. PasswordSession awaits its hooks inside the
* assignment to its internal session promise, so a synchronous throw here
* leaves that promise permanently rejected: every later request fails, and
* because the session is never marked destroyed, disposeBundle cannot even
* see that the bundle is dead. The dispatch path reaches reducer side
* effects and event emitters, so treat it as capable of throwing.
*/
try {
const did = getDid()
onSessionChange(getBundle(), did, event, sessionData)
if (event !== 'update') {
addSessionErrorLog(did, event)
}
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: onSessionChange threw for a '${event}' 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),
}
}
/**
* Run the prepare tail shared by the asynchronous factories.
*
* Preparation does real network work, so it can both reject and - when a
* request gets a 401 and the session's own refresh then fails definitively -
* destroy the session underneath us.
*
* A session destroyed during preparation is fatal rather than recoverable. The
* hooks are still disarmed at that point, so the session's `expired` event was
* swallowed and nothing will ever tell the reducer to log the account out;
* returning the bundle anyway would leave the app looking signed in over a
* session that can only make unauthenticated requests. Failing instead matches
* what `CredentialSession.resumeSession` did on a revoked token, and every
* caller already handles a rejected factory. Checking `destroyed` first also
* keeps `PasswordSession`'s `Logged out` getter throw from escaping as the
* opaque rejection a caller would surface, so `snapshot` only ever runs against
* a live session.
*
* Both failure modes dispose: the bundle is fully built by this point, and a
* still-live session left behind would keep its refresh and dispatch paths
* alive with nothing tracking it. (Disposal is a no-op for the destroyed case,
* where the session already refuses to refresh and the bridge agent already
* reads as logged out - but the two paths are indistinguishable to the caller,
* so both go through it.)
*/
export async function finishPreparation<T>(
bundle: SessionBundle,
preparation: Promise<unknown>,
snapshot: () => T,
): Promise<T> {
try {
await preparation
if (bundle.session.destroyed) {
throw new Error('Session was revoked while it was being prepared')
}
return snapshot()
} catch (e) {
disposeBundle(bundle)
throw e
}
}
/**
* Resume a stored account into a {@link SessionBundle}. Expired sessions take a
* network resume; 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,
getBundle: () => bundle,
getDid: () => storedAccount.did,
})
let session: PasswordSession
const sessionData = sessionAccountToSessionData(storedAccount)
if (isSessionExpired(storedAccount)) {
/*
* The arm latch swallows resume's initial onUpdated event.
*
* There is deliberately no network retry here: `resume` rejects only when
* the session is definitively invalid, and it swallows everything else -
* a failed refresh reports through `onUpdateFailure` and resolves with the
* stale tokens. So an offline cold start now stays signed in with dead
* tokens (requests fail until connectivity returns) rather than throwing
* the way the old `CredentialSession.resumeSession` did, and retrying a
* definitive rejection would only repeat a request that cannot succeed.
*/
session = await 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})
/*
* The proxy header is applied after the PDS-targeting setup above, so those
* calls run without it.
*/
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
Promise.all([gates, aa]),
() =>
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,
getBundle: () => bundle,
getDid: () => 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})
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
Promise.all([gates, aa]),
() => 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,
getBundle: () => bundle,
getDid: () => 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)?.()
}
+107
View File
@@ -0,0 +1,107 @@
import {type AtpSessionData} from '@atproto/api'
import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web'
import {type SessionData} from '@atproto/lex-password-session'
import {jwtDecode} from 'jwt-decode'
import {BSKY_SERVICE} from '#/lib/constants'
import {isJwtExpired} from '#/lib/jwt'
import {hasProp} from '#/lib/type-guards'
import {type SessionAccount} from './types'
/** Whether an access token was issued for a queued (waitlisted) signup. */
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
/**
* Convert live `PasswordSession` session data into the persisted
* `SessionAccount` snapshot.
*
* The object literal's field order is load-bearing: the reducer's
* `JSON.stringify` fast path and the session test snapshots depend on
* byte-stable serialization. `service` and `pdsUrl` are normalized through
* `new URL().toString()` for a stable trailing slash.
*
* `pdsUrl` comes from the DID document or a pre-refresh stored value. It does
* not fall back to the login service.
*/
export function sessionDataToSessionAccount(
session: SessionData | null | undefined,
service: string,
storedPdsUrl?: string,
): SessionAccount | undefined {
if (!session) {
return undefined
}
const normalizedService = new URL(service).toString()
const didDocPdsUrl =
session.didDoc && isValidDidDoc(session.didDoc)
? getPdsEndpoint(session.didDoc)
: undefined
const pdsUrl = didDocPdsUrl ?? storedPdsUrl
return {
service: normalizedService,
did: session.did,
handle: session.handle,
email: session.email,
emailConfirmed: session.emailConfirmed || false,
emailAuthFactor: session.emailAuthFactor || false,
refreshJwt: session.refreshJwt,
accessJwt: session.accessJwt,
signupQueued: isSignupQueued(session.accessJwt),
active: session.active,
status: session.status,
pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined,
isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE),
}
}
/** Convert a persisted account into data suitable for `PasswordSession`. */
export function sessionAccountToSessionData(
account: SessionAccount,
): SessionData {
return {
accessJwt: account.accessJwt ?? '',
active: account.active ?? true,
did: account.did as SessionData['did'],
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle as SessionData['handle'],
refreshJwt: account.refreshJwt ?? '',
status: account.status,
service: account.service,
}
}
/** Convert a persisted account into data suitable for `AtpAgent`. */
export function sessionAccountToSession(
account: SessionAccount,
): AtpSessionData {
return {
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
accessJwt: account.accessJwt ?? '',
did: account.did,
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle,
refreshJwt: account.refreshJwt ?? '',
/**
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
*/
active: account.active ?? true,
status: account.status,
}
}
export function isSessionExpired(account: SessionAccount) {
return account.accessJwt ? isJwtExpired(account.accessJwt) : true
}
+6 -5
View File
@@ -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<void>
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<void>
}
+3 -23
View File
@@ -1,36 +1,16 @@
import AtpAgent from '@atproto/api'
import {jwtDecode} from 'jwt-decode'
import {isJwtExpired} from '#/lib/jwt'
import {hasProp} from '#/lib/type-guards'
import * as persisted from '#/state/persisted'
import {sessionAccountToSession} from './agent'
import {sessionAccountToSession} from './session-data'
import {type SessionAccount} from './types'
export {isSessionExpired, isSignupQueued} from './session-data'
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)
} else {
return true
}
}
/**
* Creates and attempted to resumeSession for every stored session.
* Intended to be used to send push token revokations just before logout.
+11
View File
@@ -108,4 +108,15 @@ export type Account = {
* account after a switch, until that account's preferences loaded.
*/
isBetaUser?: boolean
/**
* The account's subscribed labeler DIDs, cached from preferences so the
* `atproto-accept-labelers` header can be configured synchronously at
* session start, before preferences load. Eventually consistent: rewritten
* on every preferences fetch (see `saveLabelers` in
* `#/state/session/moderation`). Until the first fetch lands there is
* simply no cache entry and initial requests go out without per-account
* labeler headers.
*/
labelers?: string[]
}
+1 -1
View File
@@ -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,