Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2129885c6 | |||
| 7f6db6352d | |||
| b1a7cc7998 | |||
| 69101a9348 | |||
| 131d4815e6 | |||
| 517439abd6 | |||
| fb494881de | |||
| 6df3986c2b | |||
| 5ffee15c4f | |||
| 374e1ba3de | |||
| b96d85387d | |||
| 1e6106e865 | |||
| 3e42c659c9 | |||
| 9f381485dc | |||
| 21d67da9a9 | |||
| 1d279dc894 | |||
| abdf1abf4b | |||
| 9f40ba0a0b | |||
| 427b98e154 | |||
| 045f3965ec | |||
| e79d8e8e52 | |||
| 65faee2836 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1594
-1247
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.
|
||||
*/
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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.`,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?.()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user