phase 4: delete the session bridge (SessionAgent, agent.ts, agentToLexClient)

This commit is contained in:
Samuel Newman
2026-07-17 13:12:46 +03:00
parent bab2b621a9
commit ca5b8b6346
14 changed files with 500 additions and 956 deletions
@@ -1,375 +0,0 @@
import {type AtpSessionEvent} from '@atproto/api'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
/*
* session-core imports the factory dependency graph (birthdate,
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so
* these tests do not pull in the native module chain (same approach as
* session-test.ts / session-core-test.ts).
*/
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {
makeSessionHooks,
sessionAccountToSessionData,
SessionAgent,
} from '../session-core'
import {type SessionAccount} from '../types'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/**
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
* echoes the account; anything else returns an empty 200.
*/
function makeMockFetch(
overrides: Record<
string,
(url: string, init: RequestInit) => Response | Promise<Response>
> = {},
) {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
const fetchMock = jest.fn(
/*
* PasswordSession calls fetch with a URL object (new URL(path, service));
* asFetch() below widens the mock to the full fetch signature it expects.
*/
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
const url = input instanceof URL ? input.href : input
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
const handler = nsid ? overrides[nsid] : undefined
if (handler) {
return handler(url, init)
}
if (nsid === 'com.atproto.server.refreshSession') {
return json({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
handle: HANDLE,
did: DID,
active: true,
})
}
if (nsid === 'com.atproto.server.getSession') {
return json({
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
active: true,
})
}
return json({})
},
)
return fetchMock
}
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
return mock as unknown as typeof fetch
}
describe('SessionAgent getters', () => {
it('reads live SessionData through .session', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.session?.did).toBe(DID)
expect(agent.session?.handle).toBe(HANDLE)
expect(agent.did).toBe(DID)
})
it('derives serviceUrl from the session service', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
})
it('derives pdsUrl/dispatchUrl from a synthetic didDoc', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.pdsUrl?.toString()).toBe(`${PDS_URL}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_URL}/`)
})
it('dispatchUrl falls back to serviceUrl when there is no PDS', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('public agent exposes undefined session and public serviceUrl', () => {
const agent = new SessionAgent(null)
expect(agent.session).toBe(undefined)
expect(agent.did).toBe(undefined)
expect(agent.serviceUrl.toString()).toBe('https://public.api.bsky.app/')
})
})
describe('SessionAgent.resumeSession', () => {
it('calls session.refresh() and returns a success result', async () => {
const fetchMock = makeMockFetch()
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock)},
)
const agent = new SessionAgent(session)
const res = await agent.resumeSession()
expect(res.success).toBe(true)
expect(res.data.accessJwt).toBe('access-jwt-2')
/* one refreshSession call */
const calls = fetchMock.mock.calls.map(c =>
c[0] instanceof URL ? c[0].href : c[0],
)
expect(
calls.some(u => u.includes('com.atproto.server.refreshSession')),
).toBe(true)
})
})
describe('SessionAgent destroyed session', () => {
it('did/session getters do not throw after logout', async () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
await session.logout()
expect(session.destroyed).toBe(true)
/* PasswordSession.did throws when destroyed; SessionAgent must not */
expect(() => agent.did).not.toThrow()
expect(agent.did).toBe(undefined)
expect(agent.session).toBe(undefined)
})
})
describe('SessionAgent namespace routing', () => {
it('routes a call through the session fetchHandler with proxy + labeler headers', 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 new Response(JSON.stringify({did: DID, handle: HANDLE}), {
status: 200,
headers: {'content-type': 'application/json'},
})
},
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
{fetch: asFetch(fetchMock)},
)
const agent = new SessionAgent(session)
/* base Agent's configureProxy is what buildBundle applies in production */
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 base 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].headers.get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
)
const labelers = seen[0].headers.get('atproto-accept-labelers')
expect(labelers).toContain('did:plc:custom-labeler')
/* the session attaches the bearer token */
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('makeSessionHooks arm-latch + event mapping', () => {
/*
* The hooks read neither `this` (the PasswordSession) nor their data
* argument, so we invoke them with empty stand-ins cast to the declared
* parameter types. This keeps the test focused on the arm-latch + event
* mapping.
*/
const fakeSession = {} as PasswordSession
const fakeData = {} as SessionData
function setup() {
const onSessionChange =
jest.fn<
(agent: SessionAgent, did: string, event: AtpSessionEvent) => void
>()
const agent = new SessionAgent(null)
const hooks = makeSessionHooks(
onSessionChange,
() => agent,
() => DID,
)
return {onSessionChange, agent, hooks}
}
it('swallows events before arm()', () => {
const {onSessionChange, hooks} = setup()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).not.toHaveBeenCalled()
})
it("maps onUpdated -> 'update' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).toHaveBeenCalledTimes(1)
expect(onSessionChange.mock.calls[0][2]).toBe('update')
})
it("maps onDeleted -> 'expired' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onDeleted?.call(fakeSession, fakeData)
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
})
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onUpdateFailure?.call(
fakeSession,
fakeData,
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
)
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
})
})
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
const fetchMock = makeMockFetch()
/* not expired -> new PasswordSession(...) with no refresh */
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
fetch: asFetch(fetchMock),
})
expect(fetchMock.mock.calls.length).toBe(0)
})
it('resume network path fires onUpdated with fresh tokens', async () => {
const fetchMock = makeMockFetch()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const session = await PasswordSession.resume(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onUpdated},
)
expect(onUpdated).toHaveBeenCalledTimes(1)
expect(session.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': () =>
new Response(
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
{status: 400, headers: {'content-type': 'application/json'}},
),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
)
await expect(session.refresh()).rejects.toBeDefined()
expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onUpdated).not.toHaveBeenCalled()
})
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': () =>
new Response(JSON.stringify({error: 'InternalServerError'}), {
status: 500,
headers: {'content-type': 'application/json'},
}),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{
fetch: asFetch(fetchMock),
onDeleted,
onUpdateFailure,
},
)
await session.refresh()
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
/* session data is preserved (still the original tokens) */
expect(session.session.accessJwt).toBe('access-jwt')
})
})
@@ -1,4 +1,3 @@
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
@@ -7,7 +6,7 @@ import {describe, expect, it, jest} from '@jest/globals'
/*
* clients.ts imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load
* the native module chain (same approach as bridge-agent-test.ts).
* the native module chain (same approach as session-core-test.ts).
*/
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
@@ -280,54 +279,34 @@ describe('getPublicLexClient', () => {
/*
* Regression guard: the emitted `atproto-accept-labelers` header from a
* fully-configured appview client must match the header the old AtpAgent
* produced for the same labeler set - global appLabelers carry the `;redact`
* suffix, per-instance labelers are plain. Byte-identical composition is the
* acceptance bar for the moderation migration (design section 6).
* fully-configured appview client must carry the exact byte-shape the old
* AtpAgent produced - global appLabelers carry the `;redact` suffix, per
* -instance labelers are plain. The old AtpAgent reference implementation is
* gone with the bridge, so we assert the composition invariant directly (design
* section 6).
*/
describe('labeler-header regression guard', () => {
it('appview client emits the same atproto-accept-labelers header as the old AtpAgent', async () => {
it('appview client emits the global Bluesky labeler redacted and the per-instance labeler plain', async () => {
/*
* Old behavior: AtpAgent.appLabelers default to [BSKY_LABELER_DID], emitted
* with `;redact`; per-instance configureLabelers are plain. Capture what a
* real AtpAgent emits for the same [custom] instance labeler set.
* buildAppviewClient re-asserts api.moderation.did as a base labeler; the
* global Client.appLabelers carry the `;redact` suffix. Configure the global
* appLabelers to the Bluesky moderation DID (matching switchToBskyAppLabeler
* in moderation.ts) so the composition matches production.
*/
const {seen: agentSeen, fetchMock: agentFetch} = makeCapturingFetch()
const oldAgent = new AtpAgent({
service: SERVICE,
fetch: asFetch(agentFetch),
})
oldAgent.configureProxy(APPVIEW_PROXY)
oldAgent.configureLabelers([CUSTOM_LABELER])
await oldAgent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
const oldHeader = agentSeen[0].headers.get('atproto-accept-labelers')
Client.configure({appLabelers: [api.moderation.did]})
/*
* New behavior: buildAppviewClient re-asserts api.moderation.did (===
* BSKY_LABELER_DID) as a base labeler; the global Client.appLabelers carry
* the `;redact` suffix. Configure Client global appLabelers to match the old
* AtpAgent global set so the composition is directly comparable.
*/
Client.configure({appLabelers: [BSKY_LABELER_DID]})
const {seen: clientSeen, fetchMock: clientFetch} = makeCapturingFetch()
const session = makeSession(clientFetch)
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {})
const newHeader = clientSeen[0].headers.get('atproto-accept-labelers')
const header = seen[0].headers.get('atproto-accept-labelers')
/*
* Both must contain the redacted global Bluesky labeler and the plain
* per-instance custom labeler.
*/
expect(oldHeader).toContain(`${BSKY_LABELER_DID};redact`)
expect(newHeader).toContain(`${BSKY_LABELER_DID};redact`)
expect(oldHeader).toContain(CUSTOM_LABELER)
expect(newHeader).toContain(CUSTOM_LABELER)
/* the custom labeler is plain (no redact) in both */
expect(oldHeader).not.toContain(`${CUSTOM_LABELER};redact`)
expect(newHeader).not.toContain(`${CUSTOM_LABELER};redact`)
/* the global Bluesky moderation labeler is redacted */
expect(header).toContain(`${api.moderation.did};redact`)
/* the per-instance custom labeler is present and plain (no redact) */
expect(header).toContain(CUSTOM_LABELER)
expect(header).not.toContain(`${CUSTOM_LABELER};redact`)
})
})
@@ -1,94 +0,0 @@
import {type AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {describe, expect, it, jest} from '@jest/globals'
import {app} from '#/lexicons'
/*
* clients.ts now imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load
* the native module chain (same approach as session-test.ts).
*/
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {agentToLexClient} from '../clients'
/**
* Minimal stand-in for the parts of AtpAgent that `agentToLexClient` reads: a
* `did` and a `fetchHandler`. Returned as `AtpAgent` via a cast since we only
* exercise those two members.
*/
function makeFakeAgent(did: string | undefined) {
const fetchHandler = jest.fn(
(_path: string, _init: RequestInit): Promise<Response> =>
Promise.resolve(
new Response(
JSON.stringify({
did: 'did:plc:fake',
handle: 'fake.bsky.social',
}),
{status: 200, headers: {'content-type': 'application/json'}},
),
),
)
const agent = {did, fetchHandler}
return {agent: agent as unknown as AtpAgent, fetchHandler}
}
describe('agentToLexClient', () => {
it('routes client.call through the agent fetchHandler', async () => {
const {agent, fetchHandler} = makeFakeAgent('did:plc:fake')
const client = agentToLexClient(agent)
const result = await client.call(app.bsky.actor.getProfile.main, {
actor: 'fake.bsky.social',
})
expect(fetchHandler).toHaveBeenCalledTimes(1)
const [path] = fetchHandler.mock.calls[0]
expect(path).toContain('/xrpc/app.bsky.actor.getProfile')
expect(path).toContain('actor=fake.bsky.social')
expect(result.handle).toBe('fake.bsky.social')
})
it('passes through the agent did', () => {
const {agent} = makeFakeAgent('did:plc:fake')
const client = agentToLexClient(agent)
expect(client.did).toBe('did:plc:fake')
})
it('reflects an undefined did (unauthenticated agent)', () => {
const {agent} = makeFakeAgent(undefined)
const client = agentToLexClient(agent)
expect(client.did).toBeUndefined()
})
it('memoizes one client per agent', () => {
const {agent: agentA} = makeFakeAgent('did:plc:a')
const {agent: agentB} = makeFakeAgent('did:plc:b')
const clientA1 = agentToLexClient(agentA)
const clientA2 = agentToLexClient(agentA)
const clientB = agentToLexClient(agentB)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
expect(clientA1).not.toBe(clientB)
})
})
@@ -1,4 +1,9 @@
import {type SessionData} from '@atproto/lex-password-session'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals'
import {type SessionAccount} from '../types'
@@ -32,14 +37,18 @@ jest.mock('jwt-decode', () => ({
}))
import {
type AtpSessionEvent,
extractPdsUrl,
makeSessionHooks,
sessionAccountToSessionData,
type SessionBundle,
sessionDataToSessionAccount,
synthDidDoc,
} from '../session-core'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
@@ -324,3 +333,283 @@ describe('sessionAccountToSessionData', () => {
expect(roundTripped).toEqual(selfHosted)
})
})
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/**
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
* echoes the account; anything else returns an empty 200.
*/
function makeMockFetch(
overrides: Record<
string,
(url: string, init: RequestInit) => Response | Promise<Response>
> = {},
) {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
const fetchMock = jest.fn(
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
const url = input instanceof URL ? input.href : input
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
const handler = nsid ? overrides[nsid] : undefined
if (handler) {
return handler(url, init)
}
if (nsid === 'com.atproto.server.refreshSession') {
return json({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
handle: HANDLE,
did: DID,
active: true,
})
}
if (nsid === 'com.atproto.server.getSession') {
return json({
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
active: true,
})
}
return json({})
},
)
return fetchMock
}
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
return mock as unknown as typeof fetch
}
/*
* Ported from the now-deleted bridge-agent-test: the arm-latch + event mapping
* is the durable session-hook semantics that survives the bridge removal. The
* hook now hands the whole bundle to onSessionChange (not a bridge agent), so
* getBundle returns a stand-in bundle whose identity is what matters.
*/
describe('makeSessionHooks arm-latch + event mapping', () => {
/*
* The hooks read neither `this` (the PasswordSession) nor their data
* argument, so we invoke them with empty stand-ins cast to the declared
* parameter types. This keeps the test focused on the arm-latch + event
* mapping.
*/
const fakeSession = {} as PasswordSession
const fakeData = {} as SessionData
function setup() {
const onSessionChange =
jest.fn<
(bundle: SessionBundle, did: string, event: AtpSessionEvent) => void
>()
/* the hook only passes this through by identity; a stub bundle suffices */
const bundle = {} as SessionBundle
const hooks = makeSessionHooks(
onSessionChange,
() => bundle,
() => DID,
)
return {onSessionChange, bundle, hooks}
}
it('swallows events before arm()', () => {
const {onSessionChange, hooks} = setup()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).not.toHaveBeenCalled()
})
it("maps onUpdated -> 'update' after arm(), passing the bundle through", () => {
const {onSessionChange, bundle, hooks} = setup()
hooks.arm()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).toHaveBeenCalledTimes(1)
expect(onSessionChange.mock.calls[0][0]).toBe(bundle)
expect(onSessionChange.mock.calls[0][1]).toBe(DID)
expect(onSessionChange.mock.calls[0][2]).toBe('update')
})
it("maps onDeleted -> 'expired' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onDeleted?.call(fakeSession, fakeData)
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
})
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onUpdateFailure?.call(
fakeSession,
fakeData,
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
)
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
})
})
/*
* Ported from bridge-agent-test: PasswordSession lifecycle over a mocked fetch.
* This exercises the auth core directly (the bridge that used to wrap it is
* gone), covering the resume fast path plus the onUpdated/onDeleted/
* onUpdateFailure hook firing that makeSessionHooks maps into reducer events.
*/
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
const fetchMock = makeMockFetch()
/* not expired -> new PasswordSession(...) with no refresh */
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
fetch: asFetch(fetchMock),
})
expect(fetchMock.mock.calls.length).toBe(0)
})
it('resume network path fires onUpdated with fresh tokens', async () => {
const fetchMock = makeMockFetch()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const session = await PasswordSession.resume(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onUpdated},
)
expect(onUpdated).toHaveBeenCalledTimes(1)
expect(session.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': () =>
new Response(
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
{status: 400, headers: {'content-type': 'application/json'}},
),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
)
await expect(session.refresh()).rejects.toBeDefined()
expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onUpdated).not.toHaveBeenCalled()
})
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': () =>
new Response(JSON.stringify({error: 'InternalServerError'}), {
status: 500,
headers: {'content-type': 'application/json'},
}),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{
fetch: asFetch(fetchMock),
onDeleted,
onUpdateFailure,
},
)
await session.refresh()
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
/* session data is preserved (still the original tokens) */
expect(session.session.accessJwt).toBe('access-jwt')
})
})
/*
* refreshSession coverage (design decision (b) / Test plan). The
* `useSessionApi().refreshSession()` callback is a thin wrapper over
* `PasswordSession.refresh()`: on success the armed hooks dispatch exactly one
* 'update' event and the returned snapshot reflects the refreshed data;
* rejections propagate. We exercise the auth-core mechanics that the callback
* relies on (a full provider render is out of scope for a unit test).
*/
describe('refreshSession semantics', () => {
it('refresh() resolves updated data and the armed hooks dispatch exactly one update', async () => {
const fetchMock = makeMockFetch()
const onSessionChange =
jest.fn<
(bundle: SessionBundle, did: string, event: AtpSessionEvent) => void
>()
const bundle = {} as SessionBundle
const hooks = makeSessionHooks(
onSessionChange,
() => bundle,
() => DID,
)
/*
* makeSessionHooks bakes in networkAwareFetch (the real global fetch);
* override it with the mock while keeping the arm-latched callbacks (they
* close over the same `armed` flag, so hooks.arm() below still applies).
*/
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{...hooks, fetch: asFetch(fetchMock)},
)
hooks.arm()
await session.refresh()
/* the refreshed tokens are live on the session */
expect(session.session.accessJwt).toBe('access-jwt-2')
/* exactly one 'update' event reached the reducer via the armed hooks */
expect(onSessionChange).toHaveBeenCalledTimes(1)
expect(onSessionChange.mock.calls[0][2]).toBe('update')
/* the callback's return value is the post-refresh SessionAccount snapshot */
const snapshot = sessionDataToSessionAccount(
session.session,
session.session.service,
)!
expect(snapshot.accessJwt).toBe('access-jwt-2')
expect(snapshot.refreshJwt).toBe('refresh-jwt-2')
})
it('propagates a rejection from refresh() (invalid session)', async () => {
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
new Response(
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
{status: 400, headers: {'content-type': 'application/json'}},
),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock)},
)
await expect(session.refresh()).rejects.toBeDefined()
})
})
+4 -4
View File
@@ -1,9 +1,9 @@
import {type AtpAgent} from '@atproto/api'
import {type SessionData} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals'
import {sessionDataToSessionAccount} from '../agent'
import {type TemporaryPushClient} from '#/lib/notifications/notifications'
import {type Action, getInitialState, reducer, type State} from '../reducer'
import {sessionDataToSessionAccount} from '../session-core'
import {type SessionAccount} from '../types'
jest.mock('jwt-decode', () => ({
@@ -18,7 +18,7 @@ jest.mock('../../../ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
}))
jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken(_agents: AtpAgent[]) {
unregisterPushToken(_clients: TemporaryPushClient[]) {
return Promise.resolve()
},
}))
@@ -1600,7 +1600,7 @@ describe('session', () => {
expect(state.accounts[1].did).toBe('bob-did')
expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2')
// Keep Bob logged in.
// (We patch up agent.session outside the reducer for this to work.)
// (The bundle is rebuilt from the synced tokens outside the reducer.)
expect(state.currentAgentState.did).toBe('bob-did')
expect(state.needsPersist).toBe(false)
expect(printState(state)).toMatchInlineSnapshot(`
@@ -1,7 +1,6 @@
import {Client} from '@atproto/lex-client'
import {device} from '#/storage'
import {BridgeAgent} from './session-core'
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil
export const DE_LABELER = 'did:plc:r55ow3tocux5kafs5dq445fy' // Germany
@@ -84,15 +83,13 @@ export function configureAdditionalModerationAuthorities() {
}
/*
* Merge with the currently-configured global labelers on the base `Agent`
* static (the bridge agent is a base `Agent`, not `AtpAgent`). Set the merged
* result on BOTH request paths - the lex `Client` static and the base `Agent`
* static - so both emit identical global `atproto-accept-labelers` headers.
* Merge with the currently-configured global labelers on the lex `Client`
* static and set the merged result back, so every client emits the same
* global `atproto-accept-labelers` header.
*/
const appLabelers = Array.from(
new Set([...BridgeAgent.appLabelers, ...additionalLabelers]),
new Set([...Client.appLabelers, ...additionalLabelers]),
)
Client.configure({appLabelers: appLabelers as `did:${string}:${string}`[]})
BridgeAgent.configure({appLabelers})
}
-45
View File
@@ -1,45 +0,0 @@
import {
Agent as BaseAgent,
type AtprotoServiceType,
type Did,
} from '@atproto/api'
import {createPublicSessionBundle, type SessionAgent} from './session-core'
/*
* Phase 2 SDK migration: the PasswordSession-based session core (factories,
* bridge agent, converters) lives in session-core.ts. This module is now a
* thin compat layer that keeps the few external imports working:
* - `createPublicAgent` (drafts) -> the public bundle's bridge agent
* - `Agent` (pds-detection / forgot-password / set-new-password) -> the
* proxy-header base Agent subclass
* - `ProxyHeaderValue` (constants)
* - the new converters, re-exported under their own names
*/
export {
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-core'
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
/**
* The logged-out bridge agent, pointed at the public appview. Returns the
* public bundle's `agent` (a {@link SessionAgent}). Kept for `drafts/state/api`
* and any other public-read consumers.
*/
export function createPublicAgent(): SessionAgent {
return createPublicSessionBundle().agent
}
export class Agent extends BaseAgent {
constructor(
proxyHeader: ProxyHeaderValue | null,
...options: ConstructorParameters<typeof BaseAgent>
) {
super(...options)
if (proxyHeader) {
this.configureProxy(proxyHeader)
}
}
}
+15 -56
View File
@@ -1,59 +1,10 @@
import {type AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {type PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {networkAwareFetch} from './session-core'
/**
* Stable per-agent cache of lex `Client` instances. We never reuse an
* `AtpAgent` (the session provider disposes the previous one on switch), so a
* `WeakMap` keyed on the agent gives us a client whose identity is stable for
* the lifetime of that agent. This keeps React Query keys and hook consumers
* from churning on every render.
*/
const clientForAgent = new WeakMap<AtpAgent, Client>()
/**
* Wrap a live {@link AtpAgent} as a lex {@link Client}, bridging the old session
* source of truth to the new SDK so features can migrate incrementally.
*
* The client talks to the agent through the minimal lex `Agent` interface
* (`{ did, fetchHandler }`). We deliberately route `fetchHandler` through
* `agent.fetchHandler` (the agent's XRPC dispatch layer) rather than
* `agent.sessionManager.fetchHandler`:
*
* - `agent.fetchHandler` (see @atproto/api `agent.js`) is where the agent
* applies its configured `atproto-proxy` header (set via `configureProxy` to
* the appview) and its `atproto-accept-labelers` header, before delegating to
* `sessionManager.fetchHandler` for authorization + token refresh.
* - `sessionManager.fetchHandler` (`CredentialSession`) only adds the auth
* token and handles refresh - it does NOT proxy or attach labelers. Wrapping
* it directly would silently drop appview proxying and moderation labelers.
*
* Because the wrapped agent already applies proxy + labeler headers, we do NOT
* pass a `service` option to the `Client` (lex-client only sets `atproto-proxy`
* when `service` is provided) and we leave `Client.appLabelers` at its default
* empty set. This avoids double-setting either header.
*
* Results are memoized per-agent so the returned client is referentially stable.
*/
export function agentToLexClient(agent: AtpAgent): Client {
const cached = clientForAgent.get(agent)
if (cached) {
return cached
}
const client = new Client({
get did() {
return agent.did
},
fetchHandler: (path, init) => agent.fetchHandler(path, init),
})
clientForAgent.set(agent, client)
return client
}
/**
* Lazily-constructed unauthenticated client pointed at the public appview. It
* hits {@link PUBLIC_BSKY_SERVICE} directly, mirroring `createPublicAgent`'s
@@ -135,18 +86,26 @@ export function getUnauthenticatedClient(): Client {
/**
* Build the authed appview client over a {@link PasswordSession}.
*
* Requests are proxied to the Bluesky appview (`atproto-proxy:
* did:web:api.bsky.app#bsky_appview`) and carry the per-instance labelers.
* The Bluesky moderation labeler (`api.moderation.did`) is always included as
* a base labeler because sending ANY `atproto-accept-labelers` header replaces
* the server-side default - so we must re-assert it to keep it active.
* Requests are proxied to the Bluesky appview and carry the per-instance
* labelers. The Bluesky moderation labeler (`api.moderation.did`) is always
* included as a base labeler because sending ANY `atproto-accept-labelers`
* header replaces the server-side default - so we must re-assert it to keep it
* active.
*
* The proxy `service` is read from `BLUESKY_PROXY_HEADER.get()` (rather than
* hard-coding `api.app.service`). Its default value equals `api.app.service`
* (`did:web:api.bsky.app#bsky_appview`), so production is unchanged; the getter
* exists so the e2e `TestCtrls` hack can retarget the appview by calling
* `BLUESKY_PROXY_HEADER.set()` before sign-in (the client is built at
* sign-in, so it picks up the override).
*/
export function buildAppviewClient(
session: PasswordSession,
labelerDids: string[],
): Client {
return new Client(session, {
service: api.app.service,
/* BLUESKY_PROXY_HEADER.get() is a `did:...#...` ProxyHeaderValue, assignable to Service */
service: BLUESKY_PROXY_HEADER.get(),
/* labelerDids are validated DID strings; cast to the DidString template type */
labelers: [
api.moderation.did,
+53 -70
View File
@@ -30,11 +30,9 @@ import {
makeSessionHooks,
type PublicSessionBundle,
sessionAccountToSessionData,
type SessionAgent,
type SessionBundle,
sessionDataToSessionAccount,
} from './session-core'
export {type SessionAgent} from './session-core'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
@@ -57,15 +55,10 @@ const StateContext = createContext<SessionStateContext>({
})
StateContext.displayName = 'SessionStateContext'
const AgentContext = createContext<SessionAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
/**
* Holds the full {@link SessionBundle} (or the logged-out
* {@link PublicSessionBundle}) for the active account. The three-client hooks
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here, while
* `useAgent()` continues to read the bridge agent from {@link AgentContext}
* (which is just `bundle.agent`).
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here.
*/
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null,
@@ -133,16 +126,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const state = useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = useCallback(
const onSessionChange = useCallback(
(
agent: SessionAgent,
bundle: SessionBundle,
accountDid: string,
sessionEvent: AtpSessionEvent,
) => {
// Snapshot the (mutable) live session data right away.
const refreshedAccount = agent.session
? sessionDataToSessionAccount(agent.session, agent.session.service)
: undefined
const refreshedAccount =
bundle.session && !bundle.session.destroyed
? sessionDataToSessionAccount(
bundle.session.session,
bundle.session.session.service,
)
: undefined
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
emitSessionDropped()
}
@@ -150,20 +147,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* The reducer stores the whole bundle as `currentAgentState.agent` and
* compares `action.agent` by identity to decide whether an expiry/error
* belongs to the active account (background accounts must not be able to
* log the current user out). The hook hands us the SessionAgent that
* fired; map it back to the current bundle when it is the active one, and
* otherwise pass the SessionAgent itself as a distinct, non-matching token
* so the reducer's guard ignores clears for background accounts - matching
* the pre-migration semantics exactly.
* log the current user out). The hook now hands us the bundle that fired,
* so it IS the identity token: a same-bundle event acts on the active
* account, a stale (background) bundle does not match and its clears are
* ignored - matching the pre-migration semantics exactly.
*/
const stored = store.getState().currentAgentState
.agent as unknown as SessionBundle
const eventAgent = (stored.agent === agent
? stored
: agent) as unknown as SessionBundle
store.dispatch({
type: 'received-agent-event',
agent: eventAgent,
agent: bundle,
refreshedAccount,
accountDid,
sessionEvent,
@@ -179,7 +170,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
ax.metric('account:create:begin', {})
const {bundle, account} = await createSessionBundleAndCreateAccount(
params,
onAgentSessionChange,
onSessionChange,
)
if (signal.aborted) {
@@ -195,7 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
},
[ax, store, onAgentSessionChange, cancelPendingTask],
[ax, store, onSessionChange, cancelPendingTask],
)
const login = useCallback<SessionApiContext['login']>(
@@ -204,7 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signal = cancelPendingTask()
const {bundle, account} = await createSessionBundleAndLogin(
params,
onAgentSessionChange,
onSessionChange,
)
if (signal.aborted) {
@@ -222,7 +213,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
addSessionDebugLog({type: 'method:end', method: 'login', account})
},
[ax, store, onAgentSessionChange, cancelPendingTask],
[ax, store, onSessionChange, cancelPendingTask],
)
const logoutCurrentAccount = useCallback<
@@ -301,7 +292,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signal = cancelPendingTask()
const {bundle, account} = await createSessionBundleAndResume(
storedAccount,
onAgentSessionChange,
onSessionChange,
)
if (signal.aborted) {
@@ -318,7 +309,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
onboardingDispatch({type: 'skip'})
}
},
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
[store, onSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = useCallback<
@@ -330,14 +321,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* Fetch through the account (PDS) client and dispatch the patch. We do NOT
* mutate the session object anymore (PasswordSession's data is immutable to
* us); the reducer patches only the `accounts` entry, and the email-state
* hook reads from the account rather than `agent.session` (Task 7).
* hook reads from the account rather than the session.
* `client.call` returns the response body directly (no `{data}` wrapper).
*/
const data = await bundle.accountClient.call(com.atproto.server.getSession)
if (signal.aborted) return
store.dispatch({
type: 'partial-refresh-session',
accountDid: bundle.agent.session!.did,
accountDid: bundle.session.did,
patch: {
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
@@ -408,20 +399,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} else {
/*
* Same account, new tokens synced from the leader tab. PasswordSession
* is immutable (no in-place session patch like the old
* `agent.sessionManager.session = ...`), so rebuild a fresh bundle
* is immutable (no in-place session patch), so rebuild a fresh bundle
* from the synced tokens WITHOUT a network call (the leader already
* refreshed) and swap it in via `replaced-current-bundle`. The
* bundle-identity effect disposes the previous session once it swaps,
* which strengthens the single-refresher guarantee (the stale-token
* session can no longer refresh).
*/
const prevBundle = state.currentAgentState
.agent as unknown as SessionBundle
const prevBundle = state.currentAgentState.agent as unknown as
| SessionBundle
| PublicSessionBundle
let newBundle!: SessionBundle
const hooks = makeSessionHooks(
onAgentSessionChange,
() => newBundle.agent,
onSessionChange,
() => newBundle,
() => syncedAccount.did,
)
const newSession = new PasswordSession(
@@ -432,9 +423,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
hooks.arm()
addSessionDebugLog({
type: 'agent:patch',
agent: newBundle.agent,
prevSession: prevBundle.agent.session,
nextSession: newBundle.agent.session,
agent: newBundle,
prevSession:
prevBundle.session && !prevBundle.session.destroyed
? prevBundle.session.session
: undefined,
nextSession: newBundle.session.session,
})
store.dispatch({
type: 'replaced-current-bundle',
@@ -444,7 +438,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
}
})
}, [store, state, resumeSession, onAgentSessionChange])
}, [store, state, resumeSession, onSessionChange])
const stateContext = useMemo(
() => ({
@@ -483,11 +477,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const bundle = state.currentAgentState.agent as unknown as
| SessionBundle
| PublicSessionBundle
const agent = bundle.agent
// @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability
if (__DEV__ && IS_WEB) window.agent = agent
if (__DEV__ && IS_WEB) window.bundle = bundle
const currentBundleRef = useRef(bundle)
useEffect(() => {
@@ -497,8 +490,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
currentBundleRef.current = bundle
addSessionDebugLog({
type: 'agent:switch',
prevAgent: prevBundle.agent,
nextAgent: bundle.agent,
prevAgent: prevBundle,
nextAgent: bundle,
})
// We never reuse bundles so let's fully neutralize the previous one.
// This ensures its session won't try to consume any refresh tokens.
@@ -507,22 +500,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [bundle])
return (
<AgentContext.Provider value={agent}>
<BundleContext.Provider value={bundle}>
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>
<AnalyticsContext
metadata={utils.useMeta({
session: utils.accountToSessionMetadata(
stateContext.currentAccount,
),
})}>
{children}
</AnalyticsContext>
</ApiContext.Provider>
</StateContext.Provider>
</BundleContext.Provider>
</AgentContext.Provider>
<BundleContext.Provider value={bundle}>
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>
<AnalyticsContext
metadata={utils.useMeta({
session: utils.accountToSessionMetadata(
stateContext.currentAccount,
),
})}>
{children}
</AnalyticsContext>
</ApiContext.Provider>
</StateContext.Provider>
</BundleContext.Provider>
)
}
@@ -564,14 +555,6 @@ export function useRequireAuth() {
)
}
export function useAgent(): SessionAgent {
const agent = useContext(AgentContext)
if (!agent) {
throw Error('useAgent() must be below <SessionProvider>.')
}
return agent
}
/**
* Authenticated lex {@link Client} for appview reads. Backed by the active
* bundle's appview client (proxied to the Bluesky appview, with labelers). Its
+7 -4
View File
@@ -1,5 +1,3 @@
import {type SessionData} from '@atproto/lex-password-session'
import {type Schema} from '../persisted'
import {type Action, type State} from './reducer'
import {type AtpSessionEvent} from './session-core'
@@ -52,10 +50,15 @@ type Log =
nextAgent: object
}
| {
/*
* Dev-only bundle-swap log. The bundle is treated as an opaque object
* (the reducer never reads its internals); the session snapshots are
* plain objects captured for debugging.
*/
type: 'agent:patch'
agent: object
prevSession: SessionData | undefined
nextSession: SessionData | undefined
prevSession: object | undefined
nextSession: object | undefined
}
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
+15 -28
View File
@@ -5,33 +5,23 @@ import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {readLabelers} from './agent-config'
import {BridgeAgent, type SessionBundle} from './session-core'
import {type SessionBundle} from './session-core'
import {type SessionAccount} from './types'
/*
* The Bluesky moderation labeler DID. The old `BSKY_LABELER_DID` (from
* '@atproto/api') and `api.moderation.did` (from '@bsky.app/sdk') are the SAME
* value - `did:plc:ar7c4by46qjdydhdevvrndac` - verified at implementation. We
* use `api.moderation.did` everywhere: the global appLabelers config, the
* per-account filter, and the appview client's base labeler (matching
* `buildAppviewClient`); all resolve to identical `atproto-accept-labelers`
* headers.
* The Bluesky moderation labeler DID is `api.moderation.did` (from
* '@bsky.app/sdk'), value `did:plc:ar7c4by46qjdydhdevvrndac`. We use it
* everywhere: the global appLabelers config, the per-account filter, and the
* appview client's base labeler (matching `buildAppviewClient`); all resolve to
* identical `atproto-accept-labelers` headers.
*/
/**
* Set the global app labelers on BOTH request paths so they emit identical
* `atproto-accept-labelers` headers.
*
* The migration runs two live request stacks this phase: lex `Client`s (whose
* global labelers live on the static `Client.appLabelers`) and the bridge
* `SessionAgent`, a base `Agent` from '@atproto/api' (whose global labelers
* live on the static `Agent.appLabelers`, NOT `AtpAgent`'s). We must configure
* both so a request routed through either path carries the same global
* (`;redact`-suffixed) labelers.
* Set the global app labelers on the lex `Client` static so every client emits
* the same global (`;redact`-suffixed) `atproto-accept-labelers` header.
*/
function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
BridgeAgent.configure({appLabelers: dids})
}
export function configureModerationForGuest() {
@@ -44,10 +34,8 @@ export function configureModerationForGuest() {
/**
* Configure moderation labelers for a signed-in account.
*
* Takes the whole {@link SessionBundle} because per-account labelers must be
* applied to BOTH live request paths: the bridge agent (`bundle.agent`, still
* used by `useAgent()` consumers) and the authed appview client
* (`bundle.appviewClient`, backing `useLexClient()`).
* Takes the whole {@link SessionBundle} so it can apply per-account labelers to
* the authed appview client (`bundle.appviewClient`, backing `useLexClient()`).
*/
export async function configureModerationForAccount(
bundle: SessionBundle,
@@ -65,13 +53,12 @@ export async function configureModerationForAccount(
if (labelerDids) {
const perAccount = labelerDids.filter(did => did !== api.moderation.did)
/*
* Apply the per-account labelers to both live request paths. The appview
* client re-asserts the Bluesky moderation labeler as its base because
* sending ANY `atproto-accept-labelers` header replaces the server-side
* default - `setLabelers` clears then re-adds, so the moderation DID must
* be included explicitly to stay active.
* Apply the per-account labelers to the appview client. It re-asserts the
* Bluesky moderation labeler as its base because sending ANY
* `atproto-accept-labelers` header replaces the server-side default -
* `setLabelers` clears then re-adds, so the moderation DID must be included
* explicitly to stay active.
*/
bundle.agent.configureLabelers(perAccount)
bundle.appviewClient.setLabelers([
api.moderation.did,
...perAccount,
+4 -4
View File
@@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util'
import {wrapSessionReducerForLogging} from './logging'
import {type AtpSessionEvent, createPublicSessionBundle} from './session-core'
import {type SessionAccount} from './types'
import {createTemporaryAgentsAndResume} from './util'
import {createTemporaryClientsAndResume} from './util'
/*
* A hack so that the reducer can't read anything from the session bundle. From
@@ -170,7 +170,7 @@ let reducer = (state: State, action: Action): State => {
// side effect
const account = state.accounts.find(a => a.did === accountDid)
if (account) {
createTemporaryAgentsAndResume([account])
createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents))
.then(() =>
logger.debug('Push token unregistered', {did: accountDid}),
@@ -198,7 +198,7 @@ let reducer = (state: State, action: Action): State => {
// side effect
const account = state.accounts.find(a => a.did === accountDid)
if (account && accountDid) {
createTemporaryAgentsAndResume([account])
createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents))
.then(() =>
logger.debug('Push token unregistered', {did: accountDid}),
@@ -226,7 +226,7 @@ let reducer = (state: State, action: Action): State => {
}
}
case 'logged-out-every-account': {
createTemporaryAgentsAndResume(state.accounts)
createTemporaryClientsAndResume(state.accounts)
.then(agents => unregisterPushToken(agents))
.then(() => logger.debug('Push token unregistered'))
.catch(err => {
+85 -224
View File
@@ -1,9 +1,3 @@
import {
Agent,
type AppBskyActorProfile,
type AtpSessionEvent,
type Un$Typed,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex-client'
import {
@@ -11,11 +5,16 @@ import {
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {toDatetimeString} from '@atproto/syntax'
import {
overwriteSavedFeeds,
setPersonalDetails,
upsertProfile,
} from '@bsky.app/sdk'
import {jwtDecode} from 'jwt-decode'
import {networkRetry} from '#/lib/async/retry'
import {
BLUESKY_PROXY_HEADER,
BSKY_SERVICE,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
@@ -35,6 +34,7 @@ import {
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {type app} from '#/lexicons'
import {
buildAccountClient,
buildAppviewClient,
@@ -50,15 +50,22 @@ import {
import {type SessionAccount} from './types'
import {isSessionExpired} from './util'
/*
* Re-exported so session-layer siblings (index.tsx, reducer.ts, logging.ts,
* moderation.ts, additional-moderation-authorities.ts) import the bridge
* vocabulary through this whitelisted bridge module rather than from
* '@atproto/api' directly. `Agent` is re-exported as `BridgeAgent` for the
* labeler-config statics (`Agent.configure`/`Agent.appLabelers`). Dies with
* the bridge in Phase 4.
/**
* The session-change events the reducer/logging/tests speak.
*
* Formerly re-exported from the legacy API package; defined locally now that
* the bridge is gone. These are the exact union members the reducer switches
* on. In
* production only `'update'`/`'expired'`/`'network-error'` are ever emitted from
* {@link makeSessionHooks}; `'create'`/`'create-failed'` remain in the type for
* the reducer and the session tests.
*/
export {type AtpSessionEvent, Agent as BridgeAgent} from '@atproto/api'
export type AtpSessionEvent =
| 'create'
| 'create-failed'
| 'update'
| 'expired'
| 'network-error'
/**
* Whether an access token was issued for a queued (waitlisted) signup rather
@@ -251,164 +258,29 @@ export function sessionAccountToSessionData(
}
/**
* Read `session.did` without throwing.
* The service (entryway) URL for a session, or the public appview URL when
* logged out / destroyed.
*
* `PasswordSession.did` throws `Error('Logged out')` once the session is
* destroyed, but base `Agent`'s `did` getter must never throw (it is read all
* over the app, including by late readers after logout). This returns
* `undefined` for a destroyed/absent session.
* Byte-identical to the derivation the old service getter used: a
* `new URL(...)` over `session.session.service` when the session is live, else
* `PUBLIC_BSKY_SERVICE`. Used for the {@link SessionBundle.service} getter.
*/
function safeDid(
session: PasswordSession | null,
): SessionData['did'] | undefined {
if (!session || session.destroyed) {
return undefined
}
return session.did
}
/**
* The legacy bridge agent returned by `useAgent()`.
*
* It is a real base `Agent` (from `@atproto/api`) whose fetch layer is a
* `PasswordSession` - reproducing today's exact two-layer model: base-Agent
* proxy/labeler layer on top, `PasswordSession` auth+refresh layer underneath.
* On top of that it adds a small CredentialSession-compat shim (`session`,
* `serviceUrl`, `pdsUrl`, `dispatchUrl`, `resumeSession`, `sessionManager`) so
* the ~28 `.session` reads and 6 `resumeSession` callers across the app compile
* and behave unchanged without a call-site migration.
*
* `#session` is null for the logged-out/public agent.
*/
/**
* The CredentialSession-compat facade exposed as `SessionAgent.sessionManager`.
* A handful of sites read `agent.sessionManager.{did,fetchHandler,
* refreshSession,session}` directly (birthdate.ts, ExportCarDialog,
* ageAssurance/data); this is a live view over the underlying
* `PasswordSession`.
*/
type SessionManagerFacade = {
readonly did: string | undefined
fetchHandler: (path: string, init: RequestInit) => Promise<Response>
refreshSession: () => Promise<SessionData>
readonly session: SessionData | undefined
}
/**
* Build the sessionManager facade for a session (or the logged-out fallback).
* This object is passed straight to base `Agent`'s constructor as its
* `SessionManager`, so base's request path (`this.sessionManager.fetchHandler`)
* and `did` getter route through it - and the richer members
* (`refreshSession`/`session`) are available to the hard-tail consumers.
*/
function makeSessionManagerFacade(
session: PasswordSession | null,
): SessionManagerFacade {
const s = session
return {
get did() {
return safeDid(s)
},
fetchHandler: (path: string, init: RequestInit) =>
(s ?? getPublicLexClient()).fetchHandler(path as `/${string}`, init),
refreshSession: () => s!.refresh(),
get session() {
return s && !s.destroyed ? s.session : undefined
},
}
}
/*
* Declaration merging: widen the inherited `sessionManager` (base types it as
* the minimal `SessionManager`) to the richer facade the shim actually stores.
* This exposes `refreshSession`/`session` to the hard-tail consumers
* (birthdate.ts, ExportCarDialog, ageAssurance/data) without a property vs
* accessor override (which TS/babel reject - risk #3 in the design doc). The
* facade is genuinely assigned via super(makeSessionManagerFacade(...)), so the
* merge is sound despite the generic lint warning.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface SessionAgent {
readonly sessionManager: SessionManagerFacade
}
export class SessionAgent extends Agent {
#session: PasswordSession | null
constructor(session: PasswordSession | null) {
/*
* The facade IS base Agent's SessionManager: base routes every request
* through `this.sessionManager.fetchHandler` and reads `did` from it. For a
* logged-out agent the facade routes to the public client (same public
* appview service), so base's proxy/labeler layer still applies on top,
* matching the old createPublicAgent behavior.
*/
super(makeSessionManagerFacade(session))
this.#session = session
}
/**
* The live `SessionData` for the current session, or `undefined` when logged
* out. Reads through to the `PasswordSession`, so `.did`/`.handle`/`.email`
* etc. are always current.
*/
get session(): SessionData | undefined {
return this.#session && !this.#session.destroyed
? this.#session.session
: undefined
}
/** The account's service (entryway) URL. */
get serviceUrl(): URL {
return new URL(
this.#session && !this.#session.destroyed
? this.#session.session.service
: PUBLIC_BSKY_SERVICE,
)
}
/**
* The PDS URL derived from the session's didDoc, or `undefined` when there is
* no didDoc PDS entry (hosted accounts) - matching the old
* `agent.pdsUrl?.toString()` semantics.
*/
get pdsUrl(): URL | undefined {
if (!this.#session || this.#session.destroyed) {
return undefined
}
const pds = extractPdsUrl(this.#session.session.didDoc)
return pds ? new URL(pds) : undefined
}
/**
* The URL requests are dispatched to: the PDS if known, else the service.
* Matches AtpAgent's `dispatchUrl` semantics.
*/
get dispatchUrl(): URL {
return this.pdsUrl ?? this.serviceUrl
}
/**
* CredentialSession-compat: force a refresh and return an AtpAgent-shaped
* result. The argument (the old `agent.session`) is ignored - the session
* already knows its own tokens.
*/
async resumeSession(_?: unknown) {
await this.#session!.refresh()
return {success: true as const, data: this.#session!.session}
}
function deriveServiceUrl(session: PasswordSession | null): URL {
return new URL(
session && !session.destroyed
? session.session.service
: PUBLIC_BSKY_SERVICE,
)
}
/**
* The full set of read-through views over ONE `PasswordSession`. The
* `session` is the sole auth core (single refresher); the `agent` and both
* clients never refresh independently.
* `session` is the sole auth core (single refresher); the clients never refresh
* independently.
*/
export type SessionBundle = {
/** The single auth core. Never exposed to the reducer. */
session: PasswordSession
/** Legacy bridge agent for `useAgent()` consumers. */
agent: SessionAgent
/** Account (writes/records) client - talks to the user's PDS. */
accountClient: Client
/** Authed appview client (proxied, with labelers). */
@@ -416,56 +288,52 @@ export type SessionBundle = {
/** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */
chatClient: Client
/**
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
* reducer can read `.service` for its opaque snapshot/logging view
* (`OpaqueSessionBundle = {readonly service: URL}`) without reaching into the
* agent or the (never-exposed) session.
* The service (entryway) URL. Exposed so the reducer can read `.service` for
* its opaque snapshot/logging view (`OpaqueSessionBundle = {readonly service:
* URL}`) without reaching into the (never-exposed) session.
*/
readonly service: URL
}
/**
* Assemble a {@link SessionBundle} from a live session: the bridge agent plus
* the account and appview clients, all read-through views over the one session.
* The Bluesky appview proxy header is applied to the bridge (matching the old
* `agent.configureProxy(BLUESKY_PROXY_HEADER.get())`).
* Assemble a {@link SessionBundle} from a live session: the account, appview,
* and chat clients, all read-through views over the one session. The appview
* proxy header is baked into `buildAppviewClient` (`service: api.app.service`),
* so no separate proxy configuration is needed here.
*/
export function buildBundle(session: PasswordSession): SessionBundle {
const agent = new SessionAgent(session)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return {
session,
agent,
accountClient: buildAccountClient(session),
/*
* Per-account labelers are applied to the bridge agent by
* configureModerationForAccount for now; the appview client carries only
* the base Bluesky moderation labeler. TODO(phase-2 moderation task):
* rework moderation.ts to take the bundle and set per-account labelers on
* appviewClient too.
* Per-account labelers are applied to the appview client by
* configureModerationForAccount; buildAppviewClient carries only the base
* Bluesky moderation labeler until then.
*/
appviewClient: buildAppviewClient(session, []),
chatClient: buildChatClient(session),
/*
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
* read `.service`. A getter keeps it live with the agent's derivation.
* Derived from the session so the reducer's opaque view can read `.service`.
* A getter keeps it live with the session's state (destroyed -> public).
*/
get service() {
return agent.serviceUrl
return deriveServiceUrl(session)
},
}
}
/**
* The session-change events the reducer speaks. `PasswordSession` surfaces
* three hooks (`onUpdated`/`onDeleted`/`onUpdateFailure`) which we map into
* this `AtpSessionEvent` vocabulary (see the table in the phase-2 design doc):
* refresh -> `'update'`, dead session/logout -> `'expired'`, transient failure
* -> `'network-error'`. `'create'`/`'create-failed'` remain in the type for the
* reducer/tests but are never emitted from here in production.
* The session-change callback the provider passes into the hooks.
*
* `PasswordSession` surfaces three hooks (`onUpdated`/`onDeleted`/
* `onUpdateFailure`) which {@link makeSessionHooks} maps into the
* {@link AtpSessionEvent} vocabulary: refresh -> `'update'`, dead session/logout
* -> `'expired'`, transient failure -> `'network-error'`. The whole
* {@link SessionBundle} is handed through so the provider can snapshot the live
* session and use the bundle itself as the reducer's identity token.
*/
type OnSessionChange = (
agent: SessionAgent,
bundle: SessionBundle,
did: string,
event: AtpSessionEvent,
) => void
@@ -480,14 +348,15 @@ type OnSessionChange = (
* during `prepare()`. So hooks are inert until `arm()` is called, after the
* prepare tail resolves.
*
* `getAgent` is deferred because the bridge agent does not exist yet when the
* hooks are constructed (the session is created first).
* `getBundle` is deferred because the bundle does not exist yet when the hooks
* are constructed (the session is created first, then the bundle is built over
* it).
*
* Exported for testing (the arm-latch + event mapping is the core semantics).
*/
export function makeSessionHooks(
onSessionChange: OnSessionChange,
getAgent: () => SessionAgent,
getBundle: () => SessionBundle,
getDid: () => string,
) {
let armed = false
@@ -496,7 +365,7 @@ export function makeSessionHooks(
return
}
const did = getDid()
onSessionChange(getAgent(), did, event)
onSessionChange(getBundle(), did, event)
/*
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
* session event. In practice we only emit 'update'/'expired'/'network-error'
@@ -526,12 +395,11 @@ export function makeSessionHooks(
}
/**
* The public (logged-out) bundle. Its bridge agent points at the public
* appview and all clients are unauthenticated.
* The public (logged-out) bundle. Its appview client points at the public
* appview; the write/chat clients are the throwing unauthenticated client.
*/
export type PublicSessionBundle = {
session: null
agent: SessionAgent
accountClient: Client
appviewClient: Client
/**
@@ -541,23 +409,19 @@ export type PublicSessionBundle = {
* and design section J.
*/
chatClient: Client
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
/** The public appview URL. See {@link SessionBundle.service}. */
readonly service: URL
}
/**
* Build the logged-out bundle used before/without a session. Mirrors the old
* `createPublicAgent`: configures guest moderation as a side effect and applies
* the Bluesky appview proxy header to the bridge agent.
* Build the logged-out bundle used before/without a session. Configures guest
* moderation as a side effect.
*/
export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest() // Side effect but only relevant for tests
const agent = new SessionAgent(null)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
const publicClient = getPublicLexClient()
return {
session: null,
agent,
/*
* Write/auth clients throw on use when logged out (design section J): the
* public bundle exposes the throwing unauthenticated client for the account
@@ -568,9 +432,7 @@ export function createPublicSessionBundle(): PublicSessionBundle {
accountClient: getUnauthenticatedClient(),
appviewClient: publicClient,
chatClient: getUnauthenticatedClient(),
get service() {
return agent.serviceUrl
},
service: new URL(PUBLIC_BSKY_SERVICE),
}
}
@@ -590,7 +452,7 @@ export async function createSessionBundleAndResume(
let bundle!: SessionBundle
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => bundle,
() => storedAccount.did,
)
@@ -651,7 +513,7 @@ export async function createSessionBundleAndLogin(
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => bundle,
() => accountDid,
)
@@ -686,7 +548,7 @@ export async function createSessionBundleAndLogin(
* created-at/birthdate, the prod vs non-prod deferred server-write block
* (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids,
* restrictChatSettings gated on AA flags), and snoozeEmailConfirmationPrompt.
* The deferred writes run against the bridge agent's sugar methods.
* The deferred writes run as SDK actions against the account (PDS) client.
*/
export async function createSessionBundleAndCreateAccount(
{
@@ -714,7 +576,7 @@ export async function createSessionBundleAndCreateAccount(
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => bundle,
() => accountDid,
)
@@ -734,12 +596,11 @@ export async function createSessionBundleAndCreateAccount(
bundle = buildBundle(session)
const account = sessionDataToSessionAccountOrThrow(session)
accountDid = account.did
const agent = bundle.agent
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, account)
const createdAt = new Date().toISOString()
const createdAt = toDatetimeString(new Date())
const birthdate = birthDate.toISOString()
/*
@@ -762,8 +623,8 @@ export async function createSessionBundleAndCreateAccount(
if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthdate,
return bundle.accountClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
logger.info(
@@ -772,8 +633,8 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
return bundle.accountClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
@@ -785,7 +646,7 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(1, () => {
return agent.overwriteSavedFeeds([
return bundle.accountClient.call(overwriteSavedFeeds, [
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
@@ -823,8 +684,8 @@ export async function createSessionBundleAndCreateAccount(
} else {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthDate.toISOString(),
return bundle.accountClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
logger.info(
@@ -833,9 +694,9 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString()
return bundle.accountClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.createdAt = prev?.createdAt || toDatetimeString(new Date())
return next
})
}).catch(e => {
@@ -894,8 +755,8 @@ function sessionDataToSessionAccountOrThrow(
* is that this session's tokens are no longer reachable by any live client. We
* do NOT call `logout()` here: disposal is a local switch, not a server-side
* revocation (revocation is handled separately via the push-token unregister
* temporary sessions). The bridge agent stays usable enough (its `did`/session
* getters return undefined) not to crash late readers.
* temporary sessions). The bundle's clients stay usable enough not to crash
* late readers.
*/
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
const session = bundle.session
+3 -3
View File
@@ -9,8 +9,8 @@ import {type SessionAccount} from './types'
/*
* Canonical implementation moved to session-core.ts so that module stays
* dependency-light (this file pulls in agent.ts and, transitively, a large
* chunk of the app). Re-exported here for existing consumers.
* dependency-light (this file transitively pulls in a large chunk of the app).
* Re-exported here for existing consumers.
*/
export {isSignupQueued} from './session-core'
@@ -41,7 +41,7 @@ export function isSessionExpired(account: SessionAccount) {
* paired with the account's service origin and handle, matching the contract
* {@link unregisterPushToken} consumes.
*/
export async function createTemporaryAgentsAndResume(
export async function createTemporaryClientsAndResume(
accounts: SessionAccount[],
): Promise<TemporaryPushClient[]> {
const settled = await Promise.allSettled(