phase 4: delete the session bridge (SessionAgent, agent.ts, agentToLexClient)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user