// handle resolution — bidirectional verification, and refusing to render an unverified claim.
// Run: node test/handles.test.mjs
//
// The assertion that matters is the IMPERSONATION case: a DID that claims someone else's handle must
// not render as that person. alsoKnownAs is a claim by the DID, not proof, and our inbound lens retains
// records from arbitrary foreign DIDs discovered via a third-party index — so a UI that trusts the claim
// is an impersonation surface by construction.
import { createHandleResolver, claimedHandle, didFromTxt, displayHandle } from '../lib/handles.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const DID = 'did:plc:y5caotnrle4kxlfez4azaf33'
const HANDLE = 'fleeky.bsky.social'
const OTHER = 'did:plc:impostor00000000000000'
// ── claim extraction ─────────────────────────────────────────────────────────────────────────────
ok(claimedHandle(['at://fleeky.bsky.social']) === HANDLE, 'at:// prefix stripped')
ok(claimedHandle(['at://P2P.7RNX.NET']) === 'p2p.7rnx.net', 'handles are lowercased — they are domain names')
ok(claimedHandle(['https://example.com', 'at://a.b']) === 'a.b', 'non-at:// entries are skipped; alsoKnownAs is a list of arbitrary URIs')
ok(claimedHandle([]) === null && claimedHandle(null) === null, 'absent is null')
ok(claimedHandle(['at://nodot']) === null, 'a handle must be a domain name')
ok(claimedHandle(['at://a.b/path']) === null, 'a URI with a path is not a handle')
// ── DNS TXT parsing ──────────────────────────────────────────────────────────────────────────────
ok(didFromTxt([['did=' + DID]]) === DID, 'chunked TXT arrays are joined before matching')
ok(didFromTxt(['did=' + DID]) === DID, 'flat strings work too')
ok(didFromTxt([['v=spf1 -all'], ['did=' + DID]]) === DID, 'the did= record is picked out of an unrelated set')
ok(didFromTxt([['notadid']]) === null, 'no did= record is null')
ok(didFromTxt(null) === null, 'garbage is null')
// ── verification: HTTPS well-known ───────────────────────────────────────────────────────────────
const plcFor = (did, aka) => ({ getData: async () => ({ did, alsoKnownAs: aka }) })
const okText = (t) => ({ ok: true, text: async () => t })
{
let url = null
const r = createHandleResolver({
plc: plcFor(DID, ['at://' + HANDLE]),
doFetch: async (u) => { url = u; return okText(DID) }
})
const got = await r.resolve(DID)
ok(got.verified === true && got.via === 'well-known', 'a well-known that returns our DID verifies')
ok(url === `https://${HANDLE}/.well-known/atproto-did`, 'probing the handle, not the DID')
ok(displayHandle(got) === HANDLE, 'and a verified handle is what the UI shows')
}
// ── verification: DNS TXT (what *.bsky.social actually uses) ─────────────────────────────────────
{
let queried = null
const r = createHandleResolver({
plc: plcFor(DID, ['at://' + HANDLE]),
doFetch: async () => { throw new Error('no well-known here') },
resolveTxt: async (n) => { queried = n; return [['did=' + DID]] }
})
const got = await r.resolve(DID)
ok(got.verified === true && got.via === 'dns', 'DNS TXT verifies when the well-known path is absent')
ok(queried === `_atproto.${HANDLE}`, 'querying _atproto.<handle>')
ok(displayHandle(got) === HANDLE, 'shown')
}
{
// Implementing only HTTPS would fail every bsky.social handle — half the network rendered invalid.
const r = createHandleResolver({
plc: plcFor(DID, ['at://' + HANDLE]),
doFetch: async () => { throw new Error('unreachable') }
})
const got = await r.resolve(DID)
ok(got.verified === false, 'with NO dns resolver wired, a DNS-backed handle cannot verify')
ok(displayHandle(got) === 'handle.invalid', 'and it must not be rendered as the claim')
}
// ── THE IMPERSONATION CASE ───────────────────────────────────────────────────────────────────────
{
// An impostor DID whose document claims someone else's handle. Both mechanisms answer with the REAL
// owner's DID, which does not match the claimant.
const r = createHandleResolver({
plc: plcFor(OTHER, ['at://' + HANDLE]),
doFetch: async () => okText(DID), // well-known says the handle belongs to the REAL did
resolveTxt: async () => [['did=' + DID]] // so does DNS
})
const got = await r.resolve(OTHER)
ok(got.handle === HANDLE, 'the claim is reported...')
ok(got.verified === false, '...but NOT verified — the handle resolves back to a different DID')
ok(displayHandle(got) === 'handle.invalid',
'so the UI shows handle.invalid, never the claim: rendering it would let anyone appear as anyone')
}
// ── failure and cache behaviour ──────────────────────────────────────────────────────────────────
{
const r = createHandleResolver({ plc: plcFor(DID, []), doFetch: async () => okText(DID) })
ok((await r.resolve(DID)) === null, 'a DID with no handle claim resolves to null')
ok(displayHandle(null) === null, 'and the UI falls back (to a short DID) rather than inventing a name')
}
{
let calls = 0
const r = createHandleResolver({
plc: { getData: async () => { calls++; return { did: DID, alsoKnownAs: ['at://' + HANDLE] } } },
doFetch: async () => okText(DID)
})
await r.resolve(DID); await r.resolve(DID)
ok(calls === 1, 'resolution is cached — a timeline of N posts by one author must not be N lookups')
}
{
let calls = 0
const r = createHandleResolver({
plc: { getData: async () => { calls++; throw new Error('502') } },
doFetch: async () => okText(DID)
})
ok((await r.resolve(DID)) === null, 'a directory failure is null, not a throw — a name lookup must never break a render')
await r.resolve(DID)
ok(calls === 1, 'and negatives are cached briefly so a dead DID cannot hammer the directory per post')
}
{
const r = createHandleResolver({ plc: plcFor(DID, ['at://' + HANDLE]), doFetch: async () => okText(DID) })
ok((await r.resolve('not-a-did')) === null, 'non-DIDs resolve to null')
}
console.log(`\nHANDLES OK: ${pass} assertions`)