From 9f40ba0a0bd2627ad36d14fde6cc4abb1c5c863a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:25:43 +0300 Subject: [PATCH] add the passwordsession bridge agent Co-Authored-By: Claude Fable 5 --- .../session/__tests__/bridge-agent-test.ts | 578 ++++++++++++++++++ src/state/session/bridge-agent.ts | 375 ++++++++++++ 2 files changed, 953 insertions(+) create mode 100644 src/state/session/__tests__/bridge-agent-test.ts create mode 100644 src/state/session/bridge-agent.ts diff --git a/src/state/session/__tests__/bridge-agent-test.ts b/src/state/session/__tests__/bridge-agent-test.ts new file mode 100644 index 0000000000..e977d0a6cd --- /dev/null +++ b/src/state/session/__tests__/bridge-agent-test.ts @@ -0,0 +1,578 @@ +import { + PasswordSession, + type PasswordSessionOptions, + type SessionData, +} from '@atproto/lex-password-session' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('#/state/events', () => ({ + emitNetworkConfirmed: jest.fn(), + emitNetworkLost: jest.fn(), +})) + +jest.mock('jwt-decode', () => ({ + jwtDecode() { + return {scope: 'com.atproto.access'} + }, +})) + +import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' +import {sessionAccountToSessionData} from '../session-data' +import {type SessionAccount} from '../types' + +const DID = 'did:plc:example123' +const HANDLE = 'alice.test' +const SERVICE = 'https://bsky.social' +const PDS_HOST = 'https://shimeji.us-east.host.bsky.network' +const DIDDOC_PDS_HOST = 'https://morel.us-west.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, + } +} + +/** A minimal valid DID document whose only service entry is a PDS. */ +function makeDidDoc(pdsUrl: string) { + return { + id: DID, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: pdsUrl, + }, + ], + } +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: {'content-type': 'application/json'}, + }) +} + +/** + * 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 + > = {}, +) { + return 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, + email: 'alice@example.com', + /* both emailConfirmed and didDoc present -> no getSession follow-up */ + emailConfirmed: true, + didDoc: makeDidDoc(DIDDOC_PDS_HOST), + active: true, + }) + } + if (nsid === 'com.atproto.server.getSession') { + return json({ + did: DID, + handle: HANDLE, + email: 'alice@example.com', + emailConfirmed: true, + active: true, + }) + } + return json({}) + }, + ) +} + +type MockFetch = ReturnType + +/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */ +function asFetch(mock: MockFetch): typeof fetch { + return mock as unknown as typeof fetch +} + +function urlsOf(mock: MockFetch): string[] { + return mock.mock.calls.map(c => (c[0] instanceof URL ? c[0].href : c[0])) +} + +/** + * Build the manager + agent pair under test. + * + * The mock fetch is installed in both places it can be reached from: as the + * inner `PasswordSession`'s fetch (the authenticated path) and, via + * `setFetch`, as the manager's own fetch (the unauthenticated bypass path, + * which would otherwise use the real network-aware fetch). + */ +function setup({ + account = makeAccount(), + didDoc, + pdsUrl, + fetchMock = makeMockFetch(), + sessionOptions, +}: { + account?: SessionAccount + didDoc?: SessionData['didDoc'] + pdsUrl?: string + fetchMock?: MockFetch + sessionOptions?: PasswordSessionOptions +} = {}) { + const data: SessionData = { + ...sessionAccountToSessionData(account), + ...(didDoc ? {didDoc} : {}), + } + const inner = new PasswordSession(data, { + fetch: asFetch(fetchMock), + ...sessionOptions, + }) + const manager = new PasswordSessionManager(inner, { + service: account.service, + pdsUrl, + }) + manager.setFetch(asFetch(fetchMock)) + const agent = new BskyAppAgent(manager) + return {inner, manager, agent, fetchMock} +} + +function setupPublic(fetchMock: MockFetch = makeMockFetch()) { + const manager = new PasswordSessionManager(null, {service: SERVICE}) + manager.setFetch(asFetch(fetchMock)) + return {manager, agent: new BskyAppAgent(manager), fetchMock} +} + +describe('PasswordSessionManager getters', () => { + it('reads live SessionData through .session', () => { + const {agent} = setup() + expect(agent.session?.did).toBe(DID) + expect(agent.session?.handle).toBe(HANDLE) + expect(agent.session?.email).toBe('alice@example.com') + expect(agent.session?.emailConfirmed).toBe(true) + expect(agent.did).toBe(DID) + expect(agent.hasSession).toBe(true) + }) + + it('defaults active to true when the payload omits it', () => { + const {agent} = setup({account: makeAccount({active: undefined})}) + expect(agent.session?.active).toBe(true) + }) + + it('exposes serviceUrl from the constructor service', () => { + const {agent} = setup() + expect(agent.serviceUrl.toString()).toBe('https://bsky.social/') + }) + + it('derives pdsUrl/dispatchUrl from the didDoc', () => { + const {agent} = setup({didDoc: makeDidDoc(PDS_HOST)}) + expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) + expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`) + }) + + it('falls back to the stored pdsUrl when there is no didDoc', () => { + const {agent} = setup({pdsUrl: PDS_HOST}) + expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) + expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`) + }) + + it('prefers the didDoc PDS over the stored pdsUrl', () => { + const {agent} = setup({ + didDoc: makeDidDoc(DIDDOC_PDS_HOST), + pdsUrl: PDS_HOST, + }) + expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`) + }) + + it('dispatchUrl falls back to serviceUrl with no PDS at all', () => { + const {agent} = setup() + expect(agent.pdsUrl).toBe(undefined) + expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') + }) + + it('ignores an unparseable stored pdsUrl', () => { + const {agent} = setup({pdsUrl: 'not a url'}) + expect(agent.pdsUrl).toBe(undefined) + expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') + }) +}) + +describe('PasswordSessionManager.session identity', () => { + it('is stable across consecutive reads', () => { + const {agent} = setup() + expect(agent.session).toBe(agent.session) + }) + + it('is a new object after a refresh rotates tokens', async () => { + const {agent} = setup() + const before = agent.session + expect(before?.accessJwt).toBe('access-jwt') + await agent.sessionManager.refreshSession() + const after = agent.session + expect(after).not.toBe(before) + expect(after?.accessJwt).toBe('access-jwt-2') + expect(after).toBe(agent.session) + }) + + it('rejects writes to .session', () => { + const {agent} = setup() + expect(() => { + /* the whole point of the accessor: writes must not silently drift */ + agent.sessionManager.session = agent.session + }).toThrow('read-only') + }) + + it('rejects writes to .pdsUrl', () => { + const {agent} = setup() + expect(() => { + agent.sessionManager.pdsUrl = new URL(PDS_HOST) + }).toThrow('read-only') + }) +}) + +describe('PasswordSessionManager.fetchHandler routing', () => { + it('dispatches to the stored PDS before a refresh, then to the didDoc PDS', async () => { + const {manager, fetchMock} = setup({pdsUrl: PDS_HOST}) + + await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') + expect(urlsOf(fetchMock).at(-1)).toBe( + `${PDS_HOST}/xrpc/app.bsky.actor.getProfile`, + ) + + /* the refresh response carries a didDoc pointing at a different host */ + await manager.refreshSession() + expect(manager.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`) + + await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') + expect(urlsOf(fetchMock).at(-1)).toBe( + `${DIDDOC_PDS_HOST}/xrpc/app.bsky.actor.getProfile`, + ) + }) + + it('attaches the session bearer token', async () => { + const seen: Headers[] = [] + const fetchMock = makeMockFetch({ + 'app.bsky.actor.getProfile': (_url, init) => { + seen.push(new Headers(init.headers)) + return json({}) + }, + }) + const {manager} = setup({fetchMock}) + await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') + expect(seen[0].get('authorization')).toBe('Bearer access-jwt') + }) + + it('bypasses the inner session when authorization is pre-set', async () => { + const seen: Headers[] = [] + const fetchMock = makeMockFetch({ + 'com.atproto.server.describeServer': (_url, init) => { + seen.push(new Headers(init.headers)) + return json({}) + }, + }) + const {manager} = setup({fetchMock, pdsUrl: PDS_HOST}) + + /* + * PasswordSession throws TypeError on a pre-set authorization header, so + * this path must never reach it. + */ + await expect( + manager.fetchHandler('/xrpc/com.atproto.server.describeServer', { + headers: {authorization: 'Bearer caller-supplied'}, + }), + ).resolves.toBeDefined() + + expect(seen.length).toBe(1) + /* the caller's header survives, and there is exactly one of them */ + expect(seen[0].get('authorization')).toBe('Bearer caller-supplied') + expect(urlsOf(fetchMock).at(-1)).toBe( + `${PDS_HOST}/xrpc/com.atproto.server.describeServer`, + ) + }) +}) + +describe('BskyAppAgent namespace requests', () => { + it('carries proxy, labeler and bearer headers to the dispatch host', async () => { + const seen: {url: string; headers: Headers}[] = [] + const fetchMock = makeMockFetch({ + 'app.bsky.actor.getProfile': (url, init) => { + seen.push({url, headers: new Headers(init.headers)}) + return json({did: DID, handle: HANDLE}) + }, + }) + const {agent} = setup({fetchMock, pdsUrl: PDS_HOST}) + agent.configureProxy('did:web:api.bsky.app#bsky_appview') + agent.configureLabelers(['did:plc:custom-labeler']) + + /* + * The request headers (what we assert) are captured by the fetch mock + * before the agent parses the response body. Response-body lexicon + * validation can throw in the jest environment (a multiformats CID mock + * quirk unrelated to the header composition under test), so we ignore any + * parse error here. + */ + await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {}) + + expect(seen.length).toBe(1) + expect(seen[0].url.startsWith(`${PDS_HOST}/xrpc/`)).toBe(true) + expect(seen[0].headers.get('atproto-proxy')).toBe( + 'did:web:api.bsky.app#bsky_appview', + ) + expect(seen[0].headers.get('atproto-accept-labelers')).toContain( + 'did:plc:custom-labeler', + ) + expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt') + }) +}) + +describe('PasswordSessionManager.refreshSession', () => { + it('returns an old-shaped XRPC envelope with fresh tokens', async () => { + const {manager, fetchMock} = setup() + const res = await manager.refreshSession() + expect(res.success).toBe(true) + expect(res.data.accessJwt).toBe('access-jwt-2') + expect(res.data.refreshJwt).toBe('refresh-jwt-2') + expect(res.data.did).toBe(DID) + expect(res.data.handle).toBe(HANDLE) + expect( + urlsOf(fetchMock).some(u => + u.includes('com.atproto.server.refreshSession'), + ), + ).toBe(true) + }) + + it('throws when there is no live session', async () => { + const {manager} = setupPublic() + await expect(manager.refreshSession()).rejects.toThrow( + 'No session to refresh', + ) + }) +}) + +describe('PasswordSessionManager.resumeSession', () => { + const staleData = { + accessJwt: 'stale-access', + refreshJwt: 'stale-refresh', + handle: 'stale.test', + did: DID, + active: true, + } + + it('ignores its argument and returns fresh tokens from a refresh', async () => { + const {manager} = setup() + const res = await manager.resumeSession(staleData) + expect(res.data.accessJwt).toBe('access-jwt-2') + expect(res.data.refreshJwt).toBe('refresh-jwt-2') + expect(manager.session?.accessJwt).toBe('access-jwt-2') + }) + + it('is reachable through the agent and does not install the stale data', async () => { + const {agent} = setup() + await agent.resumeSession(staleData) + expect(agent.session?.accessJwt).toBe('access-jwt-2') + expect(agent.session?.handle).toBe(HANDLE) + }) +}) + +describe('PasswordSessionManager unsupported methods', () => { + it('refuses login()', async () => { + const {agent} = setup() + await expect( + agent.login({identifier: HANDLE, password: 'hunter2'}), + ).rejects.toThrow('Not supported on PasswordSessionManager') + }) + + it('refuses createAccount()', async () => { + const {agent} = setup() + await expect( + agent.createAccount({handle: HANDLE, email: 'a@b.c', password: 'x'}), + ).rejects.toThrow('Not supported on PasswordSessionManager') + }) +}) + +describe('PasswordSessionManager destroyed inner session', () => { + it('getters return undefined rather than throwing after logout', async () => { + const {agent, inner} = setup() + await agent.logout() + expect(inner.destroyed).toBe(true) + /* PasswordSession.did/.session throw once destroyed; the bridge must not */ + expect(() => agent.did).not.toThrow() + expect(agent.did).toBe(undefined) + expect(agent.session).toBe(undefined) + expect(agent.hasSession).toBe(false) + expect(agent.pdsUrl).toBe(undefined) + }) + + it('logout() is idempotent', async () => { + const {agent} = setup() + await agent.logout() + await expect(agent.logout()).resolves.toBeUndefined() + }) + + it('fetchHandler stops attaching auth once destroyed', async () => { + const seen: Headers[] = [] + const fetchMock = makeMockFetch({ + 'app.bsky.actor.getProfile': (_url, init) => { + seen.push(new Headers(init.headers)) + return json({}) + }, + }) + const {agent, manager} = setup({fetchMock}) + await agent.logout() + await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') + expect(seen.length).toBe(1) + expect(seen[0].get('authorization')).toBe(null) + }) +}) + +describe('BskyAppAgent.dispose', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = setup({pdsUrl: PDS_HOST}) + }) + + it('makes the session read as logged out', () => { + expect(ctx.agent.session).toBeDefined() + ctx.agent.dispose() + expect(ctx.agent.session).toBe(undefined) + expect(ctx.agent.did).toBe(undefined) + expect(ctx.agent.pdsUrl).toBe(undefined) + expect(ctx.agent.hasSession).toBe(false) + }) + + it('routes requests through the plain unauthenticated fetch', async () => { + const seen: Headers[] = [] + const fetchMock = makeMockFetch({ + 'app.bsky.actor.getProfile': (_url, init) => { + seen.push(new Headers(init.headers)) + return json({}) + }, + }) + const {agent, manager} = setup({fetchMock, pdsUrl: PDS_HOST}) + agent.dispose() + await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') + expect(seen.length).toBe(1) + expect(seen[0].get('authorization')).toBe(null) + /* dispatch falls back to the service, since pdsUrl now reads undefined */ + expect(urlsOf(fetchMock).at(-1)).toBe( + `${SERVICE}/xrpc/app.bsky.actor.getProfile`, + ) + }) + + it('leaves refreshSession unusable', async () => { + ctx.agent.dispose() + await expect(ctx.agent.sessionManager.refreshSession()).rejects.toThrow( + 'No session to refresh', + ) + }) +}) + +describe('public PasswordSessionManager (no inner session)', () => { + it('reads as logged out', () => { + const {agent} = setupPublic() + expect(agent.session).toBe(undefined) + expect(agent.did).toBe(undefined) + expect(agent.hasSession).toBe(false) + expect(agent.pdsUrl).toBe(undefined) + expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') + }) + + it('dispatches to the service unauthenticated', async () => { + const seen: Headers[] = [] + const fetchMock = makeMockFetch({ + 'app.bsky.feed.getFeed': (_url, init) => { + seen.push(new Headers(init.headers)) + return json({}) + }, + }) + const {manager} = setupPublic(fetchMock) + await manager.fetchHandler('/xrpc/app.bsky.feed.getFeed') + expect(seen.length).toBe(1) + expect(seen[0].get('authorization')).toBe(null) + expect(urlsOf(fetchMock).at(-1)).toBe( + `${SERVICE}/xrpc/app.bsky.feed.getFeed`, + ) + }) +}) + +describe('PasswordSession lifecycle over mocked fetch', () => { + it('resume fast path: constructing does not hit the network', () => { + const fetchMock = makeMockFetch() + setup({fetchMock}) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + it('a refresh fires onUpdated with fresh tokens', async () => { + const onUpdated = + jest.fn>() + const {manager} = setup({sessionOptions: {onUpdated}}) + await manager.refreshSession() + expect(onUpdated).toHaveBeenCalledTimes(1) + expect(manager.session?.accessJwt).toBe('access-jwt-2') + }) + + it('onDeleted fires when refresh returns a declared invalid-token error', async () => { + const onDeleted = + jest.fn>() + const onUpdated = + jest.fn>() + const fetchMock = makeMockFetch({ + 'com.atproto.server.refreshSession': () => + json({error: 'ExpiredToken', message: 'Token expired'}, 400), + }) + const {manager} = setup({fetchMock, sessionOptions: {onDeleted, onUpdated}}) + await expect(manager.refreshSession()).rejects.toBeDefined() + expect(onDeleted).toHaveBeenCalledTimes(1) + expect(onUpdated).not.toHaveBeenCalled() + /* and the bridge reads as logged out afterwards */ + expect(manager.session).toBe(undefined) + }) + + it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => { + const onDeleted = + jest.fn>() + const onUpdateFailure = + jest.fn>() + const fetchMock = makeMockFetch({ + 'com.atproto.server.refreshSession': () => + json({error: 'InternalServerError'}, 500), + }) + const {manager} = setup({ + fetchMock, + sessionOptions: {onDeleted, onUpdateFailure}, + }) + await manager.refreshSession() + expect(onUpdateFailure).toHaveBeenCalledTimes(1) + expect(onDeleted).not.toHaveBeenCalled() + expect(manager.session?.accessJwt).toBe('access-jwt') + }) +}) diff --git a/src/state/session/bridge-agent.ts b/src/state/session/bridge-agent.ts new file mode 100644 index 0000000000..58f1d0b64f --- /dev/null +++ b/src/state/session/bridge-agent.ts @@ -0,0 +1,375 @@ +import { + AtpAgent, + type AtpAgentLoginOpts, + type AtpSessionData, + type ComAtprotoServerCreateAccount, + type ComAtprotoServerCreateSession, + type ComAtprotoServerRefreshSession, + CredentialSession, +} from '@atproto/api' +import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web' +import { + type PasswordSession, + type SessionData, +} from '@atproto/lex-password-session' + +import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {configureModerationForGuest} from './moderation' +import {networkAwareFetch} from './network' + +const UNSUPPORTED = + 'Not supported on PasswordSessionManager; use the session factories in session-core' + +/** + * Convert live `PasswordSession` session data into the old `AtpSessionData` + * shape that `CredentialSession.session` consumers expect. + * + * The only real adaptation is `active`: `AtpSessionData` requires it, while the + * lexicon payload leaves it optional (absent means active, per the lexicon + * docs). + */ +function toAtpSessionData(d: SessionData): AtpSessionData { + return { + refreshJwt: d.refreshJwt, + accessJwt: d.accessJwt, + handle: d.handle, + did: d.did, + email: d.email, + emailConfirmed: d.emailConfirmed, + emailAuthFactor: d.emailAuthFactor, + active: d.active ?? true, + status: d.status, + } +} + +/** + * Parse a URL without throwing. + * + * `URL.canParse` exists on Expo 54's Hermes, but a try/catch needs no feature + * detection and behaves identically, so we avoid the branch entirely. + */ +function parseUrl(input: string): URL | undefined { + try { + return new URL(input) + } catch { + return undefined + } +} + +/** + * A `CredentialSession` whose auth core is a `PasswordSession`. + * + * This is the compat shim that lets the new session layer sit under the old + * `AtpAgent`: every call site that reads `agent.session`, `agent.pdsUrl`, + * `agent.dispatchUrl`, `agent.did` or calls `agent.resumeSession()` keeps + * working, while the actual tokens, refresh serialization and PDS routing live + * in the `PasswordSession` underneath. + * + * A `null` inner session means "logged out" - the public/guest agent. In that + * mode requests still go out (unauthenticated) through the inherited `fetch`. + */ +export class PasswordSessionManager extends CredentialSession { + #inner: PasswordSession | null + #storedPdsUrl: URL | undefined + #disposed = false + + /* + * Identity caches for the two pull-through accessors. Each holds the + * `SessionData` it was derived from so a repeated read returns the very same + * object (see the note on identity stability below). Keying on the whole + * `SessionData` rather than the individual field works because + * `PasswordSession` replaces the object wholesale on every rotation. + */ + #sessionSource: SessionData | undefined + #sessionValue: AtpSessionData | undefined + #pdsSource: SessionData | undefined + #pdsValue: URL | undefined + + constructor( + inner: PasswordSession | null, + {service, pdsUrl}: {service: string; pdsUrl?: string}, + ) { + /* + * `persistSession` is deliberately undefined: the inner `PasswordSession` + * owns persistence through its own hooks, and none of the inherited methods + * that would call this handler survive the overrides below. + */ + super(new URL(service), networkAwareFetch, undefined) + + this.#inner = inner + this.#storedPdsUrl = pdsUrl ? parseUrl(pdsUrl) : undefined + + /* + * `session` and `pdsUrl` are pull-through accessors over the inner session + * rather than mirrored values, installed here with `defineProperty` for two + * reasons. + * + * Why accessors at all: a mirror has to be written on every token rotation, + * and any missed write silently serves stale tokens. Pulling through cannot + * drift. + * + * Why `defineProperty` and not `get session()` in the class body: the + * parent declares `session` and `pdsUrl` as *properties*, and TypeScript + * rejects overriding a property with an accessor (TS2611). Installing them + * at runtime sidesteps that, and it is safe because the parent's emitted + * constructor never assigns either one - both are declaration-only in the + * 0.20.34 dist, so there is nothing to clobber and no ordering hazard. + * + * For the same reason this class must NOT redeclare `session`/`pdsUrl` as + * fields: under `useDefineForClassFields` semantics (target esnext) a field + * declaration emits an own-property definition that would overwrite these + * accessors with `undefined`. + */ + Object.defineProperty(this, 'session', { + configurable: true, + get: () => this.#readSession(), + set: () => { + throw new Error('PasswordSessionManager.session is read-only') + }, + }) + Object.defineProperty(this, 'pdsUrl', { + configurable: true, + get: () => this.#readPdsUrl(), + set: () => { + throw new Error('PasswordSessionManager.pdsUrl is read-only') + }, + }) + } + + /** + * The inner session's live data, or `undefined` when there is nothing to read + * from. + * + * `PasswordSession`'s `session`/`did`/`handle` getters *throw* `Logged out` + * once the session has been destroyed. Every read in this class funnels + * through here so that failure mode can never escape into the app, which + * reads `agent.session` from render paths. + */ + #liveData(): SessionData | undefined { + if (this.#disposed || !this.#inner || this.#inner.destroyed) { + return undefined + } + return this.#inner.session + } + + /** + * The `session` accessor's implementation. + * + * Identity-cached on the source `SessionData`: consecutive reads with no + * intervening token rotation return the same object, and a rotation produces + * a new one. Consumers depend on this - `useAccountEmailState` has a + * `useMemo` keyed on `agent.session`, which would recompute on every render + * if we allocated a fresh object per read. + */ + #readSession(): AtpSessionData | undefined { + const live = this.#liveData() + if (!live) { + this.#sessionSource = undefined + this.#sessionValue = undefined + return undefined + } + if (live !== this.#sessionSource) { + this.#sessionSource = live + this.#sessionValue = toAtpSessionData(live) + } + return this.#sessionValue + } + + /** + * The `pdsUrl` accessor's implementation. + * + * The DID document's PDS endpoint wins when there is one. Before the first + * refresh delivers a didDoc (the non-expired resume fast path, which makes no + * network call) we fall back to the `pdsUrl` persisted on the account, so the + * very first requests still reach the right host - entryway accounts have + * `service: bsky.social` but live on a different PDS. + * + * Identity-cached on the didDoc for the same reason as `session`. + */ + #readPdsUrl(): URL | undefined { + const live = this.#liveData() + if (!live) { + this.#pdsSource = undefined + this.#pdsValue = undefined + return undefined + } + if (live !== this.#pdsSource) { + this.#pdsSource = live + const endpoint = isValidDidDoc(live.didDoc) + ? getPdsEndpoint(live.didDoc) + : undefined + this.#pdsValue = endpoint ? parseUrl(endpoint) : this.#storedPdsUrl + } + return this.#pdsValue + } + + /* + * `did`, `hasSession` and `dispatchUrl` are deliberately NOT overridden: the + * inherited getters read `this.session` / `this.pdsUrl`, which now resolve + * through the accessors above, so they are already live. + */ + + override async fetchHandler( + url: string, + init?: RequestInit, + ): Promise { + /* + * Absolutizing against `dispatchUrl` is what replaces the old + * `sessionManager.pdsUrl = ...` writes and `_updateApiEndpoint`: it routes + * to the stored PDS on the resume fast path and to the didDoc PDS from the + * first refresh onwards, since `PasswordSession` resolves an already + * absolute URL against its own base as a no-op. + */ + const target = new URL(url, this.dispatchUrl) + const inner = this.#disposed ? null : this.#inner + + /* + * A caller that set its own `authorization` header bypasses the inner + * session entirely. This preserves old `CredentialSession` semantics (it + * skipped its own bearer in that case) and is mandatory here: + * `PasswordSession.fetchHandler` throws `TypeError` on a pre-set + * authorization header rather than deferring to it. + */ + if ( + !inner || + inner.destroyed || + new Headers(init?.headers).has('authorization') + ) { + return (0, this.fetch)(target, init) + } + + /* + * `init ?? {}` because `PasswordSession.fetchHandler` reads `init.headers` + * unguarded, while the inherited signature makes `init` optional. + */ + return inner.fetchHandler(target.href, init ?? {}) + } + + override async refreshSession(): Promise { + const inner = this.#disposed ? null : this.#inner + if (!inner || inner.destroyed) { + throw new Error('No session to refresh') + } + const data = await inner.refresh() + /* + * Re-shape the lex payload into the old XRPC envelope. `headers` is empty + * because the inner session does not surface response headers, and no + * caller in this app reads them off a refresh. + */ + return { + success: true, + headers: {}, + data: { + accessJwt: data.accessJwt, + refreshJwt: data.refreshJwt, + handle: data.handle, + did: data.did, + didDoc: data.didDoc, + email: data.email, + emailConfirmed: data.emailConfirmed, + emailAuthFactor: data.emailAuthFactor, + active: data.active, + status: data.status, + }, + } + } + + /** + * Force a refresh, ignoring the passed-in session data. + * + * The inner session already owns its tokens, so there is nothing to install; + * every call site in the app uses `resumeSession` as "refresh my session + * now". The returned envelope is the refresh one, which is structurally a + * superset of `ComAtprotoServerGetSession.Response` (the shape `AtpAgent` + * advertises), so both layers stay type-correct. + */ + override resumeSession( + _session: AtpSessionData, + ): Promise { + return this.refreshSession() + } + + override async logout(): Promise { + const inner = this.#inner + if (!inner || inner.destroyed) { + return + } + try { + await inner.logout() + } catch { + /* matches the parent, which swallows delete-session failures */ + } + } + + override login( + _opts: AtpAgentLoginOpts, + ): Promise { + return Promise.reject(new Error(UNSUPPORTED)) + } + + override createAccount( + _data: ComAtprotoServerCreateAccount.InputSchema, + _opts?: ComAtprotoServerCreateAccount.CallOptions, + ): Promise { + return Promise.reject(new Error(UNSUPPORTED)) + } + + /** + * Detach this manager from its inner session. + * + * All reads then behave as logged out and requests fall back to the + * unauthenticated `fetch` path. The inner session is left alone: it may still + * be shared, and logging out is a separate, explicit operation. + */ + dispose() { + this.#disposed = true + } +} + +/* + * Declaration merging to narrow the inherited `sessionManager` (typed as + * `CredentialSession` by `AtpAgent`) to the manager `BskyAppAgent` actually + * receives. A `declare` class field would be the direct way to say this, but + * babel's TypeScript transform rejects `declare` fields in this config, and a + * `get sessionManager()` override is forbidden because the parent declares it + * as a property (TS2611). The merge is sound: the constructor passes the + * manager straight to `super`, which assigns it. + */ +// eslint-disable-next-line typescript/no-unsafe-declaration-merging +export interface BskyAppAgent { + readonly sessionManager: PasswordSessionManager +} + +/** + * The app's `AtpAgent`, backed by a `PasswordSession`. + * + * Everything interesting lives in {@link PasswordSessionManager}; this exists + * so `useAgent()` consumers keep getting a real `AtpAgent` (proxy headers, + * labeler headers, the `app`/`com`/`chat` namespaces) and so the agent can be + * disposed alongside its session. + */ +export class BskyAppAgent extends AtpAgent { + constructor(manager: PasswordSessionManager) { + super(manager) + } + + dispose() { + this.sessionManager.dispose() + } +} + +/** + * Build the logged-out agent used for public/guest browsing. + * + * Temporary name: it exists alongside `createPublicAgent` in `./agent` until + * the provider is switched over to the bridge. + */ +export function createPublicBridgeAgent() { + configureModerationForGuest() // Side effect but only relevant for tests + + const agent = new BskyAppAgent( + new PasswordSessionManager(null, {service: PUBLIC_BSKY_SERVICE}), + ) + agent.configureProxy(BLUESKY_PROXY_HEADER.get()) + return agent +}