OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / handles.js

132 lines · javascript ref:
// 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)))
  })
}