// handles.js — resolve a DID to a DISPLAYABLE, VERIFIED handle.
//
// WHY THIS IS NOT JUST READING alsoKnownAs. A DID document's `alsoKnownAs` is a CLAIM MADE BY THE DID,
// not proof. Anyone can mint a did:plc whose document says `at://fleeky.bsky.social` and, if we rendered
// that, they would appear in the UI as that person. atproto's rule is BIDIRECTIONAL verification: the
// handle must independently resolve BACK to the same DID. Unverified handles must render as
// `handle.invalid` (the same convention Slingshot and @atproto/identity use), never as the bare claim.
//
// This matters more here than in a normal client: our inbound lens retains records authored by arbitrary
// foreign DIDs discovered through a third-party index. Impersonation is the obvious attack on a UI that
// shows names next to content, and displaying an unverified claim is exactly how it lands.
//
// TWO MECHANISMS, because atproto specifies either:
// 1. HTTPS — GET https://<handle>/.well-known/atproto-did -> the DID as plain text
// 2. DNS — TXT _atproto.<handle> -> "did=<did>"
// MEASURED 2026-07-31: BOTH handles we actually serve verify over HTTPS — p2p.7rnx.net (self-hosted)
// and fleeky.bsky.social (bsky-hosted) each return their DID from the well-known path, and
// `dig TXT _atproto.fleeky.bsky.social` returns nothing. So HTTPS carries the load today. DNS is
// implemented anyway because the spec permits it and some self-hosted handles use only that — a handle
// we cannot verify renders as handle.invalid, so a missing mechanism costs real names, not just
// coverage.
//
// PURE + INJECTED. `doFetch`, `resolveTxt` and `now` are parameters, so both verification paths, the
// mismatch case and the cache are testable with no network and no DNS.
const TTL_MS = 30 * 60 * 1000 // handles change rarely; a rename is what this lags on
const NEG_TTL_MS = 5 * 60 * 1000
// `alsoKnownAs: ['at://fleeky.bsky.social']` -> 'fleeky.bsky.social'. Returns null for anything that is
// not an at:// handle URI (the field is a list of arbitrary URIs by spec).
export function claimedHandle (alsoKnownAs) {
if (!Array.isArray(alsoKnownAs)) return null
for (const aka of alsoKnownAs) {
if (typeof aka !== 'string' || !aka.startsWith('at://')) continue
const h = aka.slice(5).trim().toLowerCase()
// A handle is a domain name. Reject anything with a path/query, or that is obviously not one.
if (!h || h.includes('/') || h.includes('?') || !h.includes('.')) continue
return h
}
return null
}
// A DNS TXT record set for _atproto.<handle> may contain several strings; the DID is the one shaped
// `did=...`. Chunked TXT values arrive as arrays and must be joined before matching.
export function didFromTxt (records) {
if (!Array.isArray(records)) return null
for (const r of records) {
const s = Array.isArray(r) ? r.join('') : String(r)
const m = s.trim().match(/^did=(did:[a-z0-9:._%-]+)$/i)
if (m) return m[1]
}
return null
}
// What the UI should show, in preference order. Never the raw claim when unverified.
export function displayHandle (resolved) {
if (!resolved || !resolved.handle) return null
return resolved.verified ? resolved.handle : 'handle.invalid'
}
export function createHandleResolver ({
plc, // PlcDirectory-like: getData(did) -> { alsoKnownAs, ... }
doFetch, // for the .well-known probe
resolveTxt = null, // async (name) -> string[]|string[][]; null disables the DNS path
ttlMs = TTL_MS,
now = () => Date.now()
} = {}) {
if (!plc) throw new Error('createHandleResolver requires plc')
if (typeof doFetch !== 'function') throw new Error('createHandleResolver requires doFetch')
const cache = new Map()
// Does `handle` independently resolve back to `did`? Either mechanism suffices.
async function verifies (handle, did) {
// 1. HTTPS well-known.
try {
const res = await doFetch(`https://${handle}/.well-known/atproto-did`, {
method: 'GET',
headers: { accept: 'text/plain' },
timeout: 5000
})
if (res && res.ok !== false) {
const txt = (await res.text()).trim()
if (txt === did) return 'well-known'
}
} catch { /* fall through to DNS — an unreachable host is not a verdict */ }
// 2. DNS TXT.
if (typeof resolveTxt === 'function') {
try {
const recs = await resolveTxt(`_atproto.${handle}`)
if (didFromTxt(recs) === did) return 'dns'
} catch { /* no record / NXDOMAIN — not verified, not an error */ }
}
return null
}
return {
// -> { did, handle, verified, via } | null. `handle` is the CLAIM; consult `verified` before
// showing it, or use displayHandle() which enforces that for you.
async resolve (did) {
if (typeof did !== 'string' || !did.startsWith('did:')) return null
const hit = cache.get(did)
if (hit && hit.expires > now()) return hit.value
let value = null
try {
const data = await plc.getData(did)
const handle = claimedHandle(data && data.alsoKnownAs)
if (handle) {
const via = await verifies(handle, did)
value = { did, handle, verified: via !== null, via }
}
} catch {
value = null // network/404 — retry sooner than a success
}
cache.set(did, { value, expires: now() + (value === null ? NEG_TTL_MS : ttlMs) })
return value
},
get size () { return cache.size }
}
}
// bare-dns is callback-style; wrap it for the daemon. Kept here so the resolver itself stays runtime
// agnostic and node-testable (bare-dns pulls a native addon that cannot load under node).
export function bareResolveTxt (dns) {
return (name) => new Promise((resolve, reject) => {
dns.resolveTxt(name, (err, records) => (err ? reject(err) : resolve(records)))
})
}