hold the three clients in the bundle and delete the bridge agent
The bundle becomes `{session, appviewClient, pdsClient, chatClient, service}`
and the hooks read those fields directly, so `useAgent` and the three
`agentTo*Client` memo maps go with the agent. The WeakMaps existed only because
clients were derived from a long-lived agent; a bundle that HOLDS its clients
gets identity stability for free.
The clients are now the sole producers of their own headers, which is what lets
`bridge-agent.ts` (442 lines) and `agent.ts` go:
- the appview client passes BLUESKY_PROXY_HEADER as its `service`, so it emits
atproto-proxy itself rather than inheriting it from a configureProxy call
sequenced after the PDS-targeting setup;
- labeler subscriptions go on the appview instance via `setLabelers`, and the
global `;redact` authorities come from the `Client` static alone. Both halves
of the double-emit hazard the previous slice documented are closed by there
being one producer instead of two, so the appview client no longer suppresses
`appLabelers` - only the PDS and chat clients do, because those services take
no moderation authorities at all;
- `configureGlobalAppLabelers` drops its `AtpAgent.configure` half.
PDS routing for the pre-didDoc window is `routeSessionToPds`, a 10-line shim
replacing the manager's dispatchUrl/extractPdsUrl/identity-cache apparatus.
`PasswordSession` resolves each request against `extractPdsUrl(didDoc) ??
service`, so an entryway account with no didDoc yet - the synchronous resume
fast path, i.e. the common cold start - would send every request to the
entryway. The shim absolutizes against the stored url first, which survives
`new URL(path, base)` untouched. It pins that url for the bundle's lifetime
where the manager would have preferred a later didDoc endpoint; that only
differs if the account's PDS moved, and the next cold start pins the new one.
Four cases in clients-test cover this, including the two the old suite could
not express (pinned-vs-didDoc precedence, and didDoc routing with nothing
pinned).
`createPublicSessionBundle` now runs `configureModerationForGuest` itself. That
is a behavior fix, not a refactor: it populates the `Client.appLabelers` static
the public appview client reads, and with no agent left to stamp the header a
logged-out read would otherwise carry no moderation authorities.
finishPreparation, the kill-switch disposal, the redacted logging, refreshSession
and the expiry-rescue path are unchanged - only how the bundle is constructed
moved. Disposal is now just the hook kill: the clients hold no state, and every
request they make goes through the session's injected fetch.
bridge-agent-test.ts is deleted with its subject. clients-test.ts is rewritten
against session-built clients, keeping every header canary (exactly-once on the
appview, none on pds/chat, exact proxy values, the throwing client's cause
chain). The three provider suites and session-core-test are adapted in place -
their constructions change, their assertions do not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import {type Insets, Platform} from 'react-native'
|
||||
import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {type Service} from '@atproto/lex'
|
||||
|
||||
import {type ProxyHeaderValue} from '#/state/session/agent'
|
||||
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env'
|
||||
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
@@ -241,7 +240,7 @@ export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always
|
||||
export const BLUESKY_PROXY_HEADER = {
|
||||
value: `${BLUESKY_PROXY_DID}#bsky_appview`,
|
||||
get() {
|
||||
return this.value as ProxyHeaderValue
|
||||
return this.value as Service
|
||||
},
|
||||
set(value: string) {
|
||||
this.value = value
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
import {useAgent, usePdsClient} from '#/state/session'
|
||||
import {useAppviewClient, usePdsClient} from '#/state/session'
|
||||
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
||||
@@ -58,7 +58,7 @@ export const preferencesQueryKey = createQueryKey(
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
const client = usePdsClient()
|
||||
const agent = useAgent()
|
||||
const appviewClient = useAppviewClient()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const query = useQuery({
|
||||
@@ -86,12 +86,12 @@ export function usePreferencesQuery() {
|
||||
* from a labeler would not affect server-attached labels until the
|
||||
* session bundle was rebuilt.
|
||||
*
|
||||
* `applyLabelersToClient` writes to the agent, which is what stamps the
|
||||
* header on the requests the wrapping appview client issues, and it
|
||||
* drops the Bluesky moderation DID so the globally redacted authority is
|
||||
* not also listed unredacted.
|
||||
* The subscriptions go on the appview client, which is what stamps
|
||||
* `atproto-accept-labelers` on its own requests. The Bluesky moderation
|
||||
* DID is dropped so the globally redacted authority is not also listed
|
||||
* unredacted.
|
||||
*/
|
||||
applyLabelersToClient(agent, labelerDids)
|
||||
applyLabelersToClient(appviewClient, labelerDids)
|
||||
|
||||
/*
|
||||
* `BskyPreferences` is now the sdk's own type, so the assembled
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -13,26 +13,29 @@ jest.mock('jwt-decode', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import {CHAT_PROXY_SERVICE} from '#/lib/constants'
|
||||
import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants'
|
||||
import {app, chat, com} from '#/lexicons'
|
||||
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
|
||||
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||
import {
|
||||
agentToAppviewClient,
|
||||
agentToChatClient,
|
||||
agentToPdsClient,
|
||||
buildAppviewClient,
|
||||
buildChatClient,
|
||||
buildPdsClient,
|
||||
getUnauthenticatedThrowingClient,
|
||||
NotAuthenticatedError,
|
||||
routeSessionToPds,
|
||||
} from '../clients'
|
||||
import {sessionAccountToSessionData} from '../session-data'
|
||||
import {
|
||||
asFetch,
|
||||
DID,
|
||||
DIDDOC_PDS_HOST,
|
||||
HANDLE,
|
||||
json,
|
||||
makeAccount,
|
||||
makeDidDoc,
|
||||
makeMockFetch,
|
||||
type MockFetch,
|
||||
PDS_HOST,
|
||||
SERVICE,
|
||||
urlsOf,
|
||||
} from './mock-fetch'
|
||||
@@ -49,25 +52,16 @@ function makeProfileFetch(): MockFetch {
|
||||
})
|
||||
}
|
||||
|
||||
/** An authenticated agent whose whole network path is the mock fetch. */
|
||||
function setup(fetchMock: MockFetch = makeProfileFetch()) {
|
||||
/** A live `PasswordSession` whose whole network path is the mock fetch. */
|
||||
function makeSession(fetchMock: MockFetch, didDocPdsUrl?: string) {
|
||||
const account = makeAccount()
|
||||
const inner = new PasswordSession(sessionAccountToSessionData(account), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
const manager = new PasswordSessionManager(inner, {
|
||||
service: account.service,
|
||||
})
|
||||
manager.setFetch(asFetch(fetchMock))
|
||||
const agent = new BskyAppAgent(manager)
|
||||
return {agent, fetchMock}
|
||||
}
|
||||
|
||||
/** A logged-out agent whose whole network path is the mock fetch. */
|
||||
function setupPublic(fetchMock: MockFetch = makeProfileFetch()) {
|
||||
const manager = new PasswordSessionManager(null, {service: SERVICE})
|
||||
manager.setFetch(asFetch(fetchMock))
|
||||
return {agent: new BskyAppAgent(manager), fetchMock}
|
||||
return new PasswordSession(
|
||||
{
|
||||
...sessionAccountToSessionData(account),
|
||||
...(didDocPdsUrl ? {didDoc: makeDidDoc(didDocPdsUrl)} : {}),
|
||||
},
|
||||
{fetch: asFetch(fetchMock)},
|
||||
)
|
||||
}
|
||||
|
||||
/** The `init` a mock fetch was called with for a given nsid. */
|
||||
@@ -79,84 +73,56 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined {
|
||||
return call?.[1]
|
||||
}
|
||||
|
||||
describe('agentToAppviewClient', () => {
|
||||
/** The headers a mock fetch was called with for a given nsid. */
|
||||
function headersFor(mock: MockFetch, nsid: string): Headers {
|
||||
return new Headers(initFor(mock, nsid)?.headers)
|
||||
}
|
||||
|
||||
describe('buildAppviewClient', () => {
|
||||
let fetchMock: MockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = makeProfileFetch()
|
||||
configureGlobalAppLabelers([])
|
||||
})
|
||||
|
||||
it('memoizes one client per agent', () => {
|
||||
const {agent: agentA} = setup(fetchMock)
|
||||
const {agent: agentB} = setup(fetchMock)
|
||||
|
||||
const clientA1 = agentToAppviewClient(agentA)
|
||||
const clientA2 = agentToAppviewClient(agentA)
|
||||
const clientB = agentToAppviewClient(agentB)
|
||||
|
||||
expect(clientA1).toBeInstanceOf(Client)
|
||||
expect(clientA1).toBe(clientA2)
|
||||
expect(clientA1).not.toBe(clientB)
|
||||
it('passes through the session did', () => {
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
expect(client).toBeInstanceOf(Client)
|
||||
expect(client.did).toBe(DID)
|
||||
})
|
||||
|
||||
it('passes through the agent did', () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
expect(agentToAppviewClient(agent).did).toBe(DID)
|
||||
})
|
||||
it('routes client.call through the session to the network', async () => {
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
|
||||
it('reflects an undefined did on a logged-out agent', () => {
|
||||
const {agent} = setupPublic(fetchMock)
|
||||
expect(agentToAppviewClient(agent).did).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes client.call through the agent to the network', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
const body = await agentToAppviewClient(agent).call(
|
||||
app.bsky.actor.getProfile,
|
||||
{
|
||||
actor: HANDLE,
|
||||
},
|
||||
)
|
||||
const body = await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||
|
||||
expect(body.handle).toBe(HANDLE)
|
||||
const call = fetchMock.mock.calls.find(c => {
|
||||
const url = c[0] instanceof URL ? c[0].href : String(c[0])
|
||||
return url.includes('/xrpc/app.bsky.actor.getProfile')
|
||||
})
|
||||
expect(call).toBeDefined()
|
||||
const url = call![0] instanceof URL ? call![0].href : String(call![0])
|
||||
expect(url).toContain(`actor=${HANDLE}`)
|
||||
expect(urlsOf(fetchMock).join()).toContain(`actor=${HANDLE}`)
|
||||
})
|
||||
|
||||
it('emits the agent proxy header', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
||||
it('emits the appview proxy header', async () => {
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
|
||||
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||
actor: HANDLE,
|
||||
})
|
||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
expect(new Headers(init?.headers).get('atproto-proxy')).toBe(
|
||||
'did:web:api.bsky.app#bsky_appview',
|
||||
expect(
|
||||
headersFor(fetchMock, 'app.bsky.actor.getProfile').get('atproto-proxy'),
|
||||
).toBe(BLUESKY_PROXY_HEADER.get())
|
||||
})
|
||||
|
||||
it('emits an account subscription exactly once', async () => {
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
client.setLabelers(['did:plc:labeler'])
|
||||
|
||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||
|
||||
const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
|
||||
'atproto-accept-labelers',
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the agent labeler header exactly once', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||
|
||||
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||
actor: HANDLE,
|
||||
})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
|
||||
expect(labelers).toContain('did:plc:labeler')
|
||||
/*
|
||||
* The client contributes no labelers of its own, so the agent's single
|
||||
* entry must not be duplicated.
|
||||
* The client is the only producer of this header now, so a duplicate would
|
||||
* mean lex itself emitted the same DID twice.
|
||||
*/
|
||||
const entries = labelers!
|
||||
.split(',')
|
||||
@@ -164,25 +130,21 @@ describe('agentToAppviewClient', () => {
|
||||
expect(entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate a global app labeler set on both statics', async () => {
|
||||
it('emits a global app labeler once, redacted', async () => {
|
||||
/*
|
||||
* `configureGlobalAppLabelers` populates the agent AND the lex `Client`
|
||||
* static, because clients built without a wrapped agent read only the
|
||||
* latter. On this path both producers are in play for the same request, and
|
||||
* neither dedupes against the other - the agent joins its list with the
|
||||
* existing header string while lex collects into a `Set` keyed on the
|
||||
* `;redact`-suffixed value. The appview client suppresses its `appLabelers`
|
||||
* so exactly one producer contributes.
|
||||
* The global static is the ONLY producer of the redacted authorities - no
|
||||
* agent stamps them any more - and lex suffixes them with `;redact`. An
|
||||
* account subscription that also listed the same DID would produce a second,
|
||||
* non-redacting entry, which is what `applyLabelersToClient` filters against.
|
||||
*/
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
const {agent} = setup(fetchMock)
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
|
||||
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||
actor: HANDLE,
|
||||
})
|
||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
|
||||
const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
|
||||
'atproto-accept-labelers',
|
||||
)
|
||||
const entries = labelers!
|
||||
.split(',')
|
||||
.map(l => l.trim())
|
||||
@@ -191,100 +153,69 @@ describe('agentToAppviewClient', () => {
|
||||
})
|
||||
|
||||
it('sends the session access token', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||
actor: HANDLE,
|
||||
})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe(
|
||||
'Bearer access-jwt',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to unauthenticated requests once the agent is disposed', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
const client = agentToAppviewClient(agent)
|
||||
agent.dispose()
|
||||
const client = buildAppviewClient(makeSession(fetchMock))
|
||||
|
||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||
|
||||
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
|
||||
expect(new Headers(init?.headers).has('authorization')).toBe(false)
|
||||
expect(client.did).toBeUndefined()
|
||||
expect(
|
||||
headersFor(fetchMock, 'app.bsky.actor.getProfile').get('authorization'),
|
||||
).toBe('Bearer access-jwt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentToPdsClient', () => {
|
||||
describe('buildPdsClient', () => {
|
||||
let fetchMock: MockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = makeProfileFetch()
|
||||
configureGlobalAppLabelers([])
|
||||
})
|
||||
|
||||
it('memoizes one client per agent', () => {
|
||||
const {agent: agentA} = setup(fetchMock)
|
||||
const {agent: agentB} = setup(fetchMock)
|
||||
|
||||
const clientA1 = agentToPdsClient(agentA)
|
||||
const clientA2 = agentToPdsClient(agentA)
|
||||
|
||||
expect(clientA1).toBeInstanceOf(Client)
|
||||
expect(clientA1).toBe(clientA2)
|
||||
expect(clientA1).not.toBe(agentToPdsClient(agentB))
|
||||
})
|
||||
|
||||
it('is a distinct client from the appview client for the same agent', () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
expect(agentToPdsClient(agent)).not.toBe(agentToAppviewClient(agent))
|
||||
})
|
||||
|
||||
it('passes through the agent did', () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
expect(agentToPdsClient(agent).did).toBe(DID)
|
||||
it('is a distinct client from the appview client over the same session', () => {
|
||||
const session = makeSession(fetchMock)
|
||||
expect(buildPdsClient(session)).not.toBe(buildAppviewClient(session))
|
||||
})
|
||||
|
||||
it('sends the session access token', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
|
||||
|
||||
const init = initFor(fetchMock, 'com.atproto.server.getSession')
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe(
|
||||
'Bearer access-jwt',
|
||||
await buildPdsClient(makeSession(fetchMock)).call(
|
||||
com.atproto.server.getSession,
|
||||
{},
|
||||
)
|
||||
|
||||
expect(
|
||||
headersFor(fetchMock, 'com.atproto.server.getSession').get(
|
||||
'authorization',
|
||||
),
|
||||
).toBe('Bearer access-jwt')
|
||||
})
|
||||
|
||||
it('emits neither the proxy nor the labeler header the agent is configured with', async () => {
|
||||
it('emits neither the proxy nor any labeler header', async () => {
|
||||
/*
|
||||
* The load-bearing difference from the appview client: this client wraps the
|
||||
* session manager, below the agent layer that sets both headers, so a
|
||||
* request reaches the account's PDS instead of being proxied onward.
|
||||
* The load-bearing difference from the appview client: a PDS request must
|
||||
* reach the account host itself rather than being proxied onward, and it is
|
||||
* not an appview read, so it carries no moderation authorities either.
|
||||
*/
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||
/* nor the global authorities: a PDS call is not an appview read */
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
|
||||
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
|
||||
|
||||
const headers = new Headers(
|
||||
initFor(fetchMock, 'com.atproto.server.getSession')?.headers,
|
||||
await buildPdsClient(makeSession(fetchMock)).call(
|
||||
com.atproto.server.getSession,
|
||||
{},
|
||||
)
|
||||
|
||||
const headers = headersFor(fetchMock, 'com.atproto.server.getSession')
|
||||
expect(headers.get('atproto-proxy')).toBeNull()
|
||||
expect(headers.get('atproto-accept-labelers')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the relative xrpc path against the account host', async () => {
|
||||
/*
|
||||
* lex-client hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the
|
||||
* session manager absolutizes it against dispatchUrl.
|
||||
* lex hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the session
|
||||
* absolutizes it against its didDoc endpoint or, absent one, its service.
|
||||
*/
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
|
||||
await buildPdsClient(makeSession(fetchMock)).call(
|
||||
com.atproto.server.getSession,
|
||||
{},
|
||||
)
|
||||
|
||||
expect(urlsOf(fetchMock)).toContain(
|
||||
`${SERVICE}/xrpc/com.atproto.server.getSession`,
|
||||
@@ -292,34 +223,26 @@ describe('agentToPdsClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentToChatClient', () => {
|
||||
describe('buildChatClient', () => {
|
||||
let fetchMock: MockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = makeProfileFetch()
|
||||
configureGlobalAppLabelers([])
|
||||
})
|
||||
|
||||
it('memoizes one client per agent, distinct from the pds client', () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
const client = agentToChatClient(agent)
|
||||
|
||||
expect(client).toBeInstanceOf(Client)
|
||||
expect(client).toBe(agentToChatClient(agent))
|
||||
expect(client).not.toBe(agentToPdsClient(agent))
|
||||
it('is a distinct client from the pds client over the same session', () => {
|
||||
const session = makeSession(fetchMock)
|
||||
expect(buildChatClient(session)).not.toBe(buildPdsClient(session))
|
||||
})
|
||||
|
||||
it('emits the chat proxy header exactly once, with the session token', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
|
||||
/* the stub body fails listConvos output validation; headers are recorded pre-parse */
|
||||
await agentToChatClient(agent)
|
||||
await buildChatClient(makeSession(fetchMock))
|
||||
.call(chat.bsky.convo.listConvos, {})
|
||||
.catch(() => {})
|
||||
|
||||
const headers = new Headers(
|
||||
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
|
||||
)
|
||||
const headers = headersFor(fetchMock, 'chat.bsky.convo.listConvos')
|
||||
/*
|
||||
* An exact match, not `toContain`: `Headers` comma-joins repeated entries
|
||||
* for the same name, so a second contributor would show up here.
|
||||
@@ -328,20 +251,96 @@ describe('agentToChatClient', () => {
|
||||
expect(headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
})
|
||||
|
||||
it('does not emit the agent labeler header', async () => {
|
||||
const {agent} = setup(fetchMock)
|
||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||
/* nor the global authorities: a chat call is not an appview read */
|
||||
it('emits no labeler header', async () => {
|
||||
/* the global authorities do not apply: a chat call is not an appview read */
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
|
||||
await agentToChatClient(agent)
|
||||
await buildChatClient(makeSession(fetchMock))
|
||||
.call(chat.bsky.convo.listConvos, {})
|
||||
.catch(() => {})
|
||||
|
||||
const headers = new Headers(
|
||||
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
|
||||
)
|
||||
expect(headers.get('atproto-accept-labelers')).toBeNull()
|
||||
expect(
|
||||
headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
|
||||
'atproto-accept-labelers',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeSessionToPds', () => {
|
||||
let fetchMock: MockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = makeProfileFetch()
|
||||
})
|
||||
|
||||
it('sends a request to the pinned host rather than the login service', async () => {
|
||||
/*
|
||||
* The entryway case, and the reason this shim exists: an account whose
|
||||
* service is `bsky.social` but whose PDS is elsewhere, with no didDoc yet
|
||||
* (the synchronous resume fast path, i.e. the common cold start). Without
|
||||
* the shim the session would resolve against its service and every request
|
||||
* of that cold start would go to the entryway.
|
||||
*/
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
|
||||
|
||||
await client.call(com.atproto.server.getSession, {})
|
||||
|
||||
expect(urlsOf(fetchMock)).toEqual([
|
||||
`${PDS_HOST}/xrpc/com.atproto.server.getSession`,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the session auth lifecycle on the pinned host', async () => {
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
|
||||
|
||||
await client.call(com.atproto.server.getSession, {})
|
||||
|
||||
expect(
|
||||
headersFor(fetchMock, 'com.atproto.server.getSession').get(
|
||||
'authorization',
|
||||
),
|
||||
).toBe('Bearer access-jwt')
|
||||
})
|
||||
|
||||
it('passes through the session did', () => {
|
||||
const session = makeSession(fetchMock)
|
||||
expect(routeSessionToPds(session, PDS_HOST).did).toBe(DID)
|
||||
})
|
||||
|
||||
it('pins the stored host even when the session carries a different didDoc endpoint', async () => {
|
||||
/*
|
||||
* The narrowing this shim accepts versus the session manager it replaces:
|
||||
* the manager preferred a didDoc endpoint once one arrived, whereas an
|
||||
* absolute URL handed to `session.fetchHandler` survives `new URL(path,
|
||||
* base)` untouched, so the stored host wins for the bundle's lifetime. That
|
||||
* only matters if the account's PDS moved, and the next cold start pins the
|
||||
* newly persisted endpoint.
|
||||
*/
|
||||
const session = makeSession(fetchMock, DIDDOC_PDS_HOST)
|
||||
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
|
||||
|
||||
await client.call(com.atproto.server.getSession, {})
|
||||
|
||||
expect(urlsOf(fetchMock)).toEqual([
|
||||
`${PDS_HOST}/xrpc/com.atproto.server.getSession`,
|
||||
])
|
||||
})
|
||||
|
||||
it('lets the session route by didDoc when nothing is pinned', async () => {
|
||||
/*
|
||||
* The counterpart: a bundle built with no stored `pdsUrl` goes straight over
|
||||
* the session, which resolves against its own didDoc endpoint.
|
||||
*/
|
||||
const client = buildPdsClient(makeSession(fetchMock, DIDDOC_PDS_HOST))
|
||||
|
||||
await client.call(com.atproto.server.getSession, {})
|
||||
|
||||
expect(urlsOf(fetchMock)).toEqual([
|
||||
`${DIDDOC_PDS_HOST}/xrpc/com.atproto.server.getSession`,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -72,11 +72,10 @@ import {
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {type SessionApiContext} from '#/state/session/types'
|
||||
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||
import {
|
||||
agentToAppviewClient,
|
||||
agentToChatClient,
|
||||
agentToPdsClient,
|
||||
buildAppviewClient,
|
||||
buildChatClient,
|
||||
buildPdsClient,
|
||||
getUnauthenticatedThrowingClient,
|
||||
} from '../clients'
|
||||
import {type SessionBundle} from '../session-core'
|
||||
@@ -92,22 +91,19 @@ type Clients = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bundle whose agent is a real `BskyAppAgent` over a real
|
||||
* `PasswordSession`, since the client builders derive from the agent. Only the
|
||||
* fields the provider reads are populated.
|
||||
* Build a bundle over a real `PasswordSession`, with the three clients the
|
||||
* provider serves. Only the fields the provider reads are populated.
|
||||
*/
|
||||
function makeBundle(account: SessionAccount): SessionBundle {
|
||||
const fetchMock = makeMockFetch()
|
||||
const session = new PasswordSession(sessionAccountToSessionData(account), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
const manager = new PasswordSessionManager(session, {
|
||||
service: account.service,
|
||||
})
|
||||
manager.setFetch(asFetch(fetchMock))
|
||||
return {
|
||||
session,
|
||||
agent: new BskyAppAgent(manager),
|
||||
appviewClient: buildAppviewClient(session),
|
||||
pdsClient: buildPdsClient(session),
|
||||
chatClient: buildChatClient(session),
|
||||
service: new URL(account.service),
|
||||
}
|
||||
}
|
||||
@@ -140,10 +136,10 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('client hooks while logged out', () => {
|
||||
it('serves the public agent for appview reads', () => {
|
||||
it('serves the public client for appview reads', () => {
|
||||
const {clients} = renderClients()
|
||||
expect(clients().appview).toBeDefined()
|
||||
/* the logged-out bundle's agent IS the public agent, so no separate branch */
|
||||
/* the logged-out bundle holds the public appview client itself */
|
||||
expect(clients().appview.did).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -162,7 +158,7 @@ describe('client hooks while logged out', () => {
|
||||
})
|
||||
|
||||
describe('client hooks with a session', () => {
|
||||
it('derives every surface from the session bundle agent', async () => {
|
||||
it('serves every surface straight off the session bundle', async () => {
|
||||
const account = makeAccount()
|
||||
const bundle = makeBundle(account)
|
||||
const {api, clients} = renderClients()
|
||||
@@ -172,9 +168,9 @@ describe('client hooks with a session', () => {
|
||||
await api.login({} as never, 'LoginForm')
|
||||
})
|
||||
|
||||
expect(clients().appview).toBe(agentToAppviewClient(bundle.agent))
|
||||
expect(clients().pds).toBe(agentToPdsClient(bundle.agent))
|
||||
expect(clients().chat).toBe(agentToChatClient(bundle.agent))
|
||||
expect(clients().appview).toBe(bundle.appviewClient)
|
||||
expect(clients().pds).toBe(bundle.pdsClient)
|
||||
expect(clients().chat).toBe(bundle.chatClient)
|
||||
})
|
||||
|
||||
it('serves the same clients from the maybe variants', async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ jest.mock('../create-account', () => ({
|
||||
|
||||
import {Provider, useSession, useSessionApi} from '#/state/session'
|
||||
import {type SessionApiContext} from '#/state/session/types'
|
||||
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||
import {buildAppviewClient, buildChatClient, buildPdsClient} from '../clients'
|
||||
import {type SessionBundle} from '../session-core'
|
||||
import {sessionAccountToSessionData} from '../session-data'
|
||||
import {
|
||||
@@ -87,13 +87,11 @@ function makeBundle(
|
||||
const session = new PasswordSession(sessionAccountToSessionData(account), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
const manager = new PasswordSessionManager(session, {
|
||||
service: account.service,
|
||||
})
|
||||
manager.setFetch(asFetch(fetchMock))
|
||||
return {
|
||||
session,
|
||||
agent: new BskyAppAgent(manager),
|
||||
appviewClient: buildAppviewClient(session),
|
||||
pdsClient: buildPdsClient(session),
|
||||
chatClient: buildChatClient(session),
|
||||
service: new URL(account.service),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +59,17 @@ jest.mock('#/analytics', () => ({
|
||||
/*
|
||||
* `configureModerationForAccount` is synchronous (the labeler cache is a local
|
||||
* MMKV read), so it is not a prep await - but it still runs inside each factory
|
||||
* with the freshly built bridge agent, before the awaited prep steps. The
|
||||
* factory tests capture the agent with this mock, then inject a real refresh
|
||||
* into the awaited AA prefetch so a token rotation happens during prep, before
|
||||
* arm(). The default is a no-op so other tests are unaffected.
|
||||
* with the freshly built bundle, before the awaited prep steps. The factory
|
||||
* tests capture the bundle with this mock, then inject a real refresh into the
|
||||
* awaited AA prefetch so a token rotation happens during prep, before arm().
|
||||
* The default is a no-op so other tests are unaffected.
|
||||
* (jest requires out-of-scope factory references to be `mock`-prefixed.)
|
||||
*/
|
||||
const mockConfigureModerationForAccount =
|
||||
jest.fn<(agent: unknown, account: unknown) => void>()
|
||||
jest.fn<(bundle: unknown, account: unknown) => void>()
|
||||
jest.mock('../moderation', () => ({
|
||||
configureModerationForAccount: (agent: unknown, account: unknown) =>
|
||||
mockConfigureModerationForAccount(agent, account),
|
||||
configureModerationForAccount: (bundle: unknown, account: unknown) =>
|
||||
mockConfigureModerationForAccount(bundle, account),
|
||||
configureModerationForGuest: () => {},
|
||||
}))
|
||||
|
||||
@@ -90,7 +90,6 @@ jest.mock('jwt-decode', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import {type BskyAppAgent} from '../bridge-agent'
|
||||
import {
|
||||
type AtpSessionEvent,
|
||||
buildBundle,
|
||||
@@ -365,22 +364,23 @@ describe('sessionAccountToSessionData', () => {
|
||||
})
|
||||
|
||||
describe('createSessionBundleFromStoredAccount', () => {
|
||||
it('builds a bridge agent over one session', () => {
|
||||
it('builds three clients over one session', async () => {
|
||||
const result = createSessionBundleFromStoredAccount(
|
||||
makeAccount(),
|
||||
jest.fn(),
|
||||
)!
|
||||
|
||||
/* the agent reads its identity straight through the shared session */
|
||||
expect(result.bundle.agent.session?.accessJwt).toBe('access-jwt')
|
||||
expect(result.bundle.agent.did).toBe(DID)
|
||||
expect(result.bundle.agent.sessionManager.session).toBe(
|
||||
result.bundle.agent.session,
|
||||
)
|
||||
/* every client reads its identity straight through the shared session */
|
||||
expect(result.bundle.session.session.accessJwt).toBe('access-jwt')
|
||||
expect(result.bundle.appviewClient.did).toBe(DID)
|
||||
expect(result.bundle.pdsClient.did).toBe(DID)
|
||||
expect(result.bundle.chatClient.did).toBe(DID)
|
||||
expect(result.bundle.service.toString()).toBe(`${SERVICE}/`)
|
||||
disposeBundle(result.bundle)
|
||||
/* disposal detaches the agent from the session */
|
||||
expect(result.bundle.agent.session).toBe(undefined)
|
||||
/* disposal disables the transport every client shares */
|
||||
await expect(
|
||||
result.bundle.session.fetchHandler('/xrpc/test', {}),
|
||||
).rejects.toThrow('session disposed')
|
||||
})
|
||||
|
||||
it('disposes a bundle rejected by the activation guard', async () => {
|
||||
@@ -836,15 +836,14 @@ describe('factory account snapshot after preparation', () => {
|
||||
* captured from the (synchronous) moderation call, and the rotation is
|
||||
* injected into the awaited AA prefetch.
|
||||
*/
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
let capturedBundle: SessionBundle | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
capturedBundle = bundle as SessionBundle
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
|
||||
/* routes through the bridge into the shared PasswordSession's refresh */
|
||||
await capturedAgent!.sessionManager.refreshSession()
|
||||
await capturedBundle!.session.refresh()
|
||||
})
|
||||
const fetchMock = makeMockFetch()
|
||||
|
||||
@@ -902,14 +901,14 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
* way to drive a session to `destroyed` from outside, and it takes the same
|
||||
* `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does.
|
||||
*/
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
let capturedBundle: SessionBundle | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
capturedBundle = bundle as SessionBundle
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
|
||||
await capturedAgent!.logout()
|
||||
await capturedBundle!.session.logout()
|
||||
})
|
||||
const fetchMock = makeMockFetch()
|
||||
|
||||
@@ -927,15 +926,15 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
).rejects.toThrow('Session was revoked while it was being prepared')
|
||||
|
||||
/* the bundle the caller never received reads as logged out */
|
||||
expect(capturedAgent!.session).toBe(undefined)
|
||||
expect(capturedBundle!.session.destroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('resume: a prep rejection propagates and disposes the bundle', async () => {
|
||||
let capturedAgent: BskyAppAgent | undefined
|
||||
let capturedBundle: SessionBundle | undefined
|
||||
mockConfigureModerationForAccount.mockImplementationOnce(
|
||||
(bundle: unknown) => {
|
||||
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
|
||||
capturedBundle = bundle as SessionBundle
|
||||
},
|
||||
)
|
||||
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() =>
|
||||
@@ -952,7 +951,9 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
).rejects.toThrow('prefetch blew up')
|
||||
|
||||
/* the still-live session was disposed rather than left refreshing */
|
||||
expect(capturedAgent!.session).toBe(undefined)
|
||||
await expect(
|
||||
capturedBundle!.session.fetchHandler('/xrpc/test', {}),
|
||||
).rejects.toThrow('session disposed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -964,7 +965,12 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{...hooks, fetch: asFetch(makeMockFetch())},
|
||||
/*
|
||||
* The hooks' own `fetch` is what the kill switch disables, so it must not
|
||||
* be overridden here. Nothing in this test reaches the network: the
|
||||
* post-disposal `fetchHandler` call throws before dispatching.
|
||||
*/
|
||||
hooks,
|
||||
)
|
||||
const bundle = buildBundle(session)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
@@ -975,7 +981,9 @@ describe('a session destroyed or rejected during preparation', () => {
|
||||
).rejects.toThrow('nope')
|
||||
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
expect(bundle.agent.session).toBe(undefined)
|
||||
await expect(bundle.session.fetchHandler('/xrpc/test', {})).rejects.toThrow(
|
||||
'session disposed',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex'
|
||||
|
||||
import {device} from '#/storage'
|
||||
@@ -83,27 +82,27 @@ export function configureAdditionalModerationAuthorities() {
|
||||
additionalLabelers = []
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge with whatever is already on the static rather than replacing it, so
|
||||
* `switchToBskyAppLabeler`'s entry survives.
|
||||
*/
|
||||
const appLabelers = Array.from(
|
||||
new Set([...AtpAgent.appLabelers, ...additionalLabelers]),
|
||||
new Set<string>([...Client.appLabelers, ...additionalLabelers]),
|
||||
)
|
||||
|
||||
configureGlobalAppLabelers(appLabelers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global app labelers on BOTH statics, so the agent-backed request path
|
||||
* and any client built without a wrapped agent emit the same `;redact`
|
||||
* authorities.
|
||||
* Set the global app labelers on the lex `Client` static, which every client
|
||||
* reads, so a request carries the same `;redact` authorities whether or not
|
||||
* there is a session behind it.
|
||||
*
|
||||
* Keeping the two in lockstep is what makes the duplicate-header hazard
|
||||
* avoidable: the agent joins its own list with whatever the caller already put
|
||||
* on the request, and lex appends the `Client` static on top of that, so a DID
|
||||
* present in both would appear twice. The agent-wrapping clients therefore
|
||||
* suppress their `appLabelers` (see `clients.ts`), which leaves exactly one
|
||||
* producer per request while both statics stay populated for the paths that
|
||||
* read only one of them.
|
||||
* It is a single global producer by design. The PDS and chat clients opt out
|
||||
* with `appLabelers: null` (see `clients.ts`) because those services take no
|
||||
* moderation authorities, leaving exactly one producer on an appview request and
|
||||
* none elsewhere.
|
||||
*/
|
||||
export function configureGlobalAppLabelers(dids: string[]) {
|
||||
AtpAgent.configure({appLabelers: dids})
|
||||
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
Agent as BaseAgent,
|
||||
type AtprotoServiceType,
|
||||
type Did,
|
||||
} from '@atproto/api'
|
||||
|
||||
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
||||
|
||||
/**
|
||||
* A bare `Agent` that applies a service-proxy header on construction.
|
||||
*
|
||||
* Used for the unauthenticated, service-specific calls that cannot go through
|
||||
* the session agent (PDS detection, password reset, handle availability).
|
||||
*/
|
||||
export class Agent extends BaseAgent {
|
||||
constructor(
|
||||
proxyHeader: ProxyHeaderValue | null,
|
||||
...options: ConstructorParameters<typeof BaseAgent>
|
||||
) {
|
||||
super(...options)
|
||||
if (proxyHeader) {
|
||||
this.configureProxy(proxyHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
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
|
||||
}
|
||||
+89
-122
@@ -1,132 +1,102 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type Agent, type Client} from '@atproto/lex'
|
||||
import {type PasswordSession} from '@atproto/lex-password-session'
|
||||
|
||||
import {CHAT_PROXY_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
CHAT_PROXY_SERVICE,
|
||||
PUBLIC_BSKY_SERVICE,
|
||||
} from '#/lib/constants'
|
||||
import {createLexClient} from '#/lib/lexClient'
|
||||
import {type BskyAppAgent} from './bridge-agent'
|
||||
import {networkAwareFetch} from './network'
|
||||
|
||||
/*
|
||||
* One client per agent, per surface, so that repeated reads for the same agent
|
||||
* return the same instance. Client identity is observable: a lex `Client` is
|
||||
* passed to React Query `queryFn`s and read from render paths, so a freshly
|
||||
* allocated client on every read would break any dependency array or reference
|
||||
* comparison built on top of it.
|
||||
*
|
||||
* Keying on the agent also ties client lifetime to agent lifetime. A disposed
|
||||
* agent's `fetchHandler` falls back to unauthenticated fetch, and session
|
||||
* rotation builds a new agent rather than mutating the old one, so a client
|
||||
* derived from a stale agent becomes unreachable exactly when its agent does.
|
||||
*/
|
||||
const appviewClients = new WeakMap<BskyAppAgent, Client>()
|
||||
const pdsClients = new WeakMap<BskyAppAgent, Client>()
|
||||
const chatClients = new WeakMap<BskyAppAgent, Client>()
|
||||
|
||||
/**
|
||||
* The appview {@link Client} for an agent, memoized per agent.
|
||||
* Build the signed-in appview {@link Client}.
|
||||
*
|
||||
* The wrapped handler is `agent.fetchHandler`, NOT
|
||||
* `agent.sessionManager.fetchHandler`. The agent-level handler is where
|
||||
* `atproto-proxy` and `atproto-accept-labelers` are set before the request is
|
||||
* passed down to the session manager, which only adds authorization and PDS
|
||||
* routing. Because the agent already emits both headers, the client is
|
||||
* deliberately built with neither a `service` option nor labelers - setting
|
||||
* either here would emit them a second time.
|
||||
* {@link BLUESKY_PROXY_HEADER} is passed as the client's `service`, so lex sets
|
||||
* `atproto-proxy: <that value>` on every request and raw calls are proxied to
|
||||
* the appview. Record helpers force `service: null`, so they still target the
|
||||
* account host.
|
||||
*
|
||||
* `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this
|
||||
* instance specifically. The static is populated (see
|
||||
* `configureGlobalAppLabelers`) so that clients built without a wrapped agent
|
||||
* carry the global authorities, but the agent already stamped those same DIDs
|
||||
* onto the request, and lex would append its own copy on top: the agent joins
|
||||
* its list with the existing header value while lex collects into a `Set` keyed
|
||||
* on the suffixed string, so neither dedupes against the other and every global
|
||||
* authority would appear twice.
|
||||
* The class-wide `Client.appLabelers` static is deliberately NOT suppressed
|
||||
* here: this client is the only producer of `atproto-accept-labelers` on an
|
||||
* appview request now that no agent sits underneath it. The account's own
|
||||
* subscriptions arrive separately, through `applyLabelersToClient` on the
|
||||
* instance, and that function filters out the Bluesky moderation DID so the
|
||||
* globally redacted authority is not also listed unredacted.
|
||||
*
|
||||
* No `fetch` option: a client built over a session uses that session's own
|
||||
* fetch, which is `networkAwareFetch` wrapped in the disposal kill switch.
|
||||
*/
|
||||
export function agentToAppviewClient(agent: BskyAppAgent): Client {
|
||||
const existing = appviewClients.get(agent)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const client = createLexClient(
|
||||
{
|
||||
get did() {
|
||||
return agent.did
|
||||
},
|
||||
fetchHandler: (path, init) => agent.fetchHandler(path, init),
|
||||
},
|
||||
{appLabelers: null},
|
||||
)
|
||||
appviewClients.set(agent, client)
|
||||
return client
|
||||
export function buildAppviewClient(agent: Agent): Client {
|
||||
return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()})
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-host {@link Client} for an agent, memoized per agent.
|
||||
* Build the signed-in account-host {@link Client}.
|
||||
*
|
||||
* This wraps `agent.sessionManager.fetchHandler`, one layer below
|
||||
* {@link agentToAppviewClient}. That layer does authorization and refresh-on-401
|
||||
* and resolves the request against `dispatchUrl` (the account's PDS), but it
|
||||
* does NOT set `atproto-proxy` or `atproto-accept-labelers`, so requests reach
|
||||
* the PDS itself rather than being proxied onward. That is the right transport
|
||||
* for `com.atproto.*` repo/server/identity calls.
|
||||
* No `service`, so no proxy header: `com.atproto.*` repo, server and identity
|
||||
* calls reach the account's own PDS rather than being proxied onward.
|
||||
*
|
||||
* No `service` option for the same reason: adding one would reintroduce the
|
||||
* proxy header this client exists to avoid. `appLabelers: null` is the same
|
||||
* kind of suppression: a PDS request is not an appview read, so it must carry no
|
||||
* moderation authorities at all - without this it would start emitting the
|
||||
* global `Client.appLabelers`.
|
||||
*
|
||||
* The handler is wrapped in a closure rather than passed by reference because
|
||||
* `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are
|
||||
* intentional: lex-client hands its handler an origin-less
|
||||
* `/xrpc/<nsid>[?query]` path, which the session manager absolutizes against
|
||||
* `dispatchUrl`.
|
||||
* `appLabelers: null` suppresses the class-wide static for this instance. A PDS
|
||||
* request is not an appview read, so it must carry no moderation authorities at
|
||||
* all; without the suppression it would start emitting the global list.
|
||||
*/
|
||||
export function agentToPdsClient(agent: BskyAppAgent): Client {
|
||||
const existing = pdsClients.get(agent)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const client = createLexClient(
|
||||
{
|
||||
get did() {
|
||||
return agent.did
|
||||
},
|
||||
fetchHandler: (path, init) =>
|
||||
agent.sessionManager.fetchHandler(path, init),
|
||||
},
|
||||
{appLabelers: null},
|
||||
)
|
||||
pdsClients.set(agent, client)
|
||||
return client
|
||||
export function buildPdsClient(agent: Agent): Client {
|
||||
return createLexClient(agent, {appLabelers: null})
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat {@link Client} for an agent, memoized per agent.
|
||||
* Build the signed-in chat {@link Client}.
|
||||
*
|
||||
* Same session-manager transport as {@link agentToPdsClient} - authorization
|
||||
* and PDS routing, no agent-level proxy or labeler headers - but constructed
|
||||
* with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits
|
||||
* `atproto-proxy: <CHAT_PROXY_SERVICE>` on every request and `chat.bsky.*`
|
||||
* calls are proxied to the chat service. `appLabelers: null` for the same
|
||||
* reason as the PDS client: the chat service takes no moderation authorities.
|
||||
* {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default
|
||||
* `did:web:api.bsky.chat#bsky_chat`) is the client's `service`, so `chat.bsky.*`
|
||||
* calls are proxied to the chat service. The DID is read from the
|
||||
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
|
||||
* be retargeted per environment.
|
||||
*
|
||||
* `appLabelers: null` for the same reason as the PDS client: the chat service
|
||||
* takes no moderation authorities.
|
||||
*/
|
||||
export function agentToChatClient(agent: BskyAppAgent): Client {
|
||||
const existing = chatClients.get(agent)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const client = createLexClient(
|
||||
{
|
||||
get did() {
|
||||
return agent.did
|
||||
},
|
||||
fetchHandler: (path, init) =>
|
||||
agent.sessionManager.fetchHandler(path, init),
|
||||
export function buildChatClient(agent: Agent): Client {
|
||||
return createLexClient(agent, {
|
||||
appLabelers: null,
|
||||
service: CHAT_PROXY_SERVICE,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a session so requests resolve against a known PDS while auth and refresh
|
||||
* stay with the session.
|
||||
*
|
||||
* This exists for the pre-didDoc window. `PasswordSession` resolves each request
|
||||
* against `extractPdsUrl(didDoc) ?? service`, so before a refresh has delivered
|
||||
* a didDoc it falls back to the login service - which for an entryway account
|
||||
* (`service: bsky.social`, PDS elsewhere) is the wrong host. The synchronous
|
||||
* resume fast path makes no network request at all, so that window covers every
|
||||
* request of a cold start until something triggers a refresh.
|
||||
*
|
||||
* Absolutizing here is enough because `PasswordSession.fetchHandler` builds its
|
||||
* URL with `new URL(path, base)`, which ignores the base for an already-absolute
|
||||
* input. So an absolute URL passes through untouched, and the session's own
|
||||
* didDoc routing still wins for any client built directly over it.
|
||||
*
|
||||
* The tradeoff is that this pins the STORED url for the bundle's lifetime, where
|
||||
* the session would prefer a didDoc endpoint that arrived later. That is
|
||||
* acceptable because the two only disagree if the account's PDS moved, and the
|
||||
* next cold start persists (and therefore pins) the new endpoint.
|
||||
*/
|
||||
export function routeSessionToPds(
|
||||
session: PasswordSession,
|
||||
pdsUrl: string,
|
||||
): Agent {
|
||||
return {
|
||||
get did() {
|
||||
return session.did
|
||||
},
|
||||
{appLabelers: null, service: CHAT_PROXY_SERVICE},
|
||||
)
|
||||
chatClients.set(agent, client)
|
||||
return client
|
||||
fetchHandler(path, init) {
|
||||
return session.fetchHandler(new URL(path, pdsUrl).href, init)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a write/auth-only client is used with no active session. */
|
||||
@@ -164,20 +134,17 @@ let publicLexClient: Client | undefined
|
||||
* The unauthenticated {@link Client} for public reads, pointed at the public
|
||||
* appview.
|
||||
*
|
||||
* A single module-level instance for the same identity-stability reason as
|
||||
* {@link agentToAppviewClient}: there is no session to scope it to, so it lives
|
||||
* for the lifetime of the process. Requests go through
|
||||
* {@link networkAwareFetch} so public reads feed the app's reachability signal
|
||||
* like authenticated ones do.
|
||||
* A single module-level instance: there is no session to scope it to, so it
|
||||
* lives for the lifetime of the process, and its identity is therefore stable
|
||||
* enough for a React Query key. Requests go through {@link networkAwareFetch} so
|
||||
* public reads feed the app's reachability signal like authenticated ones do.
|
||||
*
|
||||
* Unlike the agent-wrapping clients, this one does NOT suppress
|
||||
* `Client.appLabelers`: there is no agent underneath to stamp the header, so the
|
||||
* class-wide static is the only producer and a logged-out read carries the same
|
||||
* `;redact` moderation authorities an authenticated one does.
|
||||
*
|
||||
* That makes `configureModerationForGuest()` load-bearing rather than
|
||||
* test-only - it is what populates the static before this client's first
|
||||
* request. `createPublicSessionBundle` runs it while building the bundle.
|
||||
* Like the session appview client, it carries the class-wide
|
||||
* `Client.appLabelers`, so a logged-out read gets the same `;redact` moderation
|
||||
* authorities an authenticated one does. That makes `configureModerationForGuest`
|
||||
* load-bearing rather than test-only - it is what populates the static before
|
||||
* this client's first request, and `createPublicSessionBundle` runs it while
|
||||
* building the bundle.
|
||||
*/
|
||||
export function getPublicAppviewClient(): Client {
|
||||
return (publicLexClient ??= createLexClient({
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
DISCOVER_SAVED_FEED,
|
||||
IS_PROD_SERVICE,
|
||||
TIMELINE_SAVED_FEED,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {features} from '#/analytics'
|
||||
import {type app} from '#/lexicons'
|
||||
import {agentToAppviewClient, agentToPdsClient} from './clients'
|
||||
import {configureModerationForAccount} from './moderation'
|
||||
import {
|
||||
buildBundle,
|
||||
@@ -106,10 +104,10 @@ export async function createSessionBundleAndCreateAccount(
|
||||
setBirthdateForDid({did: earlyAccount.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
|
||||
// Post-signup writes all target the account's own repo and actor store.
|
||||
const pdsClient = agentToPdsClient(bundle.agent)
|
||||
const pdsClient = bundle.pdsClient
|
||||
// Start the prefetch after seeding its synchronous birthdate inputs.
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
appviewClient: agentToAppviewClient(bundle.agent),
|
||||
appviewClient: bundle.appviewClient,
|
||||
accountClient: pdsClient,
|
||||
})
|
||||
|
||||
@@ -136,8 +134,6 @@ export async function createSessionBundleAndCreateAccount(
|
||||
})
|
||||
}
|
||||
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||
const account = await finishPreparation(
|
||||
bundle,
|
||||
|
||||
+18
-43
@@ -9,7 +9,6 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type SessionData} from '@atproto/lex-password-session'
|
||||
|
||||
@@ -18,14 +17,9 @@ import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {com} from '#/lexicons'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {
|
||||
agentToAppviewClient,
|
||||
agentToChatClient,
|
||||
agentToPdsClient,
|
||||
getPublicAppviewClient,
|
||||
getUnauthenticatedThrowingClient,
|
||||
} from './clients'
|
||||
import {getPublicAppviewClient} from './clients'
|
||||
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
@@ -451,13 +445,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
/*
|
||||
* 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.
|
||||
* holding a disposed bundle, whose clients dispatch through a disabled
|
||||
* fetch.
|
||||
*/
|
||||
const bundle = store.getState().currentBundleState
|
||||
.bundle as unknown as SessionBundle
|
||||
const signal = cancelPendingTask()
|
||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||
const {data} = await bundle.agent.com.atproto.server.getSession()
|
||||
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
||||
if (signal.aborted) return
|
||||
store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
@@ -478,9 +473,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
* Rotate the session's tokens and hand back the resulting account snapshot.
|
||||
*
|
||||
* Rejects when the rotation was a no-op, restoring the contract the
|
||||
* `agent.resumeSession(agent.session!)` call sites were written against (the
|
||||
* bridge agent's `refreshSession` override does the same, for the same
|
||||
* reason). `PasswordSession.refresh()` resolves with the
|
||||
* `agent.resumeSession(agent.session!)` call sites were written against.
|
||||
* `PasswordSession.refresh()` resolves with the
|
||||
* unchanged `SessionData` on a transient failure - a 500 or a network error
|
||||
* reported through `onUpdateFailure` - and reserves rejection for a
|
||||
* definitively dead session. Callers here all read resolution as "tokens
|
||||
@@ -672,15 +666,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
// @ts-expect-error window type is not declared, debug only
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
if (__DEV__ && IS_WEB) window.agent = bundle.agent
|
||||
if (__DEV__ && IS_WEB) window.bundle = bundle
|
||||
|
||||
const currentBundleRef = useRef(bundle)
|
||||
/*
|
||||
* Disposal is deferred to this post-commit effect deliberately: components may
|
||||
* still render against the outgoing bundle during the commit that swaps it, so
|
||||
* tearing its agent down inline would pull the agent out from under them. The
|
||||
* reducer's bundle-identity guard drops any events the not-yet-disposed session
|
||||
* emits in that window.
|
||||
* disabling its session inline would pull the transport out from under them.
|
||||
* The reducer's bundle-identity guard drops any events the not-yet-disposed
|
||||
* session emits in that window.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (currentBundleRef.current !== bundle) {
|
||||
@@ -753,30 +747,15 @@ export function useRequireAuth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The active session's agent, or the public agent when logged out.
|
||||
*/
|
||||
export function useAgent(): AtpAgent {
|
||||
const bundle = useContext(BundleContext)
|
||||
if (!bundle) {
|
||||
throw Error('useAgent() must be below <SessionProvider>.')
|
||||
}
|
||||
return bundle.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for appview reads.
|
||||
*
|
||||
* When logged out the bundle's agent is the public agent built by
|
||||
* `createPublicAgent`, which is configured with the appview proxy and dispatches
|
||||
* unauthenticated, so the logged-out fallback is the agent itself - there is no
|
||||
* separate public branch here.
|
||||
* Client for appview reads. Logged out, this is the bundle's public client,
|
||||
* which dispatches unauthenticated against the public appview.
|
||||
*/
|
||||
export function useAppviewClient(): Client {
|
||||
const bundle = useContext(BundleContext)
|
||||
if (!bundle) {
|
||||
throw Error('useAppviewClient() must be below <SessionProvider>.')
|
||||
}
|
||||
return agentToAppviewClient(bundle.agent)
|
||||
return bundle.appviewClient
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -790,9 +769,7 @@ export function usePdsClient(): Client {
|
||||
if (!bundle) {
|
||||
throw Error('usePdsClient() must be below <SessionProvider>.')
|
||||
}
|
||||
return bundle.session
|
||||
? agentToPdsClient(bundle.agent)
|
||||
: getUnauthenticatedThrowingClient()
|
||||
return bundle.pdsClient
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,9 +781,7 @@ export function useChatClient(): Client {
|
||||
if (!bundle) {
|
||||
throw Error('useChatClient() must be below <SessionProvider>.')
|
||||
}
|
||||
return bundle.session
|
||||
? agentToChatClient(bundle.agent)
|
||||
: getUnauthenticatedThrowingClient()
|
||||
return bundle.chatClient
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -814,7 +789,7 @@ export function useChatClient(): Client {
|
||||
*/
|
||||
export function useMaybePdsClient(): Client | null {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.session ? agentToPdsClient(bundle.agent) : null
|
||||
return bundle?.session ? bundle.pdsClient : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -822,7 +797,7 @@ export function useMaybePdsClient(): Client | null {
|
||||
*/
|
||||
export function useMaybeChatClient(): Client | null {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.session ? agentToChatClient(bundle.agent) : null
|
||||
return bundle?.session ? bundle.chatClient : null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {type AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type DidString} from '@atproto/syntax'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {com} from '#/lexicons'
|
||||
import {account as accountStorage} from '#/storage'
|
||||
import {
|
||||
configureAdditionalModerationAuthorities,
|
||||
@@ -9,6 +11,9 @@ import {
|
||||
} from './additional-moderation-authorities'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/** The moderation surface of a session bundle. */
|
||||
type ModerationSession = {appviewClient: Client}
|
||||
|
||||
/**
|
||||
* Cache an account's subscribed labeler DIDs. Called on every preferences
|
||||
* fetch, so the cache is eventually consistent with the server.
|
||||
@@ -31,25 +36,24 @@ export function readLabelers(did: string): string[] | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an account's labeler subscriptions without duplicating the globally
|
||||
* redacted Bluesky moderation authority.
|
||||
* Apply an account's labeler subscriptions to the appview client, without
|
||||
* duplicating the globally redacted Bluesky moderation authority.
|
||||
*
|
||||
* The Bluesky DID is filtered out because it already flows through the global
|
||||
* `appLabelers`, which lex and the agent both emit with a `;redact` suffix.
|
||||
* Listing it per-subscription would add a second, non-redacting entry for the
|
||||
* same authority.
|
||||
* `Client.appLabelers`, which lex emits with a `;redact` suffix. Listing it
|
||||
* per-instance would add a second, non-redacting entry for the same authority:
|
||||
* lex collects the two lists into a `Set` keyed on the suffixed string, so
|
||||
* neither dedupes against the other.
|
||||
*
|
||||
* Writes to the agent rather than the client: the agent-level fetch handler is
|
||||
* what stamps `atproto-accept-labelers` on the requests the wrapping clients
|
||||
* issue, so setting them here reaches every appview read. The bundle rework
|
||||
* moves this to `appviewClient.setLabelers` once the agent is gone.
|
||||
* Only the appview client takes subscriptions - the PDS and chat clients suppress
|
||||
* labelers entirely (see clients.ts).
|
||||
*/
|
||||
export function applyLabelersToClient(
|
||||
agent: AtpAgent,
|
||||
client: Client,
|
||||
subscribedDids: string[],
|
||||
) {
|
||||
agent.configureLabelersHeader(
|
||||
subscribedDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
client.setLabelers(
|
||||
subscribedDids.filter(did => did !== BSKY_LABELER_DID) as DidString[],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +70,7 @@ export function configureModerationForGuest() {
|
||||
* in the same tick, before any request goes out.
|
||||
*/
|
||||
export function configureModerationForAccount(
|
||||
bundle: {agent: AtpAgent; appviewClient?: Client},
|
||||
bundle: ModerationSession,
|
||||
account: SessionAccount,
|
||||
) {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
@@ -74,13 +78,13 @@ export function configureModerationForAccount(
|
||||
switchToBskyAppLabeler()
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
// Test accounts may briefly use the production authority while this resolves.
|
||||
void trySwitchToTestAppLabeler(bundle.agent)
|
||||
void trySwitchToTestAppLabeler(bundle.appviewClient)
|
||||
}
|
||||
|
||||
// The code below is actually relevant to production (and isn't global).
|
||||
const labelerDids = readLabelers(account.did)
|
||||
if (labelerDids) {
|
||||
applyLabelersToClient(bundle.agent, labelerDids)
|
||||
applyLabelersToClient(bundle.appviewClient, labelerDids)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
@@ -94,12 +98,14 @@ function switchToBskyAppLabeler() {
|
||||
}
|
||||
|
||||
/** Resolve and install the test environment's moderation authority. */
|
||||
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
|
||||
async function trySwitchToTestAppLabeler(client: Client) {
|
||||
const did = (
|
||||
await agent
|
||||
.resolveHandle({handle: 'mod-authority.test'})
|
||||
await client
|
||||
.call(com.atproto.identity.resolveHandle, {
|
||||
handle: 'mod-authority.test',
|
||||
})
|
||||
.catch(_ => undefined)
|
||||
)?.data.did
|
||||
)?.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
configureGlobalAppLabelers([did])
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {
|
||||
PasswordSession,
|
||||
type PasswordSessionOptions,
|
||||
type SessionData,
|
||||
} from '@atproto/lex-password-session'
|
||||
|
||||
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {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 {agentToAppviewClient, agentToPdsClient} from './clients'
|
||||
buildAppviewClient,
|
||||
buildChatClient,
|
||||
buildPdsClient,
|
||||
getPublicAppviewClient,
|
||||
getUnauthenticatedThrowingClient,
|
||||
routeSessionToPds,
|
||||
} from './clients'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {configureModerationForAccount} from './moderation'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {networkAwareFetch} from './network'
|
||||
import {
|
||||
isSessionExpired,
|
||||
@@ -46,10 +52,12 @@ function deriveServiceUrl(session: PasswordSession | null): URL {
|
||||
)
|
||||
}
|
||||
|
||||
/** An `AtpAgent` bridged over one `PasswordSession`, the bundle's sole auth core. */
|
||||
/** The three clients over one `PasswordSession`, the bundle's sole auth core. */
|
||||
export type SessionBundle = {
|
||||
session: PasswordSession
|
||||
agent: BskyAppAgent
|
||||
appviewClient: Client
|
||||
pdsClient: Client
|
||||
chatClient: Client
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
@@ -63,38 +71,38 @@ 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.
|
||||
* Killing the hooks is the whole of disposal now: the clients hold no state of
|
||||
* their own, and every request they make goes through the session's injected
|
||||
* fetch, which the kill switch disables.
|
||||
*/
|
||||
export function registerBundleKillSwitch(
|
||||
bundle: SessionBundle,
|
||||
kill: () => void,
|
||||
) {
|
||||
bundleKillSwitches.set(bundle, () => {
|
||||
kill()
|
||||
bundle.agent.dispose()
|
||||
})
|
||||
bundleKillSwitches.set(bundle, kill)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a session in the bridge agent.
|
||||
* Build the three clients over a session.
|
||||
*
|
||||
* `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.
|
||||
* `storedPdsUrl` pins PDS routing for requests made before a refresh has
|
||||
* delivered a didDoc - see {@link routeSessionToPds}, which explains why the
|
||||
* session's own routing is not sufficient in that window. With no stored url
|
||||
* there is nothing better to pin to, so the clients go straight over the
|
||||
* session and it resolves them against its own service.
|
||||
*/
|
||||
export function buildBundle(
|
||||
session: PasswordSession,
|
||||
storedPdsUrl?: string,
|
||||
): SessionBundle {
|
||||
const manager = new PasswordSessionManager(session, {
|
||||
service: deriveServiceUrl(session).toString(),
|
||||
pdsUrl: storedPdsUrl,
|
||||
})
|
||||
const agent = storedPdsUrl
|
||||
? routeSessionToPds(session, storedPdsUrl)
|
||||
: session
|
||||
return {
|
||||
session,
|
||||
agent: new BskyAppAgent(manager),
|
||||
appviewClient: buildAppviewClient(agent),
|
||||
pdsClient: buildPdsClient(agent),
|
||||
chatClient: buildChatClient(agent),
|
||||
get service() {
|
||||
return deriveServiceUrl(session)
|
||||
},
|
||||
@@ -182,23 +190,36 @@ export function makeSessionHooks({
|
||||
})
|
||||
}
|
||||
|
||||
/** The agent exposed while logged out. */
|
||||
/** The clients exposed while logged out. */
|
||||
export type PublicSessionBundle = {
|
||||
session: null
|
||||
agent: BskyAppAgent
|
||||
appviewClient: Client
|
||||
pdsClient: Client
|
||||
chatClient: Client
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logged-out bundle. `createPublicAgent` installs the guest
|
||||
* moderation authorities as part of building the agent, which is what populates
|
||||
* the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on
|
||||
* for its labeler header.
|
||||
* Build the logged-out bundle.
|
||||
*
|
||||
* `configureModerationForGuest` is what populates the global
|
||||
* `Client.appLabelers` that {@link getPublicAppviewClient} reads for its labeler
|
||||
* header, so it must run before the public client's first request. There is no
|
||||
* agent stamping that header any more, which makes this call load-bearing rather
|
||||
* than test-only: without it a logged-out read would carry no moderation
|
||||
* authorities at all.
|
||||
*
|
||||
* The write surfaces get the throwing client rather than a public one, so an
|
||||
* unauthenticated write fails legibly instead of 4xx-ing against public
|
||||
* infrastructure.
|
||||
*/
|
||||
export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
configureModerationForGuest()
|
||||
return {
|
||||
session: null,
|
||||
agent: createPublicAgent(),
|
||||
appviewClient: getPublicAppviewClient(),
|
||||
pdsClient: getUnauthenticatedThrowingClient(),
|
||||
chatClient: getUnauthenticatedThrowingClient(),
|
||||
service: new URL(PUBLIC_BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
@@ -224,9 +245,8 @@ export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
* 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.)
|
||||
* where the session already refuses to refresh - but the two paths are
|
||||
* indistinguishable to the caller, so both go through it.)
|
||||
*/
|
||||
export async function finishPreparation<T>(
|
||||
bundle: SessionBundle,
|
||||
@@ -294,16 +314,10 @@ export async function createSessionBundleAndResume(
|
||||
|
||||
configureModerationForAccount(bundle, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
appviewClient: agentToAppviewClient(bundle.agent),
|
||||
accountClient: agentToPdsClient(bundle.agent),
|
||||
appviewClient: bundle.appviewClient,
|
||||
accountClient: bundle.pdsClient,
|
||||
})
|
||||
|
||||
/*
|
||||
* 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,
|
||||
@@ -362,12 +376,10 @@ export async function createSessionBundleAndLogin(
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
configureModerationForAccount(bundle, earlyAccount)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
appviewClient: agentToAppviewClient(bundle.agent),
|
||||
accountClient: agentToPdsClient(bundle.agent),
|
||||
appviewClient: bundle.appviewClient,
|
||||
accountClient: bundle.pdsClient,
|
||||
})
|
||||
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||
const account = await finishPreparation(
|
||||
bundle,
|
||||
@@ -403,7 +415,6 @@ export function createSessionBundleFromStoredAccount(
|
||||
bundle = buildBundle(session, storedAccount.pdsUrl)
|
||||
registerBundleKillSwitch(bundle, hooks.kill)
|
||||
configureModerationForAccount(bundle, storedAccount)
|
||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
const account = session.destroyed
|
||||
? storedAccount
|
||||
|
||||
Reference in New Issue
Block a user