[SDK] Rebuild the session core on PasswordSession behind an AtpAgent compat shim (#11343)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1456,11 +1456,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/state/session/agent.ts": {
|
|
||||||
"typescript/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/state/shell/color-mode.tsx": {
|
"src/state/shell/color-mode.tsx": {
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.39",
|
"@atproto/api": "0.20.39",
|
||||||
"@atproto/common-web": "0.5.7",
|
"@atproto/common-web": "0.5.7",
|
||||||
|
"@atproto/lex-client": "0.3.0",
|
||||||
|
"@atproto/lex-password-session": "0.1.8",
|
||||||
"@atproto/syntax": "0.7.2",
|
"@atproto/syntax": "0.7.2",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@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 {useEffect, useMemo, useState} from 'react'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
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'
|
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
|
||||||
|
|
||||||
export type AccountEmailState = {
|
export type AccountEmailState = {
|
||||||
@@ -12,24 +12,29 @@ export type AccountEmailState = {
|
|||||||
export const accountEmailStateQueryKey = ['accountEmailState'] as const
|
export const accountEmailStateQueryKey = ['accountEmailState'] as const
|
||||||
|
|
||||||
export function useAccountEmailState() {
|
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 {partialRefreshSession} = useSessionApi()
|
||||||
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
|
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
|
||||||
!!agent.session?.emailConfirmed,
|
!!currentAccount?.emailConfirmed,
|
||||||
)
|
)
|
||||||
const state: AccountEmailState = useMemo(
|
const state: AccountEmailState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isEmailVerified: !!agent.session?.emailConfirmed,
|
isEmailVerified: !!currentAccount?.emailConfirmed,
|
||||||
email2FAEnabled: !!agent.session?.emailAuthFactor,
|
email2FAEnabled: !!currentAccount?.emailAuthFactor,
|
||||||
}),
|
}),
|
||||||
[agent.session],
|
[currentAccount],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only here to refetch on focus, when necessary
|
* Only here to refetch on focus, when necessary
|
||||||
*/
|
*/
|
||||||
useQuery({
|
useQuery({
|
||||||
enabled: !!agent.session,
|
enabled: !!currentAccount,
|
||||||
/**
|
/**
|
||||||
* Only refetch if the email verification s incomplete.
|
* 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 {XRPCError} from '@atproto/api'
|
||||||
|
import {LexError} from '@atproto/lex-client'
|
||||||
import {t} from '@lingui/core/macro'
|
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 {
|
export function cleanError(e: unknown): string {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return ''
|
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
|
// oxlint-disable-next-line typescript/no-base-to-string
|
||||||
const str = typeof e === 'string' ? e : e.toString()
|
const str = typeof e === 'string' ? e : e.toString()
|
||||||
if (isNetworkError(str)) {
|
if (isNetworkError(str)) {
|
||||||
return t`Unable to connect. Please check your internet connection and try again.`
|
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 (
|
if (
|
||||||
str.includes('Upstream Failure') ||
|
str.includes('Upstream Failure') ||
|
||||||
|
str.includes('UpstreamFailure') ||
|
||||||
str.includes('NotEnoughResources') ||
|
str.includes('NotEnoughResources') ||
|
||||||
str.includes('pipethrough network error')
|
str.includes('pipethrough network error')
|
||||||
) {
|
) {
|
||||||
@@ -41,10 +75,7 @@ export function cleanError(e: unknown): string {
|
|||||||
if (str.includes('Unable to resolve handle')) {
|
if (str.includes('Unable to resolve handle')) {
|
||||||
return t`Unable to resolve handle`
|
return t`Unable to resolve handle`
|
||||||
}
|
}
|
||||||
if (str.startsWith('Error: ')) {
|
return toDisplayString(e, str)
|
||||||
return str.slice('Error: '.length)
|
|
||||||
}
|
|
||||||
return str
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NETWORK_ERRORS = [
|
const NETWORK_ERRORS = [
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {useRef, useState} from 'react'
|
import {useRef, useState} from 'react'
|
||||||
import {Keyboard, type TextInput, View} from 'react-native'
|
import {Keyboard, type TextInput, View} from 'react-native'
|
||||||
import {
|
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||||
ComAtprotoServerCreateSession,
|
import {LexAuthFactorError} from '@atproto/lex-password-session'
|
||||||
type ComAtprotoServerDescribeServer,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
||||||
@@ -142,10 +140,11 @@ export const LoginForm = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = String(err)
|
const errMsg = String(err)
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
if (
|
/*
|
||||||
err instanceof
|
* `LexAuthFactorError` is what `PasswordSession.login` throws when the
|
||||||
ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
|
* server demands an email 2FA token.
|
||||||
) {
|
*/
|
||||||
|
if (err instanceof LexAuthFactorError) {
|
||||||
setIsAuthFactorTokenNeeded(true)
|
setIsAuthFactorTokenNeeded(true)
|
||||||
} else {
|
} else {
|
||||||
onAttemptFailed()
|
onAttemptFailed()
|
||||||
|
|||||||
+32
-13
@@ -1,9 +1,7 @@
|
|||||||
import {createContext, useCallback, useContext} from 'react'
|
import {createContext, useCallback, useContext} from 'react'
|
||||||
import {LayoutAnimation} from 'react-native'
|
import {LayoutAnimation} from 'react-native'
|
||||||
import {
|
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||||
ComAtprotoServerCreateAccount,
|
import {XrpcResponseError} from '@atproto/lex-client'
|
||||||
type ComAtprotoServerDescribeServer,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import * as EmailValidator from 'email-validator'
|
import * as EmailValidator from 'email-validator'
|
||||||
|
|
||||||
@@ -260,14 +258,35 @@ export const useSignupContext = () => useContext(SignupContext)
|
|||||||
* failure is unexpected and should be reported to Sentry.
|
* failure is unexpected and should be reported to Sentry.
|
||||||
*/
|
*/
|
||||||
function classifyExpectedSignupError(e: unknown): string | undefined {
|
function classifyExpectedSignupError(e: unknown): string | undefined {
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidHandleError)
|
/*
|
||||||
return 'InvalidHandle'
|
* TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.HandleNotAvailableError)
|
* codes are compared as plain strings and a typo silently never matches.
|
||||||
return 'HandleNotAvailable'
|
* Once the generated lexicons land, replace this (and every multi-code error
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidPasswordError)
|
* site) with a shared helper that narrows against the method schema:
|
||||||
return 'InvalidPassword'
|
*
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.UnsupportedDomainError)
|
* function matchXrpcError<M extends Procedure | Query>(
|
||||||
return 'UnsupportedDomain'
|
* 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 */
|
/* the server sends no typed error for this case */
|
||||||
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
||||||
if (isNetworkError(e)) return 'NetworkError'
|
if (isNetworkError(e)) return 'NetworkError'
|
||||||
@@ -357,7 +376,7 @@ export function useSubmitSignup() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
let errMsg = e.toString()
|
let errMsg = e.toString()
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'setError',
|
type: 'setError',
|
||||||
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
|
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']
|
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>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Schema[K],
|
value: Schema[K],
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ export function get<K extends keyof Schema>(key: K): Schema[K] {
|
|||||||
}
|
}
|
||||||
get satisfies PersistedApi['get']
|
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
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import {type Schema} from './schema'
|
|||||||
export type PersistedApi = {
|
export type PersistedApi = {
|
||||||
init(): Promise<void>
|
init(): Promise<void>
|
||||||
get<K extends keyof Schema>(key: K): Schema[K]
|
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>
|
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
||||||
onUpdate<K extends keyof Schema>(
|
onUpdate<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '#/state/queries/preferences/types'
|
} from '#/state/queries/preferences/types'
|
||||||
import {createQueryKey} from '#/state/queries/util'
|
import {createQueryKey} from '#/state/queries/util'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {saveLabelers} from '#/state/session/agent-config'
|
import {saveLabelers} from '#/state/session/moderation'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -53,7 +53,7 @@ export function usePreferencesQuery() {
|
|||||||
const res = await agent.getPreferences()
|
const res = await agent.getPreferences()
|
||||||
|
|
||||||
// save to local storage to ensure there are labels on initial requests
|
// save to local storage to ensure there are labels on initial requests
|
||||||
void saveLabelers(
|
saveLabelers(
|
||||||
agent.did,
|
agent.did,
|
||||||
res.moderationPrefs.labelers.map(l => l.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
|
|
||||||
}
|
|
||||||
+5
-397
@@ -1,338 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Agent as BaseAgent,
|
Agent as BaseAgent,
|
||||||
type AppBskyActorProfile,
|
|
||||||
AtpAgent,
|
|
||||||
type AtprotoServiceType,
|
type AtprotoServiceType,
|
||||||
type AtpSessionData,
|
|
||||||
type AtpSessionEvent,
|
|
||||||
type Did,
|
type Did,
|
||||||
type Un$Typed,
|
|
||||||
} from '@atproto/api'
|
} 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 type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
||||||
|
|
||||||
export function createPublicAgent() {
|
/**
|
||||||
configureModerationForGuest() // Side effect but only relevant for tests
|
* A bare `Agent` that applies a service-proxy header on construction.
|
||||||
|
*
|
||||||
const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
* Used for the unauthenticated, service-specific calls that cannot go through
|
||||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
* the session agent (PDS detection, password reset, handle availability).
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Agent extends BaseAgent {
|
export class Agent extends BaseAgent {
|
||||||
constructor(
|
constructor(
|
||||||
proxyHeader: ProxyHeaderValue | null,
|
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
|
||||||
|
}
|
||||||
+331
-84
@@ -3,12 +3,14 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useInsertionEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} 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 * as persisted from '#/state/persisted'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -16,17 +18,28 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
|||||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
import {
|
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||||
agentToSessionAccount,
|
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||||
type BskyAppAgent,
|
|
||||||
createAgentAndCreateAccount,
|
|
||||||
createAgentAndLogin,
|
|
||||||
createAgentAndResume,
|
|
||||||
sessionAccountToSession,
|
|
||||||
} from './agent'
|
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||||
export {isSignupQueued} from './util'
|
import {
|
||||||
import {addSessionDebugLog} from './logging'
|
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'
|
export type {SessionAccount} from '#/state/session/types'
|
||||||
|
|
||||||
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||||
@@ -47,8 +60,11 @@ const StateContext = createContext<SessionStateContext>({
|
|||||||
})
|
})
|
||||||
StateContext.displayName = 'SessionStateContext'
|
StateContext.displayName = 'SessionStateContext'
|
||||||
|
|
||||||
const AgentContext = createContext<AtpAgent | null>(null)
|
/** Active account bundle, or the public bundle when logged out. */
|
||||||
AgentContext.displayName = 'SessionAgentContext'
|
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
BundleContext.displayName = 'SessionBundleContext'
|
||||||
|
|
||||||
const ApiContext = createContext<SessionApiContext>({
|
const ApiContext = createContext<SessionApiContext>({
|
||||||
createAccount: async () => {},
|
createAccount: async () => {},
|
||||||
@@ -68,7 +84,7 @@ class SessionStore {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// Careful: By the time this runs, `persisted` needs to already be filled.
|
// Careful: By the time this runs, `persisted` needs to already be filled.
|
||||||
const initialState = getInitialState(persisted.get('session').accounts)
|
const initialState = getInitialState(persisted.get('session').accounts)
|
||||||
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
addSessionDebugLog({type: 'reducer:init', state: redactState(initialState)})
|
||||||
this.state = initialState
|
this.state = initialState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +108,13 @@ class SessionStore {
|
|||||||
const persistedData = {
|
const persistedData = {
|
||||||
accounts: nextState.accounts,
|
accounts: nextState.accounts,
|
||||||
currentAccount: nextState.accounts.find(
|
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)
|
void persisted.write('session', persistedData)
|
||||||
}
|
}
|
||||||
this.listeners.forEach(listener => listener())
|
this.listeners.forEach(listener => listener())
|
||||||
@@ -110,15 +129,118 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const state = useSyncExternalStore(store.subscribe, store.getState)
|
const state = useSyncExternalStore(store.subscribe, store.getState)
|
||||||
const onboardingDispatch = useOnboardingDispatch()
|
const onboardingDispatch = useOnboardingDispatch()
|
||||||
|
|
||||||
const onAgentSessionChange = useCallback(
|
// Refresh-token generations that have already failed during expiry rescue.
|
||||||
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
const failedExpiryTokensRef = useRef<Map<string, Set<string>>>(new Map())
|
||||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
/*
|
||||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
* 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()
|
emitSessionDropped()
|
||||||
}
|
}
|
||||||
|
// Bundle identity prevents stale sessions from changing the active account.
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'received-agent-event',
|
type: 'received-session-event',
|
||||||
agent,
|
bundle,
|
||||||
refreshedAccount,
|
refreshedAccount,
|
||||||
accountDid,
|
accountDid,
|
||||||
sessionEvent,
|
sessionEvent,
|
||||||
@@ -126,48 +248,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
},
|
},
|
||||||
[store],
|
[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']>(
|
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
||||||
async (params, metrics) => {
|
async (params, metrics) => {
|
||||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
ax.metric('account:create:begin', {})
|
ax.metric('account:create:begin', {})
|
||||||
const {agent, account} = await createAgentAndCreateAccount(
|
const {bundle, account} = await createSessionBundleAndCreateAccount(
|
||||||
params,
|
params,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
// The factory returns an armed bundle, so a superseded signup must dispose it.
|
||||||
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
session: utils.accountToSessionMetadata(account),
|
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']>(
|
const login = useCallback<SessionApiContext['login']>(
|
||||||
async (params, logContext) => {
|
async (params, logContext) => {
|
||||||
addSessionDebugLog({type: 'method:start', method: 'login'})
|
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndLogin(
|
const {bundle, account} = await createSessionBundleAndLogin(
|
||||||
params,
|
params,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
// The factory returns an armed bundle, so a superseded login must dispose it.
|
||||||
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
@@ -175,9 +314,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{logContext, withPassword: true},
|
{logContext, withPassword: true},
|
||||||
{session: utils.accountToSessionMetadata(account)},
|
{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<
|
const logoutCurrentAccount = useCallback<
|
||||||
@@ -196,17 +339,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{
|
{
|
||||||
session: utils.accountToSessionMetadata(
|
session: utils.accountToSessionMetadata(
|
||||||
prevState.accounts.find(
|
prevState.accounts.find(
|
||||||
a => a.did === prevState.currentAgentState.did,
|
a => a.did === prevState.currentBundleState.did,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||||
if (prevState.currentAgentState.did) {
|
if (prevState.currentBundleState.did) {
|
||||||
clearAgeAssuranceServerDataForDid({
|
clearAgeAssuranceServerDataForDid({
|
||||||
did: prevState.currentAgentState.did,
|
did: prevState.currentBundleState.did,
|
||||||
})
|
})
|
||||||
void clearPersistedQueryStorage(prevState.currentAgentState.did)
|
void clearPersistedQueryStorage(prevState.currentBundleState.did)
|
||||||
}
|
}
|
||||||
// reset onboarding flow on logout
|
// reset onboarding flow on logout
|
||||||
onboardingDispatch({type: 'skip'})
|
onboardingDispatch({type: 'skip'})
|
||||||
@@ -230,7 +373,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{
|
{
|
||||||
session: utils.accountToSessionMetadata(
|
session: utils.accountToSessionMetadata(
|
||||||
prevState.accounts.find(
|
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({
|
addSessionDebugLog({
|
||||||
type: 'method:start',
|
type: 'method:start',
|
||||||
method: 'resumeSession',
|
method: 'resumeSession',
|
||||||
account: storedAccount,
|
account: redactAccount(storedAccount),
|
||||||
})
|
})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndResume(
|
const {bundle, account} = await createSessionBundleAndResume(
|
||||||
storedAccount,
|
storedAccount,
|
||||||
onAgentSessionChange,
|
onSessionChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
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
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newAgent: agent,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'resumeSession',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
if (isSwitchingAccounts) {
|
if (isSwitchingAccounts) {
|
||||||
// reset onboarding flow on switch account
|
// reset onboarding flow on switch account
|
||||||
onboardingDispatch({type: 'skip'})
|
onboardingDispatch({type: 'skip'})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
[store, onSessionChange, cancelPendingTask, onboardingDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
const partialRefreshSession = useCallback<
|
const partialRefreshSession = useCallback<
|
||||||
SessionApiContext['partialRefreshSession']
|
SessionApiContext['partialRefreshSession']
|
||||||
>(async () => {
|
>(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 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
|
if (signal.aborted) return
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'partial-refresh-session',
|
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: {
|
patch: {
|
||||||
emailConfirmed: data.emailConfirmed,
|
emailConfirmed: data.emailConfirmed,
|
||||||
emailAuthFactor: data.emailAuthFactor,
|
emailAuthFactor: data.emailAuthFactor,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [store, state, cancelPendingTask])
|
}, [store, cancelPendingTask])
|
||||||
|
|
||||||
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
||||||
account => {
|
account => {
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:start',
|
type: 'method:start',
|
||||||
method: 'removeAccount',
|
method: 'removeAccount',
|
||||||
account,
|
account: redactAccount(account),
|
||||||
})
|
})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'removed-account',
|
type: 'removed-account',
|
||||||
accountDid: account.did,
|
accountDid: account.did,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'removeAccount',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
clearAgeAssuranceServerDataForDid({did: account.did})
|
clearAgeAssuranceServerDataForDid({did: account.did})
|
||||||
},
|
},
|
||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask],
|
||||||
@@ -313,7 +489,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return persisted.onUpdate('session', nextSession => {
|
return persisted.onUpdate('session', nextSession => {
|
||||||
const synced = nextSession
|
const synced = nextSession
|
||||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
addSessionDebugLog({
|
||||||
|
type: 'persisted:receive',
|
||||||
|
data: redactPersistedSession(synced),
|
||||||
|
})
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'synced-accounts',
|
type: 'synced-accounts',
|
||||||
syncedAccounts: synced.accounts,
|
syncedAccounts: synced.accounts,
|
||||||
@@ -322,38 +501,91 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const syncedAccount = synced.accounts.find(
|
const syncedAccount = synced.accounts.find(
|
||||||
a => a.did === synced.currentAccount?.did,
|
a => a.did === synced.currentAccount?.did,
|
||||||
)
|
)
|
||||||
if (syncedAccount && syncedAccount.refreshJwt) {
|
|
||||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
|
||||||
/*
|
/*
|
||||||
* Web handling: if leader tab has switched to a diff account that is
|
* Cancel pending work when another tab logs out the account this tab
|
||||||
* stale, it will refresh the session before triggering the update to
|
* considers current. Do not cancel unrelated work between logged-out tabs.
|
||||||
* follower tabs. Follower tabs will therefore receive the fresh
|
|
||||||
* session. See APP-1960, or ask Eric.
|
|
||||||
*/
|
*/
|
||||||
|
const syncedDid = syncedAccount?.refreshJwt
|
||||||
|
? syncedAccount.did
|
||||||
|
: undefined
|
||||||
|
if (
|
||||||
|
syncedDid === undefined &&
|
||||||
|
state.currentBundleState.did !== undefined
|
||||||
|
) {
|
||||||
|
cancelPendingTask()
|
||||||
|
}
|
||||||
|
if (syncedAccount && syncedAccount.refreshJwt) {
|
||||||
|
if (syncedAccount.did !== state.currentBundleState.did) {
|
||||||
|
// The leader refreshes before broadcasting, so followers receive fresh tokens.
|
||||||
void resumeSession(syncedAccount)
|
void resumeSession(syncedAccount)
|
||||||
} else {
|
} else {
|
||||||
const agent = state.currentAgentState.agent as AtpAgent
|
/*
|
||||||
const prevSession = agent.session
|
* PasswordSession cannot be patched in place. Rebuild from the tokens
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
* the leader already refreshed, then dispose the previous bundle.
|
||||||
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
|
*/
|
||||||
|
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({
|
addSessionDebugLog({
|
||||||
type: 'agent:patch',
|
type: 'bundle:patch',
|
||||||
agent,
|
bundleId: getBundleId(newBundle),
|
||||||
prevSession,
|
prevSession: redactSessionData(
|
||||||
nextSession: agent.session,
|
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(
|
const stateContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
accounts: state.accounts,
|
accounts: state.accounts,
|
||||||
currentAccount: state.accounts.find(
|
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],
|
[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
|
// @ts-expect-error window type is not declared, debug only
|
||||||
// eslint-disable-next-line react-hooks/immutability
|
// 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 currentBundleRef = useRef(bundle)
|
||||||
const currentAgentRef = useRef(agent)
|
/*
|
||||||
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (currentAgentRef.current !== agent) {
|
if (currentBundleRef.current !== bundle) {
|
||||||
// Read the previous value and immediately advance the pointer.
|
const prevBundle = currentBundleRef.current
|
||||||
const prevAgent = currentAgentRef.current
|
currentBundleRef.current = bundle
|
||||||
currentAgentRef.current = agent
|
addSessionDebugLog({
|
||||||
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
type: 'bundle:switch',
|
||||||
// We never reuse agents so let's fully neutralize the previous one.
|
prevBundleId: getBundleId(prevBundle),
|
||||||
// This ensures it won't try to consume any refresh tokens.
|
nextBundleId: getBundleId(bundle),
|
||||||
prevAgent.dispose()
|
})
|
||||||
|
// Replaced bundles must never consume another refresh token.
|
||||||
|
disposeBundle(prevBundle)
|
||||||
}
|
}
|
||||||
}, [agent])
|
}, [bundle])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentContext.Provider value={agent}>
|
<BundleContext.Provider value={bundle}>
|
||||||
<StateContext.Provider value={stateContext}>
|
<StateContext.Provider value={stateContext}>
|
||||||
<ApiContext.Provider value={api}>
|
<ApiContext.Provider value={api}>
|
||||||
<AnalyticsContext
|
<AnalyticsContext
|
||||||
@@ -411,7 +655,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
</AnalyticsContext>
|
</AnalyticsContext>
|
||||||
</ApiContext.Provider>
|
</ApiContext.Provider>
|
||||||
</StateContext.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 {
|
export function useAgent(): AtpAgent {
|
||||||
const agent = useContext(AgentContext)
|
const bundle = useContext(BundleContext)
|
||||||
if (!agent) {
|
if (!bundle) {
|
||||||
throw Error('useAgent() must be below <SessionProvider>.')
|
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 Schema} from '../persisted'
|
||||||
import {type Action, type State} from './reducer'
|
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
|
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 Log =
|
||||||
| {
|
| {
|
||||||
type: 'reducer:init'
|
type: 'reducer:init'
|
||||||
state: State
|
state: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'reducer:call'
|
type: 'reducer:call'
|
||||||
action: Action
|
action: RedactedAction
|
||||||
prevState: State
|
prevState: RedactedSessionSnapshot
|
||||||
nextState: State
|
nextState: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'method:start'
|
type: 'method:start'
|
||||||
@@ -25,7 +144,7 @@ type Log =
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'resumeSession'
|
| 'resumeSession'
|
||||||
| 'removeAccount'
|
| 'removeAccount'
|
||||||
account?: SessionAccount
|
account?: RedactedAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'method:end'
|
type: 'method:end'
|
||||||
@@ -35,32 +154,41 @@ type Log =
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'resumeSession'
|
| 'resumeSession'
|
||||||
| 'removeAccount'
|
| 'removeAccount'
|
||||||
account?: SessionAccount
|
account?: RedactedAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:broadcast'
|
type: 'persisted:broadcast'
|
||||||
data: Schema['session']
|
data: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:receive'
|
type: 'persisted:receive'
|
||||||
data: Schema['session']
|
data: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent:switch'
|
type: 'bundle:switch'
|
||||||
prevAgent: object
|
prevBundleId: string
|
||||||
nextAgent: object
|
nextBundleId: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'agent:patch'
|
/*
|
||||||
agent: object
|
* Dev-only bundle-swap log. Bundles are identified by their opaque ids;
|
||||||
prevSession: AtpSessionData | undefined
|
* the session snapshots record only identity and credential presence.
|
||||||
nextSession: AtpSessionData | undefined
|
*/
|
||||||
|
type: 'bundle:patch'
|
||||||
|
bundleId: string
|
||||||
|
prevSession: RedactedSessionData | undefined
|
||||||
|
nextSession: RedactedSessionData | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||||
return function loggingWrapper(prevState: State, action: Action): State {
|
return function loggingWrapper(prevState: State, action: Action): State {
|
||||||
const nextState = reducer(prevState, action)
|
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
|
return nextState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||||
|
|
||||||
import {IS_TEST_USER} from '#/lib/constants'
|
import {IS_TEST_USER} from '#/lib/constants'
|
||||||
|
import {account as accountStorage} from '#/storage'
|
||||||
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
|
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
|
||||||
import {readLabelers} from './agent-config'
|
|
||||||
import {type SessionAccount} from './types'
|
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() {
|
export function configureModerationForGuest() {
|
||||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||||
// Don't add any other global behavior here!
|
// Don't add any other global behavior here!
|
||||||
@@ -12,7 +33,12 @@ export function configureModerationForGuest() {
|
|||||||
configureAdditionalModerationAuthorities()
|
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,
|
agent: AtpAgent,
|
||||||
account: SessionAccount,
|
account: SessionAccount,
|
||||||
) {
|
) {
|
||||||
@@ -20,11 +46,12 @@ export async function configureModerationForAccount(
|
|||||||
// Don't add any other global behavior here!
|
// Don't add any other global behavior here!
|
||||||
switchToBskyAppLabeler()
|
switchToBskyAppLabeler()
|
||||||
if (IS_TEST_USER(account.handle)) {
|
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).
|
// 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) {
|
if (labelerDids) {
|
||||||
agent.configureLabelersHeader(
|
agent.configureLabelersHeader(
|
||||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||||
@@ -41,6 +68,7 @@ function switchToBskyAppLabeler() {
|
|||||||
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve and install the test environment's moderation authority. */
|
||||||
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
|
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
|
||||||
const did = (
|
const did = (
|
||||||
await agent
|
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 {unregisterPushToken} from '#/lib/notifications/notifications'
|
||||||
import {logger} from '#/lib/notifications/util'
|
import {logger} from '#/lib/notifications/util'
|
||||||
import {createPublicAgent} from './agent'
|
|
||||||
import {wrapSessionReducerForLogging} from './logging'
|
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'
|
import {createTemporaryAgentsAndResume} from './util'
|
||||||
|
|
||||||
// A hack so that the reducer can't read anything from the agent.
|
// Keep session internals outside the reducer's static view of a bundle.
|
||||||
// From the reducer's point of view, it should be a completely opaque object.
|
type OpaqueSessionBundle = {
|
||||||
type OpaqueBskyAgent = {
|
|
||||||
readonly service: URL
|
readonly service: URL
|
||||||
readonly api: unknown
|
|
||||||
readonly app: unknown
|
|
||||||
readonly com: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentState = {
|
type BundleState = {
|
||||||
readonly agent: OpaqueBskyAgent
|
readonly bundle: OpaqueSessionBundle
|
||||||
readonly did: string | undefined
|
readonly did: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
readonly accounts: SessionAccount[]
|
readonly accounts: SessionAccount[]
|
||||||
readonly currentAgentState: AgentState
|
readonly currentBundleState: BundleState
|
||||||
needsPersist: boolean // Mutated in an effect.
|
needsPersist: boolean // Cleared after persistence is scheduled.
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| {
|
| {
|
||||||
type: 'received-agent-event'
|
type: 'received-session-event'
|
||||||
agent: OpaqueBskyAgent
|
bundle: OpaqueSessionBundle
|
||||||
accountDid: string
|
accountDid: string
|
||||||
refreshedAccount: SessionAccount | undefined
|
refreshedAccount: SessionAccount | undefined
|
||||||
sessionEvent: AtpSessionEvent
|
sessionEvent: AtpSessionEvent
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'switched-to-account'
|
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
|
newAccount: SessionAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -61,9 +61,9 @@ export type Action =
|
|||||||
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPublicAgentState(): AgentState {
|
function createPublicBundleState(): BundleState {
|
||||||
return {
|
return {
|
||||||
agent: createPublicAgent(),
|
bundle: createPublicSessionBundle(),
|
||||||
did: undefined,
|
did: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,22 +71,20 @@ function createPublicAgentState(): AgentState {
|
|||||||
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
||||||
return {
|
return {
|
||||||
accounts: persistedAccounts,
|
accounts: persistedAccounts,
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: false,
|
needsPersist: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reducer = (state: State, action: Action): State => {
|
let reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'received-agent-event': {
|
case 'received-session-event': {
|
||||||
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
const {bundle, accountDid, refreshedAccount, sessionEvent} = action
|
||||||
if (
|
if (bundle !== state.currentBundleState.bundle) {
|
||||||
refreshedAccount === undefined &&
|
/*
|
||||||
agent !== state.currentAgentState.agent
|
* Stale bundles must neither log out the current account nor restore
|
||||||
) {
|
* tokens after logout or an account switch.
|
||||||
// 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.
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
if (sessionEvent === 'network-error') {
|
if (sessionEvent === 'network-error') {
|
||||||
@@ -98,7 +96,6 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
!existingAccount ||
|
!existingAccount ||
|
||||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||||
) {
|
) {
|
||||||
// Fast path without a state update.
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -118,26 +115,40 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
currentAgentState: refreshedAccount
|
currentBundleState: refreshedAccount
|
||||||
? state.currentAgentState
|
? state.currentBundleState
|
||||||
: createPublicAgentState(), // Log out if expired.
|
: createPublicBundleState(), // Log out if expired.
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'switched-to-account': {
|
case 'switched-to-account': {
|
||||||
const {newAccount, newAgent} = action
|
const {newAccount, newBundle} = action
|
||||||
return {
|
return {
|
||||||
accounts: [
|
accounts: [
|
||||||
newAccount,
|
newAccount,
|
||||||
...state.accounts.filter(a => a.did !== newAccount.did),
|
...state.accounts.filter(a => a.did !== newAccount.did),
|
||||||
],
|
],
|
||||||
currentAgentState: {
|
currentBundleState: {
|
||||||
did: newAccount.did,
|
did: newAccount.did,
|
||||||
agent: newAgent,
|
bundle: newBundle,
|
||||||
},
|
},
|
||||||
needsPersist: true,
|
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': {
|
case 'removed-account': {
|
||||||
const {accountDid} = action
|
const {accountDid} = action
|
||||||
|
|
||||||
@@ -159,16 +170,16 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: state.accounts.filter(a => a.did !== accountDid),
|
accounts: state.accounts.filter(a => a.did !== accountDid),
|
||||||
currentAgentState:
|
currentBundleState:
|
||||||
state.currentAgentState.did === accountDid
|
state.currentBundleState.did === accountDid
|
||||||
? createPublicAgentState() // Log out if removing the current one.
|
? createPublicBundleState() // Log out if removing the current one.
|
||||||
: state.currentAgentState,
|
: state.currentBundleState,
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'logged-out-current-account': {
|
case 'logged-out-current-account': {
|
||||||
const {currentAgentState} = state
|
const {currentBundleState} = state
|
||||||
const accountDid = currentAgentState.did
|
const accountDid = currentBundleState.did
|
||||||
// side effect
|
// side effect
|
||||||
const account = state.accounts.find(a => a.did === accountDid)
|
const account = state.accounts.find(a => a.did === accountDid)
|
||||||
if (account && accountDid) {
|
if (account && accountDid) {
|
||||||
@@ -195,7 +206,7 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
}
|
}
|
||||||
: a,
|
: a,
|
||||||
),
|
),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +227,7 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
refreshJwt: undefined,
|
refreshJwt: undefined,
|
||||||
accessJwt: undefined,
|
accessJwt: undefined,
|
||||||
})),
|
})),
|
||||||
currentAgentState: createPublicAgentState(),
|
currentBundleState: createPublicBundleState(),
|
||||||
needsPersist: true,
|
needsPersist: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,33 +235,19 @@ let reducer = (state: State, action: Action): State => {
|
|||||||
const {syncedAccounts, syncedCurrentDid} = action
|
const {syncedAccounts, syncedCurrentDid} = action
|
||||||
return {
|
return {
|
||||||
accounts: syncedAccounts,
|
accounts: syncedAccounts,
|
||||||
currentAgentState:
|
currentBundleState:
|
||||||
syncedCurrentDid === state.currentAgentState.did
|
syncedCurrentDid === state.currentBundleState.did
|
||||||
? state.currentAgentState
|
? state.currentBundleState
|
||||||
: createPublicAgentState(), // Log out if different user.
|
: createPublicBundleState(), // Log out if different user.
|
||||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'partial-refresh-session': {
|
case 'partial-refresh-session': {
|
||||||
const {accountDid, patch} = action
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentAgentState: {
|
|
||||||
...state.currentAgentState,
|
|
||||||
agent,
|
|
||||||
},
|
|
||||||
accounts: state.accounts.map(a => {
|
accounts: state.accounts.map(a => {
|
||||||
if (a.did === accountDid) {
|
if (a.did === accountDid) {
|
||||||
return {
|
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
|
export type SessionAccount = PersistedAccount
|
||||||
|
|
||||||
|
/** Session-change events understood by the reducer and logging hooks. */
|
||||||
|
export type AtpSessionEvent = 'update' | 'expired' | 'network-error'
|
||||||
|
|
||||||
export type SessionStateContext = {
|
export type SessionStateContext = {
|
||||||
accounts: SessionAccount[]
|
accounts: SessionAccount[]
|
||||||
currentAccount: SessionAccount | undefined
|
currentAccount: SessionAccount | undefined
|
||||||
@@ -44,11 +47,9 @@ export type SessionApiContext = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
removeAccount: (account: SessionAccount) => void
|
removeAccount: (account: SessionAccount) => void
|
||||||
/**
|
/**
|
||||||
* Calls `getSession` and updates select fields on the current account and
|
* Calls `getSession` and patches the email fields of the current account.
|
||||||
* `BskyAgent`. This is an alternative to `resumeSession`, which updates
|
* Unlike `resumeSession`, this does not rotate tokens or rebuild the session,
|
||||||
* current account/agent using the `persistSessionHandler`, but is more load
|
* so it produces no session-change side effects.
|
||||||
* bearing. This patches in updates without causing any side effects via
|
|
||||||
* `persistSessionHandler`.
|
|
||||||
*/
|
*/
|
||||||
partialRefreshSession: () => Promise<void>
|
partialRefreshSession: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
import AtpAgent from '@atproto/api'
|
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 * as persisted from '#/state/persisted'
|
||||||
import {sessionAccountToSession} from './agent'
|
import {sessionAccountToSession} from './session-data'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
|
|
||||||
|
export {isSessionExpired, isSignupQueued} from './session-data'
|
||||||
|
|
||||||
export function readLastActiveAccount() {
|
export function readLastActiveAccount() {
|
||||||
const {currentAccount, accounts} = persisted.get('session')
|
const {currentAccount, accounts} = persisted.get('session')
|
||||||
return accounts.find(a => a.did === currentAccount?.did)
|
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.
|
* Creates and attempted to resumeSession for every stored session.
|
||||||
* Intended to be used to send push token revokations just before logout.
|
* 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.
|
* account after a switch, until that account's preferences loaded.
|
||||||
*/
|
*/
|
||||||
isBetaUser?: boolean
|
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 {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||||
import {createPublicAgent} from '#/state/session/agent'
|
import {createPublicAgent} from '#/state/session/bridge-agent'
|
||||||
import {
|
import {
|
||||||
type ComposerState,
|
type ComposerState,
|
||||||
type EmbedDraft,
|
type EmbedDraft,
|
||||||
|
|||||||
Reference in New Issue
Block a user