diff --git a/app/lib/handle-resolver.test.ts b/app/lib/handle-resolver.test.ts index c8c3138..c1b6a23 100644 --- a/app/lib/handle-resolver.test.ts +++ b/app/lib/handle-resolver.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { HandleResolver } from '@atproto-labs/handle-resolver' -import { AuthoritativeFirstHandleResolver } from './handle-resolver' +import { AuthoritativeFirstHandleResolver, resolveTxtViaDoh } from './handle-resolver' const resolver = (impl: (handle: string) => Promise): HandleResolver => ({ resolve: vi.fn(impl) as HandleResolver['resolve'], @@ -42,3 +42,34 @@ describe('AuthoritativeFirstHandleResolver', () => { expect(await composite.resolve('nobody.example.com')).toBeNull() }) }) + +describe('resolveTxtViaDoh', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('parses TXT answers, stripping quotes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: '"did=did:web:fry69.dev"' }] }), { + headers: { 'content-type': 'application/dns-json' }, + }), + ), + ) + expect(await resolveTxtViaDoh('_atproto.fry69.dev')).toEqual(['did=did:web:fry69.dev']) + }) + + it('returns null instead of throwing when the DoH endpoint is blocked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('Failed to fetch') + }), + ) + expect(await resolveTxtViaDoh('_atproto.fry69.dev')).toBeNull() + }) + + it('returns null on non-ok responses', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 403 }))) + expect(await resolveTxtViaDoh('_atproto.fry69.dev')).toBeNull() + }) +}) diff --git a/app/lib/handle-resolver.ts b/app/lib/handle-resolver.ts index 8b7cab9..89a7223 100644 --- a/app/lib/handle-resolver.ts +++ b/app/lib/handle-resolver.ts @@ -3,17 +3,17 @@ // Bluesky's AppView resolver (bsky.social) serves its own handle index, which // can go stale — e.g. a user who moved from did:plc to did:web while keeping // the same domain handle. A stale answer fails atproto's bidirectional -// handle↔DID check and sign-in dies silently. +// handle↔DID check and sign-in dies with "Failed to resolve identity". // // This resolver runs the official atproto resolution strategy first — DNS TXT -// `_atproto.` via DNS-over-HTTPS (browsers can't do raw DNS) plus the -// HTTPS well-known fallback — so the domain owner's own records win. Only if -// the authoritative path yields nothing (no TXT record and a well-known -// endpoint the browser can't read cross-origin) does it fall back to the -// AppView resolver, keeping every currently-working handle working. +// `_atproto.` via DNS-over-HTTPS (browsers can't do raw DNS) and the +// HTTPS well-known lookup — so the domain owner's own records win. Only when +// neither authoritative route yields an answer (no TXT record and a +// well-known endpoint the browser can't read cross-origin) does it fall back +// to the AppView resolver, keeping every currently-working handle working. import { - AtprotoDohHandleResolver, + AtprotoHandleResolver, XrpcHandleResolver, type HandleResolver, type ResolveHandleOptions, @@ -24,6 +24,34 @@ import { const DOH_ENDPOINT = 'https://mozilla.cloudflare-dns.com/dns-query' const FALLBACK_APPVIEW = 'https://bsky.social' +// DNS TXT lookup over DoH that treats every failure as "no answer" instead +// of throwing. The library's own DoH resolver propagates fetch failures, +// which aborts the whole authoritative resolver before the HTTPS well-known +// route gets a chance — and DoH endpoints are commonly blocked by content +// blockers, browser shields, and filtered networks. +export async function resolveTxtViaDoh(hostname: string): Promise { + try { + const url = new URL(DOH_ENDPOINT) + url.searchParams.set('type', 'TXT') + url.searchParams.set('name', hostname) + const response = await fetch(url, { + method: 'GET', + headers: { accept: 'application/dns-json' }, + redirect: 'follow', + }) + if (!response.ok) return null + const result = (await response.json()) as { + Answer?: Array<{ type: number; data: string }> + } + if (!Array.isArray(result.Answer)) return null + return result.Answer + .filter((answer) => answer.type === 16) // TXT records + .map((answer) => answer.data.replace(/^"|"$/g, '').replace(/\\"/g, '"')) + } catch { + return null + } +} + export class AuthoritativeFirstHandleResolver implements HandleResolver { constructor( private readonly authoritative: HandleResolver, @@ -35,8 +63,8 @@ export class AuthoritativeFirstHandleResolver implements HandleResolver { const did = await this.authoritative.resolve(handle, options) if (did) return did } catch { - // DoH endpoint unreachable (blocked network) or well-known fetch - // failed — the fallback still gets its chance below. + // Both authoritative routes failed (e.g. no TXT record and a + // CORS-blocked well-known) — the fallback still gets its chance. } options?.signal?.throwIfAborted() return this.fallback.resolve(handle, options) @@ -45,7 +73,7 @@ export class AuthoritativeFirstHandleResolver implements HandleResolver { export function createLemmaHandleResolver(): HandleResolver { return new AuthoritativeFirstHandleResolver( - new AtprotoDohHandleResolver({ dohEndpoint: DOH_ENDPOINT }), + new AtprotoHandleResolver({ resolveTxt: resolveTxtViaDoh }), new XrpcHandleResolver(FALLBACK_APPVIEW), ) }