import {useState} from 'react' import {type DidDocument, getPdsEndpoint} from '@atproto/common-web' import {type HandleString} from '@atproto/syntax' import {useQuery, useQueryClient} from '@tanstack/react-query' import {DEFAULT_SERVICE} from '#/lib/constants' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {getPublicAppviewClient} from '#/state/session/clients' import {com} from '#/lexicons' const RQKEY_ROOT = 'pds-detection' export const RQKEY = (identifier: string) => [RQKEY_ROOT, identifier] /** * Normalize a login identifier for detection: lowercase, trim, and strip a * single leading `@`. Handles are often typed as `@alice.example.com`; without * stripping the `@` the identifier looks like an email and detection is * disabled. A real email (`a@b.com`) has no leading `@`, so it still contains * an `@` after normalization and classifies as an email correctly. */ function normalizeIdentifier(identifier: string): string { return identifier.trim().toLowerCase().replace(/^@/, '') } /** * Per-request timeout for identity/PDS resolution network calls. Without it a * hanging plc.directory / did:web `.well-known` fetch (or handle resolution) * could leave the sign-in button spinning indefinitely. */ const RESOLVE_TIMEOUT = 20e3 /** * Run a resolution network op with a per-request timeout. `run` receives an * `AbortSignal` that fires after `RESOLVE_TIMEOUT`, so callers that support * cancellation (fetch, the XRPC client) abort the in-flight request. A timeout * is surfaced as a network error so the caller fails the login rather than * silently falling back to the default service. */ async function withResolveTimeout( run: (signal: AbortSignal) => Promise, ): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT) try { return await run(controller.signal) } catch (err) { if (controller.signal.aborted) { throw new Error('Network request failed: resolution timed out') } throw err } finally { clearTimeout(timer) } } /** * Whether a non-ok HTTP status from an identity fetch is server-side or * transient (5xx or 429) rather than a genuine "not found / invalid" (other * 4xx). Transient statuses must not be treated as "identity doesn't exist", * since that would silently fall back to the default service. */ function isTransientHttpStatus(status: number): boolean { return status >= 500 || status === 429 } /** * Resolve a DID document without a session. * * `com.atproto.identity.resolveIdentity` would give us the DID doc in a single * call, but it requires auth on the entryway and is not implemented on the * appview, so it is unusable here. Instead we resolve the DID doc directly: * `did:plc` via the PLC directory, `did:web` via its `.well-known` endpoint. * * Returns `null` for a genuine "not found / invalid" response (a 4xx or an * unsupported DID method). Throws a network error for transient server-side * failures (5xx, 429) so the caller fails the login rather than silently * submitting the password to the default service during, e.g., a plc.directory * blip. */ async function resolveDidDoc( did: string, signal?: AbortSignal, ): Promise { if (did.startsWith('did:plc:')) { const res = await fetch(`https://plc.directory/${did}`, {signal}) if (!res.ok) { logger.debug('pds-detection: plc.directory returned non-ok status', { did, status: res.status, }) if (isTransientHttpStatus(res.status)) { throw new Error( `Network request failed: plc.directory returned ${res.status}`, ) } return null } return (await res.json()) as DidDocument } if (did.startsWith('did:web:')) { const domain = did.slice('did:web:'.length) /* * did:web method-specific ids are domains; a `:` would indicate a path * component, which the network does not support. Reject those rather than * building a malformed URL. */ if (domain.includes(':')) return null const res = await fetch( `https://${decodeURIComponent(domain)}/.well-known/did.json`, {signal}, ) if (!res.ok) { logger.debug( 'pds-detection: did:web .well-known returned non-ok status', { did, status: res.status, }, ) if (isTransientHttpStatus(res.status)) { throw new Error( `Network request failed: did:web .well-known returned ${res.status}`, ) } return null } return (await res.json()) as DidDocument } logger.debug('pds-detection: unsupported DID method', {did}) return null } /** * Resolve the identity behind a given identifier (handle or DID). * * Returns the resolved DID together with the PDS URL declared by its DID * document (verbatim). `pdsUrl` is `null` when the DID resolved but its * document declares no PDS endpoint. Returns `null` altogether when the * identifier itself cannot be resolved (unknown handle, broken identity, * unsupported DID method). * * Rethrows only on genuine network errors, so a "not found" during typing * stays quiet. */ export async function resolvePdsForIdentifier( identifier: string, ): Promise<{did: string; pdsUrl: string | null} | null> { const norm = normalizeIdentifier(identifier) /* * Resolution runs without a session, so this uses the public appview client * rather than a session-scoped one. It matches the unauthenticated, * unproxied public agent this previously constructed by hand. */ const client = getPublicAppviewClient() try { let did: string if (norm.startsWith('did:')) { did = norm } else { const data = await withResolveTimeout(signal => client.call( com.atproto.identity.resolveHandle, {handle: norm as HandleString}, {signal}, ), ) did = data.did } logger.debug('pds-detection: resolved identifier to DID', { identifier: norm, did, }) const doc = await withResolveTimeout(signal => resolveDidDoc(did, signal)) logger.debug('pds-detection: resolved DID doc', { did, foundDoc: !!doc, }) if (!doc) return null const pds = getPdsEndpoint(doc) logger.debug('pds-detection: got PDS endpoint', { did, pds: pds ?? null, }) return {did, pdsUrl: pds ?? null} } catch (err) { logger.debug('pds-detection: resolution failed', { identifier: norm, error: String(err), isNetworkError: isNetworkError(err), }) if (isNetworkError(err)) throw err return null } } /** * The detection lifecycle for a login identifier, derived from the debounced * resolution query plus any manual override. */ export type HostingProviderState = /** Empty, or not yet a plausible handle (e.g. a bare username). */ | {status: 'idle'} /** The identifier is an email address, so PDS detection is skipped. */ | {status: 'email'} /** A resolution query is in flight for the current identifier. */ | {status: 'detecting'} /** Resolved to a PDS endpoint. */ | {status: 'detected'; pdsUrl: string} /** * The handle genuinely did not resolve (unknown handle, broken identity). * This is the only state that should admonish the user about typos. */ | {status: 'unresolved'} /** * Resolution failed for a network/transient reason (offline, plc.directory * 5xx). Distinct from `unresolved` because it is not evidence the handle is * invalid, so the UI must not suggest a typo. Pressing "Sign in" surfaces the * connectivity error via `resolveService` re-throwing. */ | {status: 'error'} /** The user manually selected a provider. */ | {status: 'overridden'; pdsUrl: string} /** * Autodetects the hosting provider (PDS) for a login identifier as the user * types, with a manual override escape hatch. * * The effective `service` is `override ?? detected ?? defaultService`. * `resolveService` awaits any in-flight detection against the current * (non-debounced) identifier so that pressing "Sign in" mid-detection waits * for resolution and then continues. It resolves to `{service, did}`: the * service to log in against, plus the identifier's resolved DID (`null` when * no DID was resolved - manual override, email, or bare username). It falls * back to `defaultService` for anything that legitimately can't resolve a PDS * (emails, bare usernames, unknown handles) but rethrows genuine network * errors, so a flaky connection fails the login instead of silently * submitting to the default server. */ export function useHostingProvider({ identifier, defaultService = DEFAULT_SERVICE, }: { identifier: string defaultService?: string }): { state: HostingProviderState service: string override: (url: string) => void clearOverride: () => void resolveService: ( currentIdentifier: string, ) => Promise<{service: string; did: string | null}> } { const queryClient = useQueryClient() const [override, setOverride] = useState(null) const normalized = normalizeIdentifier(identifier) const isEmail = normalized.includes('@') const isPlausibleHandle = !!normalized && !isEmail && (normalized.includes('.') || normalized.startsWith('did:')) const debounced = useDebouncedValue(normalized, 500) const enabled = isPlausibleHandle && normalized === debounced const query = useQuery({ enabled, queryKey: RQKEY(debounced), queryFn: () => resolvePdsForIdentifier(debounced), staleTime: STALE.MINUTES.FIVE, }) let state: HostingProviderState if (override != null) { state = {status: 'overridden', pdsUrl: override} } else if (isEmail) { state = {status: 'email'} } else if (!isPlausibleHandle) { state = {status: 'idle'} } else if (normalized !== debounced) { /* * The identifier changed but the debounce hasn't caught up, so the query is * still keyed on the old value. Report 'detecting' rather than the stale * query state, which would otherwise show the previous handle's PDS as * 'detected'. */ state = {status: 'detecting'} } else if (query.isPending || query.isFetching) { state = {status: 'detecting'} } else if (query.isError && isNetworkError(query.error)) { /* * A network/transient failure is not evidence the handle is invalid, so * report 'error' instead of 'unresolved' to avoid a misleading typo hint. */ state = {status: 'error'} } else if (query.isError || query.data == null || query.data.pdsUrl == null) { state = {status: 'unresolved'} } else { state = {status: 'detected', pdsUrl: query.data.pdsUrl} } const service = override ?? (state.status === 'detected' ? state.pdsUrl : defaultService) return { state, service, override: (url: string) => setOverride(url), clearOverride: () => setOverride(null), resolveService: async (currentIdentifier: string) => { if (override != null) return {service: override, did: null} const norm = normalizeIdentifier(currentIdentifier) // Emails and bare usernames can't resolve a PDS on their own. if (norm.includes('@')) return {service: defaultService, did: null} if (!norm.includes('.') && !norm.startsWith('did:')) { return {service: defaultService, did: null} } /* * `resolvePdsForIdentifier` only throws on genuine network errors; * anything unresolvable (unknown handle, broken identity) resolves to * `null`, which we treat as the default service. Network errors are left * to propagate so the caller can fail the login rather than silently * submit the password to the wrong server. */ const resolved = await queryClient.ensureQueryData({ queryKey: RQKEY(norm), queryFn: () => resolvePdsForIdentifier(norm), staleTime: STALE.MINUTES.FIVE, }) // The DID is known even when its doc declares no PDS endpoint. return { service: resolved?.pdsUrl ?? defaultService, did: resolved?.did ?? null, } }, } }