Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫
Something went wrong. Try again.
15 kB · 483 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484import {type Client} from '@atproto/lex'import { PasswordSession, type PasswordSessionOptions, type SessionData,} from '@atproto/lex-password-session'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'import {canParseUrl} from '#/lib/strings/url-helpers'import {logger} from '#/logger'import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'import {features} from '#/analytics'import { buildAppviewClient, buildChatClient, buildPdsClient, getPublicAppviewClient, getUnauthenticatedThrowingClient, routeSessionToPds,} from './clients'import {addSessionErrorLog} from './logging'import { configureModerationForAccount, configureModerationForGuest,} from './moderation'import {networkAwareFetch} from './network'import {type OauthBskyAppAgent} from './oauth-agent'import { isSessionExpired, sessionAccountToSessionData, sessionDataToSessionAccount,} from './session-data'import {type AtpSessionEvent, type SessionAccount} from './types'
export {networkAwareFetch} from './network'export { isSignupQueued, sessionAccountToSessionData, sessionDataToSessionAccount,} from './session-data'export type {AtpSessionEvent} from './types'
/** * The service the bundle authenticated against. * * `PasswordSession`'s getters throw once the session is destroyed, so the read * is guarded and falls back to the public service. */function deriveServiceUrl(session: PasswordSession | null): URL { return new URL( session && !session.destroyed ? session.session.service : PUBLIC_BSKY_SERVICE, )}
/** The three clients over one `PasswordSession`, the bundle's sole auth core. */export type PasswordSessionBundle = { session: PasswordSession appviewClient: Client pdsClient: Client chatClient: Client readonly service: URL}
/** The same client surface backed by an AT Protocol OAuth session. */export type OAuthSessionBundle = { session: null oauthAgent: OauthBskyAppAgent appviewClient: Client pdsClient: Client chatClient: Client readonly service: URL}
// Keep the historical name for password-session lifecycle hooks and tests.export type SessionBundle = PasswordSessionBundleexport type ActiveSessionBundle = PasswordSessionBundle | OAuthSessionBundle
/** * `PasswordSession` exposes no local (logout-free) destroy, so disposal is * implemented by disabling its injected fetch and hooks. Keep that lifecycle * state private and tied to bundle identity. */const bundleKillSwitches = new WeakMap<PasswordSessionBundle, () => void>()
/** * Register the lifecycle closure used by {@link disposeBundle}. * * Killing the hooks is the whole of disposal now: the clients hold no state of * their own, and every request they make goes through the session's injected * fetch, which the kill switch disables. */export function registerBundleKillSwitch( bundle: PasswordSessionBundle, kill: () => void,) { bundleKillSwitches.set(bundle, kill)}
/** * Build the three clients over a session. * * `storedPdsUrl` pins PDS routing for requests made before a refresh has * delivered a didDoc - see {@link routeSessionToPds}, which explains why the * session's own routing is not sufficient in that window. With no stored url * there is nothing better to pin to, so the clients go straight over the * session and it resolves them against its own service. */export function buildBundle( session: PasswordSession, storedPdsUrl?: string,): PasswordSessionBundle { /* * The stored url is persisted data and may be malformed (legacy writes, * corruption). `routeSessionToPds` feeds it to `new URL()` on every request, * so an invalid value would throw from every client call; discard it here * and let the session route against its own service instead. */ const agent = storedPdsUrl && canParseUrl(storedPdsUrl) ? routeSessionToPds(session, storedPdsUrl) : session return { session, appviewClient: buildAppviewClient(agent), pdsClient: buildPdsClient(agent), chatClient: buildChatClient(agent), get service() { return deriveServiceUrl(session) }, }}
/** * PasswordSession delivers `sessionData` before updating its live getter. The * provider uses that payload for rotated tokens and expiry rescue. */export type OnSessionChange = ( bundle: SessionBundle, did: string, event: AtpSessionEvent, sessionData?: SessionData,) => void
/** * Hooks stay inert during initial session preparation. `kill()` disarms them * and disables the injected fetch so a disposed session cannot refresh or * dispatch. */export function makeSessionHooks({ onSessionChange, getBundle, getDid,}: { onSessionChange: OnSessionChange /** Deferred: hooks are created before the bundle exists. */ getBundle: () => SessionBundle /** Deferred: hooks are created before the bundle exists. */ getDid: () => string}) { let armed = false let killed = false const dispatch = (event: AtpSessionEvent, sessionData?: SessionData) => { if (!armed) { return } /* * A hook must never throw. PasswordSession awaits its hooks inside the * assignment to its internal session promise, so a synchronous throw here * leaves that promise permanently rejected: every later request fails, and * because the session is never marked destroyed, disposeBundle cannot even * see that the bundle is dead. The dispatch path reaches reducer side * effects and event emitters, so treat it as capable of throwing. */ try { const did = getDid() onSessionChange(getBundle(), did, event, sessionData) if (event !== 'update') { addSessionErrorLog(did, event) } } catch (e) { logger.error(e instanceof Error ? e : String(e), { message: `session: onSessionChange threw for a '${event}' event`, }) } } const hooks: PasswordSessionOptions = { fetch: (input, init) => { if (killed) { throw new Error('session disposed') } return networkAwareFetch(input, init) }, onUpdated(data) { dispatch('update', data) }, onDeleted(data) { dispatch('expired', data) }, onUpdateFailure() { dispatch('network-error') }, } return Object.assign(hooks, { arm() { armed = true }, kill() { killed = true armed = false }, })}
/** The clients exposed while logged out. */export type PublicSessionBundle = { session: null appviewClient: Client pdsClient: Client chatClient: Client readonly service: URL}
/** * Build the logged-out bundle. * * `configureModerationForGuest` is what populates the global * `Client.appLabelers` that {@link getPublicAppviewClient} reads for its labeler * header, so it must run before the public client's first request. There is no * agent stamping that header any more, which makes this call load-bearing rather * than test-only: without it a logged-out read would carry no moderation * authorities at all. * * The write surfaces get the throwing client rather than a public one, so an * unauthenticated write fails legibly instead of 4xx-ing against public * infrastructure. */export function createPublicSessionBundle(): PublicSessionBundle { configureModerationForGuest() return { session: null, appviewClient: getPublicAppviewClient(), pdsClient: getUnauthenticatedThrowingClient(), chatClient: getUnauthenticatedThrowingClient(), service: new URL(PUBLIC_BSKY_SERVICE), }}
/** * Run the prepare tail shared by the asynchronous factories. * * Preparation does real network work, so it can both reject and - when a * request gets a 401 and the session's own refresh then fails definitively - * destroy the session underneath us. * * A session destroyed during preparation is fatal rather than recoverable. The * hooks are still disarmed at that point, so the session's `expired` event was * swallowed and nothing will ever tell the reducer to log the account out; * returning the bundle anyway would leave the app looking signed in over a * session that can only make unauthenticated requests. Failing instead matches * what `CredentialSession.resumeSession` did on a revoked token, and every * caller already handles a rejected factory. Checking `destroyed` first also * keeps `PasswordSession`'s `Logged out` getter throw from escaping as the * opaque rejection a caller would surface, so `snapshot` only ever runs against * a live session. * * Both failure modes dispose: the bundle is fully built by this point, and a * still-live session left behind would keep its refresh and dispatch paths * alive with nothing tracking it. (Disposal is a no-op for the destroyed case, * where the session already refuses to refresh - but the two paths are * indistinguishable to the caller, so both go through it.) */export async function finishPreparation<T>( bundle: PasswordSessionBundle, preparation: Promise<unknown>, snapshot: () => T,): Promise<T> { try { await preparation if (bundle.session.destroyed) { throw new Error('Session was revoked while it was being prepared') } return snapshot() } catch (e) { disposeBundle(bundle) throw e }}
/** * Resume a stored account into a {@link SessionBundle}. Expired sessions take a * network resume; still-valid stored tokens take a synchronous no-network fast * path. Hooks are armed only after the prepare tail resolves. */export async function createSessionBundleAndResume( storedAccount: SessionAccount, onSessionChange: OnSessionChange,): Promise<{account: SessionAccount; bundle: PasswordSessionBundle}> { const gates = features.refresh({strategy: 'prefer-low-latency'}) let bundle!: SessionBundle const hooks = makeSessionHooks({ onSessionChange, getBundle: () => bundle, getDid: () => storedAccount.did, })
let session: PasswordSession const sessionData = sessionAccountToSessionData(storedAccount) if (isSessionExpired(storedAccount)) { /* * The arm latch swallows resume's initial onUpdated event. * * There is deliberately no network retry here: `resume` rejects only when * the session is definitively invalid, and it swallows everything else - * a failed refresh reports through `onUpdateFailure` and resolves with the * stale tokens. So an offline cold start now stays signed in with dead * tokens (requests fail until connectivity returns) rather than throwing * the way the old `CredentialSession.resumeSession` did, and retrying a * definitive rejection would only repeat a request that cannot succeed. */ session = await PasswordSession.resume(sessionData, hooks) } else { // Sync fast path: trust the stored tokens, no network. session = new PasswordSession(sessionData, hooks) }
bundle = buildBundle(session, storedAccount.pdsUrl) registerBundleKillSwitch(bundle, hooks.kill) // The returned account is captured again after asynchronous preparation. const earlyAccount = sessionDataToSessionAccount( session.session, session.session.service, storedAccount.pdsUrl, ) ?? storedAccount
void configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: bundle.appviewClient, accountClient: bundle.pdsClient, })
// Preparation may auto-refresh the session while hooks are still disarmed. const account = await finishPreparation( bundle, Promise.all([gates, aa]), () => sessionDataToSessionAccount( session.session, session.session.service, storedAccount.pdsUrl, ) ?? storedAccount, ) hooks.arm() return {account, bundle}}
/** * Log in with credentials and build a {@link SessionBundle}. */export async function createSessionBundleAndLogin( { service, identifier, password, authFactorToken, }: { service: string identifier: string password: string authFactorToken?: string }, onSessionChange: OnSessionChange,): Promise<{account: SessionAccount; bundle: PasswordSessionBundle}> { let bundle!: SessionBundle let accountDid = '' const hooks = makeSessionHooks({ onSessionChange, getBundle: () => bundle, getDid: () => accountDid, })
const session = await PasswordSession.login({ ...hooks, service, identifier, password, authFactorToken, allowTakendown: true, })
bundle = buildBundle(session) registerBundleKillSwitch(bundle, hooks.kill) // Seed the hook's did before it is armed. const earlyAccount = sessionDataToSessionAccountOrThrow(session) accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'}) void configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: bundle.appviewClient, accountClient: bundle.pdsClient, })
// Preparation may auto-refresh the session while hooks are still disarmed. const account = await finishPreparation( bundle, Promise.all([gates, aa]), () => sessionDataToSessionAccountOrThrow(session), ) hooks.arm() return {account, bundle}}
/** * Rebuild a bundle synchronously from stored tokens. The optional guard runs * after construction but before hooks are armed; rejected bundles are disposed. */export function createSessionBundleFromStoredAccount( storedAccount: SessionAccount, onSessionChange: OnSessionChange, shouldActivate: ( bundle: SessionBundle, account: SessionAccount, ) => boolean = () => true,): {account: SessionAccount; bundle: SessionBundle} | undefined { let bundle!: SessionBundle const hooks = makeSessionHooks({ onSessionChange, getBundle: () => bundle, getDid: () => storedAccount.did, }) const session = new PasswordSession( sessionAccountToSessionData(storedAccount), hooks, ) bundle = buildBundle(session, storedAccount.pdsUrl) registerBundleKillSwitch(bundle, hooks.kill) void configureModerationForAccount(bundle, storedAccount)
const account = session.destroyed ? storedAccount : (sessionDataToSessionAccount( session.session, session.session.service, storedAccount.pdsUrl, ) ?? storedAccount) if (!shouldActivate(bundle, account)) { disposeBundle(bundle) return undefined } hooks.arm() return {account, bundle}}
export function sessionDataToSessionAccountOrThrow( session: PasswordSession,): SessionAccount { const account = sessionDataToSessionAccount( session.session, session.session.service, ) if (!account) { throw Error('Expected an active session') } return account}
/** * Disable a replaced bundle without revoking its server session. PasswordSession * has no local destroy operation, so the registered lifecycle closure disables * its fetch and hooks instead. */export function disposeBundle( bundle: ActiveSessionBundle | PublicSessionBundle,) { const session = bundle.session if (!session || session.destroyed) { return } bundleKillSwitches.get(bundle)?.()}