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 {Client} from '@atproto/lex-client'
import {PasswordSession} from '@atproto/lex-password-session' import {PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk' 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 * clients.ts imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load * 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', () => ({ jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(), emitNetworkConfirmed: jest.fn(),
@@ -280,54 +279,34 @@ describe('getPublicLexClient', () => {
/* /*
* Regression guard: the emitted `atproto-accept-labelers` header from a * Regression guard: the emitted `atproto-accept-labelers` header from a
* fully-configured appview client must match the header the old AtpAgent * fully-configured appview client must carry the exact byte-shape the old
* produced for the same labeler set - global appLabelers carry the `;redact` * AtpAgent produced - global appLabelers carry the `;redact` suffix, per
* suffix, per-instance labelers are plain. Byte-identical composition is the * -instance labelers are plain. The old AtpAgent reference implementation is
* acceptance bar for the moderation migration (design section 6). * gone with the bridge, so we assert the composition invariant directly (design
* section 6).
*/ */
describe('labeler-header regression guard', () => { 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 * buildAppviewClient re-asserts api.moderation.did as a base labeler; the
* with `;redact`; per-instance configureLabelers are plain. Capture what a * global Client.appLabelers carry the `;redact` suffix. Configure the global
* real AtpAgent emits for the same [custom] instance labeler set. * appLabelers to the Bluesky moderation DID (matching switchToBskyAppLabeler
* in moderation.ts) so the composition matches production.
*/ */
const {seen: agentSeen, fetchMock: agentFetch} = makeCapturingFetch() Client.configure({appLabelers: [api.moderation.did]})
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')
/* const {seen, fetchMock} = makeCapturingFetch()
* New behavior: buildAppviewClient re-asserts api.moderation.did (=== const session = makeSession(fetchMock)
* 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 client = buildAppviewClient(session, [CUSTOM_LABELER]) const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE}) .call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {}) .catch(() => {})
const newHeader = clientSeen[0].headers.get('atproto-accept-labelers') const header = seen[0].headers.get('atproto-accept-labelers')
/* /* the global Bluesky moderation labeler is redacted */
* Both must contain the redacted global Bluesky labeler and the plain expect(header).toContain(`${api.moderation.did};redact`)
* per-instance custom labeler. /* the per-instance custom labeler is present and plain (no redact) */
*/ expect(header).toContain(CUSTOM_LABELER)
expect(oldHeader).toContain(`${BSKY_LABELER_DID};redact`) expect(header).not.toContain(`${CUSTOM_LABELER};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`)
}) })
}) })
@@ -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' import {type SessionAccount} from '../types'
@@ -32,14 +37,18 @@ jest.mock('jwt-decode', () => ({
})) }))
import { import {
type AtpSessionEvent,
extractPdsUrl, extractPdsUrl,
makeSessionHooks,
sessionAccountToSessionData, sessionAccountToSessionData,
type SessionBundle,
sessionDataToSessionAccount, sessionDataToSessionAccount,
synthDidDoc, synthDidDoc,
} from '../session-core' } from '../session-core'
const DID = 'did:plc:example123' const DID = 'did:plc:example123'
const HANDLE = 'alice.test' const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network' const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData { function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
@@ -324,3 +333,283 @@ describe('sessionAccountToSessionData', () => {
expect(roundTripped).toEqual(selfHosted) 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 {type SessionData} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals' 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 {type Action, getInitialState, reducer, type State} from '../reducer'
import {sessionDataToSessionAccount} from '../session-core'
import {type SessionAccount} from '../types' import {type SessionAccount} from '../types'
jest.mock('jwt-decode', () => ({ jest.mock('jwt-decode', () => ({
@@ -18,7 +18,7 @@ jest.mock('../../../ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}}), unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
})) }))
jest.mock('#/lib/notifications/notifications', () => ({ jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken(_agents: AtpAgent[]) { unregisterPushToken(_clients: TemporaryPushClient[]) {
return Promise.resolve() return Promise.resolve()
}, },
})) }))
@@ -1600,7 +1600,7 @@ describe('session', () => {
expect(state.accounts[1].did).toBe('bob-did') expect(state.accounts[1].did).toBe('bob-did')
expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2') expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2')
// Keep Bob logged in. // 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.currentAgentState.did).toBe('bob-did')
expect(state.needsPersist).toBe(false) expect(state.needsPersist).toBe(false)
expect(printState(state)).toMatchInlineSnapshot(` expect(printState(state)).toMatchInlineSnapshot(`
@@ -1,7 +1,6 @@
import {Client} from '@atproto/lex-client' import {Client} from '@atproto/lex-client'
import {device} from '#/storage' import {device} from '#/storage'
import {BridgeAgent} from './session-core'
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil
export const DE_LABELER = 'did:plc:r55ow3tocux5kafs5dq445fy' // Germany 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` * Merge with the currently-configured global labelers on the lex `Client`
* static (the bridge agent is a base `Agent`, not `AtpAgent`). Set the merged * static and set the merged result back, so every client emits the same
* result on BOTH request paths - the lex `Client` static and the base `Agent` * global `atproto-accept-labelers` header.
* static - so both emit identical global `atproto-accept-labelers` headers.
*/ */
const appLabelers = Array.from( const appLabelers = Array.from(
new Set([...BridgeAgent.appLabelers, ...additionalLabelers]), new Set([...Client.appLabelers, ...additionalLabelers]),
) )
Client.configure({appLabelers: appLabelers as `did:${string}:${string}`[]}) 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 {Client} from '@atproto/lex-client'
import {type PasswordSession} from '@atproto/lex-password-session' import {type PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk' 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' 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 * Lazily-constructed unauthenticated client pointed at the public appview. It
* hits {@link PUBLIC_BSKY_SERVICE} directly, mirroring `createPublicAgent`'s * 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}. * Build the authed appview client over a {@link PasswordSession}.
* *
* Requests are proxied to the Bluesky appview (`atproto-proxy: * Requests are proxied to the Bluesky appview and carry the per-instance
* did:web:api.bsky.app#bsky_appview`) and carry the per-instance labelers. * labelers. The Bluesky moderation labeler (`api.moderation.did`) is always
* The Bluesky moderation labeler (`api.moderation.did`) is always included as * included as a base labeler because sending ANY `atproto-accept-labelers`
* a base labeler because sending ANY `atproto-accept-labelers` header replaces * header replaces the server-side default - so we must re-assert it to keep it
* the server-side default - so we must re-assert it to keep it active. * 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( export function buildAppviewClient(
session: PasswordSession, session: PasswordSession,
labelerDids: string[], labelerDids: string[],
): Client { ): Client {
return new Client(session, { 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 */ /* labelerDids are validated DID strings; cast to the DidString template type */
labelers: [ labelers: [
api.moderation.did, api.moderation.did,
+53 -70
View File
@@ -30,11 +30,9 @@ import {
makeSessionHooks, makeSessionHooks,
type PublicSessionBundle, type PublicSessionBundle,
sessionAccountToSessionData, sessionAccountToSessionData,
type SessionAgent,
type SessionBundle, type SessionBundle,
sessionDataToSessionAccount, sessionDataToSessionAccount,
} from './session-core' } from './session-core'
export {type SessionAgent} from './session-core'
export {isSignupQueued} from './util' export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging' import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types' export type {SessionAccount} from '#/state/session/types'
@@ -57,15 +55,10 @@ const StateContext = createContext<SessionStateContext>({
}) })
StateContext.displayName = 'SessionStateContext' StateContext.displayName = 'SessionStateContext'
const AgentContext = createContext<SessionAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
/** /**
* Holds the full {@link SessionBundle} (or the logged-out * Holds the full {@link SessionBundle} (or the logged-out
* {@link PublicSessionBundle}) for the active account. The three-client hooks * {@link PublicSessionBundle}) for the active account. The three-client hooks
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here, while * (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here.
* `useAgent()` continues to read the bridge agent from {@link AgentContext}
* (which is just `bundle.agent`).
*/ */
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>( const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null, null,
@@ -133,16 +126,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const state = useSyncExternalStore(store.subscribe, store.getState) const state = useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = useCallback( const onSessionChange = useCallback(
( (
agent: SessionAgent, bundle: SessionBundle,
accountDid: string, accountDid: string,
sessionEvent: AtpSessionEvent, sessionEvent: AtpSessionEvent,
) => { ) => {
// Snapshot the (mutable) live session data right away. // Snapshot the (mutable) live session data right away.
const refreshedAccount = agent.session const refreshedAccount =
? sessionDataToSessionAccount(agent.session, agent.session.service) bundle.session && !bundle.session.destroyed
: undefined ? sessionDataToSessionAccount(
bundle.session.session,
bundle.session.session.service,
)
: undefined
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') { if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
emitSessionDropped() emitSessionDropped()
} }
@@ -150,20 +147,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* The reducer stores the whole bundle as `currentAgentState.agent` and * The reducer stores the whole bundle as `currentAgentState.agent` and
* compares `action.agent` by identity to decide whether an expiry/error * compares `action.agent` by identity to decide whether an expiry/error
* belongs to the active account (background accounts must not be able to * belongs to the active account (background accounts must not be able to
* log the current user out). The hook hands us the SessionAgent that * log the current user out). The hook now hands us the bundle that fired,
* fired; map it back to the current bundle when it is the active one, and * so it IS the identity token: a same-bundle event acts on the active
* otherwise pass the SessionAgent itself as a distinct, non-matching token * account, a stale (background) bundle does not match and its clears are
* so the reducer's guard ignores clears for background accounts - matching * ignored - matching the pre-migration semantics exactly.
* 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({ store.dispatch({
type: 'received-agent-event', type: 'received-agent-event',
agent: eventAgent, agent: bundle,
refreshedAccount, refreshedAccount,
accountDid, accountDid,
sessionEvent, sessionEvent,
@@ -179,7 +170,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
ax.metric('account:create:begin', {}) ax.metric('account:create:begin', {})
const {bundle, account} = await createSessionBundleAndCreateAccount( const {bundle, account} = await createSessionBundleAndCreateAccount(
params, params,
onAgentSessionChange, onSessionChange,
) )
if (signal.aborted) { if (signal.aborted) {
@@ -195,7 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}) })
addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
}, },
[ax, store, onAgentSessionChange, cancelPendingTask], [ax, store, onSessionChange, cancelPendingTask],
) )
const login = useCallback<SessionApiContext['login']>( const login = useCallback<SessionApiContext['login']>(
@@ -204,7 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signal = cancelPendingTask() const signal = cancelPendingTask()
const {bundle, account} = await createSessionBundleAndLogin( const {bundle, account} = await createSessionBundleAndLogin(
params, params,
onAgentSessionChange, onSessionChange,
) )
if (signal.aborted) { if (signal.aborted) {
@@ -222,7 +213,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
addSessionDebugLog({type: 'method:end', method: 'login', account}) addSessionDebugLog({type: 'method:end', method: 'login', account})
}, },
[ax, store, onAgentSessionChange, cancelPendingTask], [ax, store, onSessionChange, cancelPendingTask],
) )
const logoutCurrentAccount = useCallback< const logoutCurrentAccount = useCallback<
@@ -301,7 +292,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signal = cancelPendingTask() const signal = cancelPendingTask()
const {bundle, account} = await createSessionBundleAndResume( const {bundle, account} = await createSessionBundleAndResume(
storedAccount, storedAccount,
onAgentSessionChange, onSessionChange,
) )
if (signal.aborted) { if (signal.aborted) {
@@ -318,7 +309,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
onboardingDispatch({type: 'skip'}) onboardingDispatch({type: 'skip'})
} }
}, },
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch], [store, onSessionChange, cancelPendingTask, onboardingDispatch],
) )
const partialRefreshSession = useCallback< 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 * Fetch through the account (PDS) client and dispatch the patch. We do NOT
* mutate the session object anymore (PasswordSession's data is immutable to * mutate the session object anymore (PasswordSession's data is immutable to
* us); the reducer patches only the `accounts` entry, and the email-state * 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). * `client.call` returns the response body directly (no `{data}` wrapper).
*/ */
const data = await bundle.accountClient.call(com.atproto.server.getSession) const data = await bundle.accountClient.call(com.atproto.server.getSession)
if (signal.aborted) return if (signal.aborted) return
store.dispatch({ store.dispatch({
type: 'partial-refresh-session', type: 'partial-refresh-session',
accountDid: bundle.agent.session!.did, accountDid: bundle.session.did,
patch: { patch: {
emailConfirmed: data.emailConfirmed, emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor, emailAuthFactor: data.emailAuthFactor,
@@ -408,20 +399,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} else { } else {
/* /*
* Same account, new tokens synced from the leader tab. PasswordSession * Same account, new tokens synced from the leader tab. PasswordSession
* is immutable (no in-place session patch like the old * is immutable (no in-place session patch), so rebuild a fresh bundle
* `agent.sessionManager.session = ...`), so rebuild a fresh bundle
* from the synced tokens WITHOUT a network call (the leader already * from the synced tokens WITHOUT a network call (the leader already
* refreshed) and swap it in via `replaced-current-bundle`. The * refreshed) and swap it in via `replaced-current-bundle`. The
* bundle-identity effect disposes the previous session once it swaps, * bundle-identity effect disposes the previous session once it swaps,
* which strengthens the single-refresher guarantee (the stale-token * which strengthens the single-refresher guarantee (the stale-token
* session can no longer refresh). * session can no longer refresh).
*/ */
const prevBundle = state.currentAgentState const prevBundle = state.currentAgentState.agent as unknown as
.agent as unknown as SessionBundle | SessionBundle
| PublicSessionBundle
let newBundle!: SessionBundle let newBundle!: SessionBundle
const hooks = makeSessionHooks( const hooks = makeSessionHooks(
onAgentSessionChange, onSessionChange,
() => newBundle.agent, () => newBundle,
() => syncedAccount.did, () => syncedAccount.did,
) )
const newSession = new PasswordSession( const newSession = new PasswordSession(
@@ -432,9 +423,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
hooks.arm() hooks.arm()
addSessionDebugLog({ addSessionDebugLog({
type: 'agent:patch', type: 'agent:patch',
agent: newBundle.agent, agent: newBundle,
prevSession: prevBundle.agent.session, prevSession:
nextSession: newBundle.agent.session, prevBundle.session && !prevBundle.session.destroyed
? prevBundle.session.session
: undefined,
nextSession: newBundle.session.session,
}) })
store.dispatch({ store.dispatch({
type: 'replaced-current-bundle', 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( const stateContext = useMemo(
() => ({ () => ({
@@ -483,11 +477,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const bundle = state.currentAgentState.agent as unknown as const bundle = state.currentAgentState.agent as unknown as
| SessionBundle | SessionBundle
| PublicSessionBundle | PublicSessionBundle
const agent = bundle.agent
// @ts-expect-error window type is not declared, debug only // @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability // eslint-disable-next-line react-hooks/immutability
if (__DEV__ && IS_WEB) window.agent = agent if (__DEV__ && IS_WEB) window.bundle = bundle
const currentBundleRef = useRef(bundle) const currentBundleRef = useRef(bundle)
useEffect(() => { useEffect(() => {
@@ -497,8 +490,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
currentBundleRef.current = bundle currentBundleRef.current = bundle
addSessionDebugLog({ addSessionDebugLog({
type: 'agent:switch', type: 'agent:switch',
prevAgent: prevBundle.agent, prevAgent: prevBundle,
nextAgent: bundle.agent, nextAgent: bundle,
}) })
// We never reuse bundles so let's fully neutralize the previous one. // We never reuse bundles so let's fully neutralize the previous one.
// This ensures its session won't try to consume any refresh tokens. // This ensures its session won't try to consume any refresh tokens.
@@ -507,22 +500,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [bundle]) }, [bundle])
return ( return (
<AgentContext.Provider value={agent}> <BundleContext.Provider value={bundle}>
<BundleContext.Provider value={bundle}> <StateContext.Provider value={stateContext}>
<StateContext.Provider value={stateContext}> <ApiContext.Provider value={api}>
<ApiContext.Provider value={api}> <AnalyticsContext
<AnalyticsContext metadata={utils.useMeta({
metadata={utils.useMeta({ session: utils.accountToSessionMetadata(
session: utils.accountToSessionMetadata( stateContext.currentAccount,
stateContext.currentAccount, ),
), })}>
})}> {children}
{children} </AnalyticsContext>
</AnalyticsContext> </ApiContext.Provider>
</ApiContext.Provider> </StateContext.Provider>
</StateContext.Provider> </BundleContext.Provider>
</BundleContext.Provider>
</AgentContext.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 * Authenticated lex {@link Client} for appview reads. Backed by the active
* bundle's appview client (proxied to the Bluesky appview, with labelers). Its * 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 Schema} from '../persisted'
import {type Action, type State} from './reducer' import {type Action, type State} from './reducer'
import {type AtpSessionEvent} from './session-core' import {type AtpSessionEvent} from './session-core'
@@ -52,10 +50,15 @@ type Log =
nextAgent: object 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' type: 'agent:patch'
agent: object agent: object
prevSession: SessionData | undefined prevSession: object | undefined
nextSession: SessionData | undefined nextSession: object | undefined
} }
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer { 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 {com} from '#/lexicons'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {readLabelers} from './agent-config' import {readLabelers} from './agent-config'
import {BridgeAgent, type SessionBundle} from './session-core' import {type SessionBundle} from './session-core'
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
/* /*
* The Bluesky moderation labeler DID. The old `BSKY_LABELER_DID` (from * The Bluesky moderation labeler DID is `api.moderation.did` (from
* '@atproto/api') and `api.moderation.did` (from '@bsky.app/sdk') are the SAME * '@bsky.app/sdk'), value `did:plc:ar7c4by46qjdydhdevvrndac`. We use it
* value - `did:plc:ar7c4by46qjdydhdevvrndac` - verified at implementation. We * everywhere: the global appLabelers config, the per-account filter, and the
* use `api.moderation.did` everywhere: the global appLabelers config, the * appview client's base labeler (matching `buildAppviewClient`); all resolve to
* per-account filter, and the appview client's base labeler (matching * identical `atproto-accept-labelers` headers.
* `buildAppviewClient`); all resolve to identical `atproto-accept-labelers`
* headers.
*/ */
/** /**
* Set the global app labelers on BOTH request paths so they emit identical * Set the global app labelers on the lex `Client` static so every client emits
* `atproto-accept-labelers` headers. * the same global (`;redact`-suffixed) `atproto-accept-labelers` header.
*
* 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.
*/ */
function configureGlobalAppLabelers(dids: string[]) { function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
BridgeAgent.configure({appLabelers: dids})
} }
export function configureModerationForGuest() { export function configureModerationForGuest() {
@@ -44,10 +34,8 @@ export function configureModerationForGuest() {
/** /**
* Configure moderation labelers for a signed-in account. * Configure moderation labelers for a signed-in account.
* *
* Takes the whole {@link SessionBundle} because per-account labelers must be * Takes the whole {@link SessionBundle} so it can apply per-account labelers to
* applied to BOTH live request paths: the bridge agent (`bundle.agent`, still * the authed appview client (`bundle.appviewClient`, backing `useLexClient()`).
* used by `useAgent()` consumers) and the authed appview client
* (`bundle.appviewClient`, backing `useLexClient()`).
*/ */
export async function configureModerationForAccount( export async function configureModerationForAccount(
bundle: SessionBundle, bundle: SessionBundle,
@@ -65,13 +53,12 @@ export async function configureModerationForAccount(
if (labelerDids) { if (labelerDids) {
const perAccount = labelerDids.filter(did => did !== api.moderation.did) const perAccount = labelerDids.filter(did => did !== api.moderation.did)
/* /*
* Apply the per-account labelers to both live request paths. The appview * Apply the per-account labelers to the appview client. It re-asserts the
* client re-asserts the Bluesky moderation labeler as its base because * Bluesky moderation labeler as its base because sending ANY
* sending ANY `atproto-accept-labelers` header replaces the server-side * `atproto-accept-labelers` header replaces the server-side default -
* default - `setLabelers` clears then re-adds, so the moderation DID must * `setLabelers` clears then re-adds, so the moderation DID must be included
* be included explicitly to stay active. * explicitly to stay active.
*/ */
bundle.agent.configureLabelers(perAccount)
bundle.appviewClient.setLabelers([ bundle.appviewClient.setLabelers([
api.moderation.did, api.moderation.did,
...perAccount, ...perAccount,
+4 -4
View File
@@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util'
import {wrapSessionReducerForLogging} from './logging' import {wrapSessionReducerForLogging} from './logging'
import {type AtpSessionEvent, createPublicSessionBundle} from './session-core' import {type AtpSessionEvent, createPublicSessionBundle} from './session-core'
import {type SessionAccount} from './types' 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 * 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 // side effect
const account = state.accounts.find(a => a.did === accountDid) const account = state.accounts.find(a => a.did === accountDid)
if (account) { if (account) {
createTemporaryAgentsAndResume([account]) createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents)) .then(agents => unregisterPushToken(agents))
.then(() => .then(() =>
logger.debug('Push token unregistered', {did: accountDid}), logger.debug('Push token unregistered', {did: accountDid}),
@@ -198,7 +198,7 @@ let reducer = (state: State, action: Action): State => {
// side effect // side effect
const account = state.accounts.find(a => a.did === accountDid) const account = state.accounts.find(a => a.did === accountDid)
if (account && accountDid) { if (account && accountDid) {
createTemporaryAgentsAndResume([account]) createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents)) .then(agents => unregisterPushToken(agents))
.then(() => .then(() =>
logger.debug('Push token unregistered', {did: accountDid}), logger.debug('Push token unregistered', {did: accountDid}),
@@ -226,7 +226,7 @@ let reducer = (state: State, action: Action): State => {
} }
} }
case 'logged-out-every-account': { case 'logged-out-every-account': {
createTemporaryAgentsAndResume(state.accounts) createTemporaryClientsAndResume(state.accounts)
.then(agents => unregisterPushToken(agents)) .then(agents => unregisterPushToken(agents))
.then(() => logger.debug('Push token unregistered')) .then(() => logger.debug('Push token unregistered'))
.catch(err => { .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 {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex-client' import {type Client} from '@atproto/lex-client'
import { import {
@@ -11,11 +5,16 @@ import {
type PasswordSessionOptions, type PasswordSessionOptions,
type SessionData, type SessionData,
} from '@atproto/lex-password-session' } from '@atproto/lex-password-session'
import {toDatetimeString} from '@atproto/syntax'
import {
overwriteSavedFeeds,
setPersonalDetails,
upsertProfile,
} from '@bsky.app/sdk'
import {jwtDecode} from 'jwt-decode' import {jwtDecode} from 'jwt-decode'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import { import {
BLUESKY_PROXY_HEADER,
BSKY_SERVICE, BSKY_SERVICE,
DISCOVER_SAVED_FEED, DISCOVER_SAVED_FEED,
IS_PROD_SERVICE, IS_PROD_SERVICE,
@@ -35,6 +34,7 @@ import {
} from '#/ageAssurance/data' } from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics' import {features} from '#/analytics'
import {type app} from '#/lexicons'
import { import {
buildAccountClient, buildAccountClient,
buildAppviewClient, buildAppviewClient,
@@ -50,15 +50,22 @@ import {
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
import {isSessionExpired} from './util' import {isSessionExpired} from './util'
/* /**
* Re-exported so session-layer siblings (index.tsx, reducer.ts, logging.ts, * The session-change events the reducer/logging/tests speak.
* moderation.ts, additional-moderation-authorities.ts) import the bridge *
* vocabulary through this whitelisted bridge module rather than from * Formerly re-exported from the legacy API package; defined locally now that
* '@atproto/api' directly. `Agent` is re-exported as `BridgeAgent` for the * the bridge is gone. These are the exact union members the reducer switches
* labeler-config statics (`Agent.configure`/`Agent.appLabelers`). Dies with * on. In
* the bridge in Phase 4. * 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 * 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 * Byte-identical to the derivation the old service getter used: a
* destroyed, but base `Agent`'s `did` getter must never throw (it is read all * `new URL(...)` over `session.session.service` when the session is live, else
* over the app, including by late readers after logout). This returns * `PUBLIC_BSKY_SERVICE`. Used for the {@link SessionBundle.service} getter.
* `undefined` for a destroyed/absent session.
*/ */
function safeDid( function deriveServiceUrl(session: PasswordSession | null): URL {
session: PasswordSession | null, return new URL(
): SessionData['did'] | undefined { session && !session.destroyed
if (!session || session.destroyed) { ? session.session.service
return undefined : PUBLIC_BSKY_SERVICE,
} )
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}
}
} }
/** /**
* The full set of read-through views over ONE `PasswordSession`. The * The full set of read-through views over ONE `PasswordSession`. The
* `session` is the sole auth core (single refresher); the `agent` and both * `session` is the sole auth core (single refresher); the clients never refresh
* clients never refresh independently. * independently.
*/ */
export type SessionBundle = { export type SessionBundle = {
/** The single auth core. Never exposed to the reducer. */ /** The single auth core. Never exposed to the reducer. */
session: PasswordSession session: PasswordSession
/** Legacy bridge agent for `useAgent()` consumers. */
agent: SessionAgent
/** Account (writes/records) client - talks to the user's PDS. */ /** Account (writes/records) client - talks to the user's PDS. */
accountClient: Client accountClient: Client
/** Authed appview client (proxied, with labelers). */ /** Authed appview client (proxied, with labelers). */
@@ -416,56 +288,52 @@ export type SessionBundle = {
/** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */ /** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */
chatClient: Client chatClient: Client
/** /**
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the * The service (entryway) URL. Exposed so the reducer can read `.service` for
* reducer can read `.service` for its opaque snapshot/logging view * its opaque snapshot/logging view (`OpaqueSessionBundle = {readonly service:
* (`OpaqueSessionBundle = {readonly service: URL}`) without reaching into the * URL}`) without reaching into the (never-exposed) session.
* agent or the (never-exposed) session.
*/ */
readonly service: URL readonly service: URL
} }
/** /**
* Assemble a {@link SessionBundle} from a live session: the bridge agent plus * Assemble a {@link SessionBundle} from a live session: the account, appview,
* the account and appview clients, all read-through views over the one session. * and chat clients, all read-through views over the one session. The appview
* The Bluesky appview proxy header is applied to the bridge (matching the old * proxy header is baked into `buildAppviewClient` (`service: api.app.service`),
* `agent.configureProxy(BLUESKY_PROXY_HEADER.get())`). * so no separate proxy configuration is needed here.
*/ */
export function buildBundle(session: PasswordSession): SessionBundle { export function buildBundle(session: PasswordSession): SessionBundle {
const agent = new SessionAgent(session)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return { return {
session, session,
agent,
accountClient: buildAccountClient(session), accountClient: buildAccountClient(session),
/* /*
* Per-account labelers are applied to the bridge agent by * Per-account labelers are applied to the appview client by
* configureModerationForAccount for now; the appview client carries only * configureModerationForAccount; buildAppviewClient carries only the base
* the base Bluesky moderation labeler. TODO(phase-2 moderation task): * Bluesky moderation labeler until then.
* rework moderation.ts to take the bundle and set per-account labelers on
* appviewClient too.
*/ */
appviewClient: buildAppviewClient(session, []), appviewClient: buildAppviewClient(session, []),
chatClient: buildChatClient(session), chatClient: buildChatClient(session),
/* /*
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can * Derived from the session so the reducer's opaque view can read `.service`.
* read `.service`. A getter keeps it live with the agent's derivation. * A getter keeps it live with the session's state (destroyed -> public).
*/ */
get service() { get service() {
return agent.serviceUrl return deriveServiceUrl(session)
}, },
} }
} }
/** /**
* The session-change events the reducer speaks. `PasswordSession` surfaces * The session-change callback the provider passes into the hooks.
* three hooks (`onUpdated`/`onDeleted`/`onUpdateFailure`) which we map into *
* this `AtpSessionEvent` vocabulary (see the table in the phase-2 design doc): * `PasswordSession` surfaces three hooks (`onUpdated`/`onDeleted`/
* refresh -> `'update'`, dead session/logout -> `'expired'`, transient failure * `onUpdateFailure`) which {@link makeSessionHooks} maps into the
* -> `'network-error'`. `'create'`/`'create-failed'` remain in the type for the * {@link AtpSessionEvent} vocabulary: refresh -> `'update'`, dead session/logout
* reducer/tests but are never emitted from here in production. * -> `'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 = ( type OnSessionChange = (
agent: SessionAgent, bundle: SessionBundle,
did: string, did: string,
event: AtpSessionEvent, event: AtpSessionEvent,
) => void ) => void
@@ -480,14 +348,15 @@ type OnSessionChange = (
* during `prepare()`. So hooks are inert until `arm()` is called, after the * during `prepare()`. So hooks are inert until `arm()` is called, after the
* prepare tail resolves. * prepare tail resolves.
* *
* `getAgent` is deferred because the bridge agent does not exist yet when the * `getBundle` is deferred because the bundle does not exist yet when the hooks
* hooks are constructed (the session is created first). * 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). * Exported for testing (the arm-latch + event mapping is the core semantics).
*/ */
export function makeSessionHooks( export function makeSessionHooks(
onSessionChange: OnSessionChange, onSessionChange: OnSessionChange,
getAgent: () => SessionAgent, getBundle: () => SessionBundle,
getDid: () => string, getDid: () => string,
) { ) {
let armed = false let armed = false
@@ -496,7 +365,7 @@ export function makeSessionHooks(
return return
} }
const did = getDid() const did = getDid()
onSessionChange(getAgent(), did, event) onSessionChange(getBundle(), did, event)
/* /*
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update * Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
* session event. In practice we only emit 'update'/'expired'/'network-error' * 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 * The public (logged-out) bundle. Its appview client points at the public
* appview and all clients are unauthenticated. * appview; the write/chat clients are the throwing unauthenticated client.
*/ */
export type PublicSessionBundle = { export type PublicSessionBundle = {
session: null session: null
agent: SessionAgent
accountClient: Client accountClient: Client
appviewClient: Client appviewClient: Client
/** /**
@@ -541,23 +409,19 @@ export type PublicSessionBundle = {
* and design section J. * and design section J.
*/ */
chatClient: Client chatClient: Client
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */ /** The public appview URL. See {@link SessionBundle.service}. */
readonly service: URL readonly service: URL
} }
/** /**
* Build the logged-out bundle used before/without a session. Mirrors the old * Build the logged-out bundle used before/without a session. Configures guest
* `createPublicAgent`: configures guest moderation as a side effect and applies * moderation as a side effect.
* the Bluesky appview proxy header to the bridge agent.
*/ */
export function createPublicSessionBundle(): PublicSessionBundle { export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest() // Side effect but only relevant for tests configureModerationForGuest() // Side effect but only relevant for tests
const agent = new SessionAgent(null)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
const publicClient = getPublicLexClient() const publicClient = getPublicLexClient()
return { return {
session: null, session: null,
agent,
/* /*
* Write/auth clients throw on use when logged out (design section J): the * Write/auth clients throw on use when logged out (design section J): the
* public bundle exposes the throwing unauthenticated client for the account * public bundle exposes the throwing unauthenticated client for the account
@@ -568,9 +432,7 @@ export function createPublicSessionBundle(): PublicSessionBundle {
accountClient: getUnauthenticatedClient(), accountClient: getUnauthenticatedClient(),
appviewClient: publicClient, appviewClient: publicClient,
chatClient: getUnauthenticatedClient(), chatClient: getUnauthenticatedClient(),
get service() { service: new URL(PUBLIC_BSKY_SERVICE),
return agent.serviceUrl
},
} }
} }
@@ -590,7 +452,7 @@ export async function createSessionBundleAndResume(
let bundle!: SessionBundle let bundle!: SessionBundle
const hooks = makeSessionHooks( const hooks = makeSessionHooks(
onSessionChange, onSessionChange,
() => bundle.agent, () => bundle,
() => storedAccount.did, () => storedAccount.did,
) )
@@ -651,7 +513,7 @@ export async function createSessionBundleAndLogin(
let accountDid = '' let accountDid = ''
const hooks = makeSessionHooks( const hooks = makeSessionHooks(
onSessionChange, onSessionChange,
() => bundle.agent, () => bundle,
() => accountDid, () => accountDid,
) )
@@ -686,7 +548,7 @@ export async function createSessionBundleAndLogin(
* created-at/birthdate, the prod vs non-prod deferred server-write block * created-at/birthdate, the prod vs non-prod deferred server-write block
* (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids, * (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids,
* restrictChatSettings gated on AA flags), and snoozeEmailConfirmationPrompt. * 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( export async function createSessionBundleAndCreateAccount(
{ {
@@ -714,7 +576,7 @@ export async function createSessionBundleAndCreateAccount(
let accountDid = '' let accountDid = ''
const hooks = makeSessionHooks( const hooks = makeSessionHooks(
onSessionChange, onSessionChange,
() => bundle.agent, () => bundle,
() => accountDid, () => accountDid,
) )
@@ -734,12 +596,11 @@ export async function createSessionBundleAndCreateAccount(
bundle = buildBundle(session) bundle = buildBundle(session)
const account = sessionDataToSessionAccountOrThrow(session) const account = sessionDataToSessionAccountOrThrow(session)
accountDid = account.did accountDid = account.did
const agent = bundle.agent
const gates = features.refresh({strategy: 'prefer-fresh-gates'}) const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, account) const moderation = configureModerationForAccount(bundle, account)
const createdAt = new Date().toISOString() const createdAt = toDatetimeString(new Date())
const birthdate = birthDate.toISOString() const birthdate = birthDate.toISOString()
/* /*
@@ -762,8 +623,8 @@ export async function createSessionBundleAndCreateAccount(
if (IS_PROD_SERVICE(service)) { if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([ void Promise.allSettled([
networkRetry(3, () => { networkRetry(3, () => {
return agent.setPersonalDetails({ return bundle.accountClient.call(setPersonalDetails, {
birthDate: birthdate, birthDate,
}) })
}).catch(e => { }).catch(e => {
logger.info( logger.info(
@@ -772,8 +633,8 @@ export async function createSessionBundleAndCreateAccount(
throw e throw e
}), }),
networkRetry(3, () => { networkRetry(3, () => {
return agent.upsertProfile(prev => { return bundle.accountClient.call(upsertProfile, prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.displayName = handle next.displayName = handle
next.createdAt = createdAt next.createdAt = createdAt
return next return next
@@ -785,7 +646,7 @@ export async function createSessionBundleAndCreateAccount(
throw e throw e
}), }),
networkRetry(1, () => { networkRetry(1, () => {
return agent.overwriteSavedFeeds([ return bundle.accountClient.call(overwriteSavedFeeds, [
{ {
...DISCOVER_SAVED_FEED, ...DISCOVER_SAVED_FEED,
id: TID.nextStr(), id: TID.nextStr(),
@@ -823,8 +684,8 @@ export async function createSessionBundleAndCreateAccount(
} else { } else {
void Promise.allSettled([ void Promise.allSettled([
networkRetry(3, () => { networkRetry(3, () => {
return agent.setPersonalDetails({ return bundle.accountClient.call(setPersonalDetails, {
birthDate: birthDate.toISOString(), birthDate,
}) })
}).catch(e => { }).catch(e => {
logger.info( logger.info(
@@ -833,9 +694,9 @@ export async function createSessionBundleAndCreateAccount(
throw e throw e
}), }),
networkRetry(3, () => { networkRetry(3, () => {
return agent.upsertProfile(prev => { return bundle.accountClient.call(upsertProfile, prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString() next.createdAt = prev?.createdAt || toDatetimeString(new Date())
return next return next
}) })
}).catch(e => { }).catch(e => {
@@ -894,8 +755,8 @@ function sessionDataToSessionAccountOrThrow(
* is that this session's tokens are no longer reachable by any live client. We * 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 * do NOT call `logout()` here: disposal is a local switch, not a server-side
* revocation (revocation is handled separately via the push-token unregister * revocation (revocation is handled separately via the push-token unregister
* temporary sessions). The bridge agent stays usable enough (its `did`/session * temporary sessions). The bundle's clients stay usable enough not to crash
* getters return undefined) not to crash late readers. * late readers.
*/ */
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) { export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
const session = bundle.session 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 * Canonical implementation moved to session-core.ts so that module stays
* dependency-light (this file pulls in agent.ts and, transitively, a large * dependency-light (this file transitively pulls in a large chunk of the app).
* chunk of the app). Re-exported here for existing consumers. * Re-exported here for existing consumers.
*/ */
export {isSignupQueued} from './session-core' 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 * paired with the account's service origin and handle, matching the contract
* {@link unregisterPushToken} consumes. * {@link unregisterPushToken} consumes.
*/ */
export async function createTemporaryAgentsAndResume( export async function createTemporaryClientsAndResume(
accounts: SessionAccount[], accounts: SessionAccount[],
): Promise<TemporaryPushClient[]> { ): Promise<TemporaryPushClient[]> {
const settled = await Promise.allSettled( const settled = await Promise.allSettled(