From ca5b8b6346a419239872060d804f65fc7b32e7b5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 17 Jul 2026 13:12:46 +0300 Subject: [PATCH] phase 4: delete the session bridge (SessionAgent, agent.ts, agentToLexClient) --- .../session/__tests__/bridge-agent-test.ts | 375 ------------------ .../session/__tests__/clients-bundle-test.ts | 61 +-- src/state/session/__tests__/clients-test.ts | 94 ----- .../session/__tests__/session-core-test.ts | 291 +++++++++++++- src/state/session/__tests__/session-test.ts | 8 +- .../additional-moderation-authorities.ts | 11 +- src/state/session/agent.ts | 45 --- src/state/session/clients.ts | 71 +--- src/state/session/index.tsx | 123 +++--- src/state/session/logging.ts | 11 +- src/state/session/moderation.ts | 43 +- src/state/session/reducer.ts | 8 +- src/state/session/session-core.ts | 309 ++++----------- src/state/session/util.ts | 6 +- 14 files changed, 500 insertions(+), 956 deletions(-) delete mode 100644 src/state/session/__tests__/bridge-agent-test.ts delete mode 100644 src/state/session/__tests__/clients-test.ts delete mode 100644 src/state/session/agent.ts diff --git a/src/state/session/__tests__/bridge-agent-test.ts b/src/state/session/__tests__/bridge-agent-test.ts deleted file mode 100644 index 88bb2c4874..0000000000 --- a/src/state/session/__tests__/bridge-agent-test.ts +++ /dev/null @@ -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 { - 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 - > = {}, -) { - 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 => { - 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 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>[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>() - 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>() - const onUpdated = - jest.fn>() - 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>() - const onUpdateFailure = - jest.fn>() - 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') - }) -}) diff --git a/src/state/session/__tests__/clients-bundle-test.ts b/src/state/session/__tests__/clients-bundle-test.ts index 97283b8d2c..14de6a05c3 100644 --- a/src/state/session/__tests__/clients-bundle-test.ts +++ b/src/state/session/__tests__/clients-bundle-test.ts @@ -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`) }) }) diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts deleted file mode 100644 index f1e7cd1fac..0000000000 --- a/src/state/session/__tests__/clients-test.ts +++ /dev/null @@ -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 => - 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) - }) -}) diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 9e1b175150..90efc73760 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -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 { @@ -324,3 +333,283 @@ describe('sessionAccountToSessionData', () => { expect(roundTripped).toEqual(selfHosted) }) }) + +function makeAccount(overrides: Partial = {}): 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 + > = {}, +) { + 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 => { + 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 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>[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>() + 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>() + const onUpdated = + jest.fn>() + 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>() + const onUpdateFailure = + jest.fn>() + 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() + }) +}) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 0dee5d24bf..6959398cec 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -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(` diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 1efe0f74b8..dbc2271ab4 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -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}) } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts deleted file mode 100644 index 1f31028696..0000000000 --- a/src/state/session/agent.ts +++ /dev/null @@ -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 - ) { - super(...options) - if (proxyHeader) { - this.configureProxy(proxyHeader) - } - } -} diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 6cd8517828..67cc2669a2 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -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() - -/** - * 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, diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index f872b7d355..c7c863fa33 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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({ }) StateContext.displayName = 'SessionStateContext' -const AgentContext = createContext(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( 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( @@ -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 ( - - - - - - {children} - - - - - + + + + + {children} + + + + ) } @@ -564,14 +555,6 @@ export function useRequireAuth() { ) } -export function useAgent(): SessionAgent { - const agent = useContext(AgentContext) - if (!agent) { - throw Error('useAgent() must be below .') - } - 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 diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts index 0e096eef01..93f0bace31 100644 --- a/src/state/session/logging.ts +++ b/src/state/session/logging.ts @@ -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 { diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 5b2e0828dd..57e092c99b 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -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, diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index a806f1014b..46f71c5e6e 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -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 => { diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index a6e6b2e2d8..2557c71244 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -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 - refreshSession: () => Promise - 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 = prev || {} + return bundle.accountClient.call(upsertProfile, prev => { + const next: Partial = 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 = prev || {} - next.createdAt = prev?.createdAt || new Date().toISOString() + return bundle.accountClient.call(upsertProfile, prev => { + const next: Partial = 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 diff --git a/src/state/session/util.ts b/src/state/session/util.ts index f03ce3ae59..b548516b32 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -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 { const settled = await Promise.allSettled(