OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 7eb2250

handles: resolve DNS TXT over TCP instead of bare-dns, which segfaults
bare-dns cannot be used for TXT lookups. `bare_dns_resolve_txt` allocates its request struct in a JS arraybuffer, hands the raw pointer to c-ares, and returns without keeping a reference — index.js discards the return value too — so any GC during the query frees it and the callback reads freed memory: #0 js_open_handle_scope #1 bare_dns__on_resolve_txt #2 ares_query_dnsrec_cb #3 end_query #4 ares_process_fds_nolock #5 uv.io_poll #6 uv_run SIGSEGV, so uncatchable from JS. 819 crashes in a week on the public backend (median 243s uptime). Fixed in our bare-dns fork and headed upstream, but the addon is STATICALLY LINKED INTO THE BARE RUNTIME — nm shows bare_dns_exports as a local symbol in the executable and require.addon.resolve() returns the builtin — so it cannot be fixed by patching a dependency. It needs a runtime rebuild. ★ Turning the DNS path off was NOT an option, and I had that backwards at first. Measured across real handles: pfrazee.com TXT did=… HTTPS 308 dholms.xyz TXT did=… HTTPS 302 jay.bsky.team TXT did=… HTTPS connection failed bsky.app TXT did=… HTTPS 404 fleeky.bsky.social no TXT HTTPS 200 p2p.7rnx.net no TXT HTTPS 200 Handles ARE domain names, and for every custom domain sampled — including Bluesky's own — DNS TXT is the ONLY working verification path. It is not a rare fallback; it is the primary mechanism for most real identities, and without it they render handle.invalid. It also explains the crash distribution: tabertron resolves mostly our own HTTPS-verified handles and never crashed, while stortron's inbound lens resolves foreign custom domains constantly. So: a minimal TXT client. DNS over TCP (RFC 7766) rather than UDP — every resolver supports it including the systemd-resolved stub, it removes the truncation dance, and bare-net is already present, whereas bare-dgram would mean adding another native addon, which is the class of thing that caused this. Same resolver as before: /etc/resolv.conf, which is what c-ares defaulted to. No third-party DoH — handle verification is the last place to hand a stranger the list of every identity we look up. The codec is pure Uint8Array work with no I/O so it runs under node in the suite, and the transport takes `connect` injected. 28 assertions, weighted toward malformed input because this parses bytes off the network: truncated headers, rdlength past the end, self-referential compression pointers, wrong-id responses, split TCP chunks, closed connections, and a resolver that never answers. Verified end-to-end under bare against the live system resolver — pfrazee.com, dholms.xyz and bsky.app all resolve to the same DIDs `dig` returns, p2p.7rnx.net correctly reports no record — and under the exact GC-pressure conditions that kill bare-dns 100% of the time: 3/3 runs, 64/64 resolved, no fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7eb2250b994a79a9936cf33484fe84296c82f1b1
by plankatron <fleeky@gmail.com> · 2026-08-24T18:50:21.000Z

Changed files (4) +412 -7

bin/favela-web.js +31 -3
……@@ -31,9 +31,10 @@ import { createApInbox } from '../lib/ap-inbox.js'
3131import { tidNow } from 'atproto-store/lib/tid.js' // rkey minting for like/follow records
3232import { strongRef, parseAtUri } from '../lib/strongref.js'
3333import { resolveVerifyKey } from '../lib/plc.js'
34import bareDns from 'bare-dns'
34import net from 'bare-net'
3535import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
36import { createHandleResolver, bareResolveTxt } from '../lib/handles.js'
36import { createHandleResolver } from '../lib/handles.js'
37import { createTcpResolveTxt, parseResolvConf } from '../lib/dns-txt.js'
3738import { createFollowerLens } from '../lib/followers.js'
3839import { createApFollowers } from '../lib/ap-followers.js'
3940import { createConstellationClient } from '../lib/constellation.js'
……@@ -278,10 +279,37 @@ async function getRecordStore (did) {
278279// bidirectionally before anything renders it. Both mechanisms are wired: /.well-known/atproto-did
279280// carries both handles we serve (measured), DNS TXT is the spec's alternative. Unverified ->
280281// 'handle.invalid', so a DID claiming someone else's handle can never render as that person.
282//
283// ⛔ NOT bare-dns. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer that nothing
284// references, so any GC during a query frees it and the callback faults — a SIGSEGV, uncatchable from
285// JS, which took the public backend down 819 times in a week. The addon is statically linked into the
286// bare runtime, so it cannot be fixed by patching a dependency. See lib/dns-txt.js.
287//
288// This is NOT an optional path. Measured 2026-08-24: every custom-domain handle sampled — pfrazee.com,
289// dholms.xyz, jay.bsky.team, bsky.app — verifies ONLY over DNS TXT, because their /.well-known is a
290// redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own *.7rnx.net) are the reverse.
291// Without TXT, most of the interesting identities on the network render handle.invalid.
292//
293// Same resolver as before: whatever /etc/resolv.conf points at, which is what c-ares defaulted to. No
294// third-party DoH, because handle verification is the last place to hand a stranger the list of every
295// identity we look up.
296const nameservers = (() => {
297 try {
298 return parseResolvConf(fs.readFileSync('/etc/resolv.conf', 'utf8'))
299 } catch {
300 return []
301 }
302})()
303if (nameservers.length === 0) {
304 console.log('[favela] no nameserver in /etc/resolv.conf — DNS TXT handle verification is OFF')
305}
281306const handleResolver = createHandleResolver({
282307 plc: new PlcDirectory({ fetchImpl: bareFetch }),
283308 doFetch: bareFetch,
284 resolveTxt: bareResolveTxt(bareDns)
309 // null disables the DNS path rather than pointing it at nothing.
310 resolveTxt: nameservers.length
311 ? createTcpResolveTxt({ connect: (opts) => net.createConnection(opts), servers: nameservers })
312 : null
285313})
286314// isForeign gates whether the appview may CREATE record bytes: for a DID THE LOCAL HPP HOSTS, hpp is the
287315// authority and the sole author of those bytes, so an absent file means "not materialised here yet", not
lib/dns-txt.js +250 -0
……@@ -0,0 +1,250 @@
1// dns-txt.js — a minimal DNS TXT client, because bare-dns segfaults the process.
2//
3// WHY THIS EXISTS. atproto handles ARE domain names, and a handle is only displayable once it
4// verifies BACK to the DID (see handles.js). There are two mechanisms and we try HTTPS well-known
5// first, but measured 2026-08-24, every custom-domain handle sampled verifies ONLY by DNS TXT —
6// pfrazee.com, dholms.xyz, jay.bsky.team and bsky.app all return did= over TXT while their
7// /.well-known/atproto-did is a redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own
8// *.7rnx.net) are the reverse. So TXT is not a fallback for rare names; it is the primary mechanism
9// for most of the interesting identities on the network, and without it they render handle.invalid.
10//
11// bare-dns cannot be used for it. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer
12// that nothing references, so any GC during the query frees it and the callback faults:
13//
14// #0 js_open_handle_scope #1 bare_dns__on_resolve_txt #2 ares_query_dnsrec_cb ... #7 uv_run
15//
16// That is a SIGSEGV, not an exception — uncatchable, and it took the public backend down 819 times in
17// a week. The addon is statically linked into the bare runtime, so it cannot be fixed by patching a
18// dependency; it needs a runtime rebuild. Until then, this.
19//
20// DNS over TCP (RFC 7766), not UDP. Every resolver supports it including the systemd-resolved stub,
21// it removes the 512-byte truncation dance entirely, and bare-net is already present — adding
22// bare-dgram would mean adding another native addon, which is the class of thing that put us here.
23//
24// PURE + INJECTED, like handles.js: the codec below is plain Uint8Array work with no I/O so it runs
25// under node in the test suite, and the transport takes `connect` as a parameter.
26
27const TYPE_TXT = 16
28const CLASS_IN = 1
29const HEADER_LEN = 12
30const MAX_NAME_LEN = 255
31const MAX_LABEL_LEN = 63
32// A compressed name could otherwise point at itself and spin forever. The bound is arbitrary but far
33// above any legitimate chain.
34const MAX_POINTER_JUMPS = 64
35
36export const RCODE = { NOERROR: 0, FORMERR: 1, SERVFAIL: 2, NXDOMAIN: 3, NOTIMP: 4, REFUSED: 5 }
37
38// `nameserver 127.0.0.53` lines, in file order. Everything else in resolv.conf is ignored: we only
39// need somewhere to send the query, and options/search are the stub resolver's business.
40export function parseResolvConf (text) {
41 if (typeof text !== 'string') return []
42 const out = []
43 for (const raw of text.split('\n')) {
44 const line = raw.trim()
45 if (!line || line.startsWith('#') || line.startsWith(';')) continue
46 const m = line.match(/^nameserver\s+(\S+)$/i)
47 if (m) out.push(m[1])
48 }
49 return out
50}
51
52export function encodeQuery (name, id) {
53 if (typeof name !== 'string' || !name) throw new Error('name required')
54 const labels = name.replace(/\.$/, '').split('.')
55 let qnameLen = 1 // the terminating root label
56 for (const l of labels) {
57 const bytes = utf8(l)
58 if (bytes.length === 0) throw new Error('empty label in ' + name)
59 if (bytes.length > MAX_LABEL_LEN) throw new Error('label too long in ' + name)
60 qnameLen += 1 + bytes.length
61 }
62 if (qnameLen > MAX_NAME_LEN) throw new Error('name too long: ' + name)
63
64 const buf = new Uint8Array(HEADER_LEN + qnameLen + 4)
65 const view = new DataView(buf.buffer)
66
67 view.setUint16(0, id & 0xffff)
68 view.setUint16(2, 0x0100) // RD — we want the resolver to do the work
69 view.setUint16(4, 1) // one question
70 // ancount / nscount / arcount stay zero
71
72 let o = HEADER_LEN
73 for (const l of labels) {
74 const bytes = utf8(l)
75 buf[o++] = bytes.length
76 buf.set(bytes, o)
77 o += bytes.length
78 }
79 buf[o++] = 0
80
81 view.setUint16(o, TYPE_TXT); o += 2
82 view.setUint16(o, CLASS_IN)
83
84 return buf
85}
86
87// -> { id, rcode, answers } where answers is an array per TXT record of its character-strings, which
88// is the shape didFromTxt() already expects (it joins arrays before matching).
89export function decodeResponse (buf, expectedId = null) {
90 if (!(buf instanceof Uint8Array)) throw new Error('response must be a Uint8Array')
91 if (buf.length < HEADER_LEN) throw new Error('response shorter than a DNS header')
92 const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
93
94 const id = view.getUint16(0)
95 // A response for a different query is not our answer. Cheap, and it is the only thing standing
96 // between us and an off-path answer on a shared socket.
97 if (expectedId !== null && id !== expectedId) throw new Error('response id mismatch')
98
99 const flags = view.getUint16(2)
100 const rcode = flags & 0x0f
101 const qdcount = view.getUint16(4)
102 const ancount = view.getUint16(6)
103
104 let o = HEADER_LEN
105 for (let i = 0; i < qdcount; i++) {
106 o = skipName(buf, o)
107 o += 4 // qtype + qclass
108 if (o > buf.length) throw new Error('truncated question section')
109 }
110
111 const answers = []
112 for (let i = 0; i < ancount; i++) {
113 if (o >= buf.length) break
114 o = skipName(buf, o)
115 if (o + 10 > buf.length) throw new Error('truncated answer header')
116 const type = view.getUint16(o); o += 2
117 o += 2 // class
118 o += 4 // ttl
119 const rdlength = view.getUint16(o); o += 2
120 if (o + rdlength > buf.length) throw new Error('rdlength past end of response')
121
122 if (type === TYPE_TXT) {
123 const chunks = []
124 let p = o
125 const end = o + rdlength
126 while (p < end) {
127 const len = buf[p++]
128 if (p + len > end) throw new Error('TXT character-string past end of rdata')
129 chunks.push(ascii(buf.subarray(p, p + len)))
130 p += len
131 }
132 answers.push(chunks)
133 }
134 o += rdlength
135 }
136
137 return { id, rcode, answers }
138}
139
140// Names may be compressed as a pointer to an earlier offset. We never need the name itself here —
141// only to step over it — so this resolves pointers just far enough to find where the name ends.
142function skipName (buf, offset) {
143 let o = offset
144 let jumps = 0
145 while (true) {
146 if (o >= buf.length) throw new Error('name past end of response')
147 const len = buf[o]
148 if (len === 0) return o + 1
149 if ((len & 0xc0) === 0xc0) {
150 // A pointer is two bytes and the name ENDS there, whatever it points at.
151 if (o + 2 > buf.length) throw new Error('truncated compression pointer')
152 if (++jumps > MAX_POINTER_JUMPS) throw new Error('too many compression pointers')
153 return o + 2
154 }
155 if ((len & 0xc0) !== 0) throw new Error('reserved label type')
156 o += 1 + len
157 }
158}
159
160function utf8 (s) {
161 const out = []
162 for (const ch of unescape(encodeURIComponent(s))) out.push(ch.charCodeAt(0))
163 return Uint8Array.from(out)
164}
165
166function ascii (bytes) {
167 let s = ''
168 for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
169 return s
170}
171
172// DNS over TCP frames each message with a two-byte big-endian length.
173export function frame (msg) {
174 const out = new Uint8Array(2 + msg.length)
175 new DataView(out.buffer).setUint16(0, msg.length)
176 out.set(msg, 2)
177 return out
178}
179
180/**
181 * Build the resolveTxt(name) -> string[][] that handles.js takes.
182 *
183 * `connect` is injected (bare-net's createConnection in the daemon, a fake in tests) so none of this
184 * needs a socket to be tested.
185 */
186export function createTcpResolveTxt ({
187 connect,
188 servers = ['127.0.0.1'],
189 port = 53,
190 timeoutMs = 5000,
191 nextId = () => Math.floor(Math.random() * 0xffff)
192} = {}) {
193 if (typeof connect !== 'function') throw new Error('createTcpResolveTxt requires connect')
194 if (!Array.isArray(servers) || servers.length === 0) throw new Error('at least one server required')
195
196 return function resolveTxt (name) {
197 return new Promise((resolve, reject) => {
198 const id = nextId()
199 const query = frame(encodeQuery(name, id))
200
201 const socket = connect({ host: servers[0], port })
202
203 let settled = false
204 let expected = -1
205 let received = new Uint8Array(0)
206
207 const finish = (err, value) => {
208 if (settled) return
209 settled = true
210 clearTimeout(timer)
211 try { socket.destroy() } catch {}
212 err ? reject(err) : resolve(value)
213 }
214
215 // A resolver that accepts the connection and never answers would otherwise hang the caller
216 // forever — which is the failure mode that made the bare-dns bug so expensive to notice.
217 const timer = setTimeout(() => finish(new Error('DNS query timed out after ' + timeoutMs + 'ms')), timeoutMs)
218 if (typeof timer.unref === 'function') timer.unref()
219
220 socket.on('error', (err) => finish(err))
221 socket.on('close', () => finish(new Error('DNS connection closed before a complete response')))
222
223 socket.on('data', (chunk) => {
224 const next = new Uint8Array(received.length + chunk.length)
225 next.set(received, 0)
226 next.set(chunk, received.length)
227 received = next
228
229 if (expected < 0) {
230 if (received.length < 2) return
231 expected = (received[0] << 8) | received[1]
232 }
233 if (received.length < 2 + expected) return
234
235 try {
236 const { rcode, answers } = decodeResponse(received.subarray(2, 2 + expected), id)
237 // NXDOMAIN and an empty answer set are both "no record", which is a verdict, not a failure.
238 if (rcode !== RCODE.NOERROR && rcode !== RCODE.NXDOMAIN) {
239 return finish(new Error('DNS rcode ' + rcode))
240 }
241 finish(null, answers)
242 } catch (err) {
243 finish(err)
244 }
245 })
246
247 socket.on('connect', () => socket.write(query))
248 })
249 }
250}
package.json +4 -4
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-web",
3 "version": "0.3.71",
4 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
3 "version": "0.3.72",
4 "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
77 "bin": {
……@@ -55,7 +55,7 @@
5555 "spike:seam": "node spike/atcute-seam.mjs",
5656 "spike:p2p": "node spike/p2p-read.mjs",
5757 "web": "bare bin/favela-web.js",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs && node test/dns-txt.test.mjs",
5959 "test:ws": "bare test/subscribe-labels-ws.test.mjs",
6060 "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs",
6161 "spike:pack": "bare spike/mobile-pack-probe.mjs",
……@@ -70,9 +70,9 @@
7070 "atproto-room": "file:../atproto-room",
7171 "atproto-store": "file:../atproto-store",
7272 "b4a": "^1.8.1",
73 "bare-dns": "^2.1.4",
7473 "bare-fetch": "^3.0.1",
7574 "bare-http1": "^4.0.0",
75 "bare-net": "^2.3.2",
7676 "bare-process": "^4.0.0",
7777 "bare-ws": "^3.0.0",
7878 "blind-pairing": "^2.3.1",
test/dns-txt.test.mjs +127 -0
……@@ -0,0 +1,127 @@
1// DNS TXT client — the replacement for bare-dns, which segfaults the process on any TXT lookup.
2// Run: node test/dns-txt.test.mjs
3//
4// atproto handles ARE domain names, and measured 2026-08-24 every custom-domain handle sampled
5// (pfrazee.com, dholms.xyz, jay.bsky.team, bsky.app) verifies ONLY over TXT — their HTTPS well-known
6// is a redirect, a 404 or nothing. So this codec is on the path for most real identities, and the
7// tests that matter are the malformed-input ones: it parses bytes from the network.
8import {
9 parseResolvConf, encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE
10} from '../lib/dns-txt.js'
11
12let pass = 0
13const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
14const throws = (fn, m) => { try { fn(); console.error('FAIL (did not throw):', m); process.exit(1) } catch { console.log(' ok -', m); pass++ } }
15
16// ── resolv.conf ──────────────────────────────────────────────────────────────────────────────────
17ok(JSON.stringify(parseResolvConf('nameserver 127.0.0.53\noptions edns0\n')) === '["127.0.0.53"]', 'nameserver lines are extracted, options ignored')
18ok(parseResolvConf('# nameserver 8.8.8.8\n').length === 0, 'comments are not nameservers')
19ok(JSON.stringify(parseResolvConf('nameserver 1.1.1.1\nnameserver 8.8.8.8')) === '["1.1.1.1","8.8.8.8"]', 'order is preserved')
20ok(parseResolvConf(null).length === 0, 'garbage in, empty out')
21
22// ── query encoding ───────────────────────────────────────────────────────────────────────────────
23const q = encodeQuery('_atproto.pfrazee.com', 0x1234)
24ok((q[0] << 8 | q[1]) === 0x1234, 'query carries the id we asked for')
25ok((q[2] << 8 | q[3]) === 0x0100, 'recursion desired — the stub resolver does the work')
26ok((q[4] << 8 | q[5]) === 1, 'exactly one question')
27ok(q[12] === 8 && String.fromCharCode(...q.subarray(13, 21)) === '_atproto', 'first label is length-prefixed')
28ok(q[q.length - 4] === 0 && q[q.length - 3] === 16, 'qtype is TXT (16)')
29ok(q[q.length - 2] === 0 && q[q.length - 1] === 1, 'qclass is IN (1)')
30throws(() => encodeQuery('a'.repeat(64) + '.com', 1), 'a label over 63 bytes is rejected')
31throws(() => encodeQuery('', 1), 'an empty name is rejected')
32throws(() => encodeQuery('a..b', 1), 'an empty label is rejected')
33
34// ── response decoding ────────────────────────────────────────────────────────────────────────────
35// Hand-built response: one question, one TXT answer whose rdata is a single character-string.
36function buildResponse ({ id = 0x1234, rcode = 0, txt = ['did=did:plc:abc'], name = '_atproto.x.com' } = {}) {
37 const qname = []
38 for (const l of name.split('.')) { qname.push(l.length); for (const c of l) qname.push(c.charCodeAt(0)) }
39 qname.push(0)
40 const rdata = []
41 for (const s of txt) { rdata.push(s.length); for (const c of s) rdata.push(c.charCodeAt(0)) }
42 const head = [id >> 8, id & 0xff, 0x81, 0x80 | rcode, 0, 1, 0, 1, 0, 0, 0, 0]
43 // answer: compression pointer back to the question name, TXT, IN, ttl, rdlength, rdata
44 const ans = [0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, rdata.length >> 8, rdata.length & 0xff, ...rdata]
45 return Uint8Array.from([...head, ...qname, 0, 16, 0, 1, ...ans])
46}
47
48const r = decodeResponse(buildResponse(), 0x1234)
49ok(r.rcode === RCODE.NOERROR, 'rcode parsed')
50ok(r.answers.length === 1 && r.answers[0][0] === 'did=did:plc:abc', 'TXT record extracted')
51
52const chunked = decodeResponse(buildResponse({ txt: ['did=did:plc:', 'abcdef'] }), 0x1234)
53ok(chunked.answers[0].length === 2, 'a chunked TXT record keeps its character-strings separate')
54ok(chunked.answers[0].join('') === 'did=did:plc:abcdef', 'and they join back — the shape didFromTxt expects')
55
56ok(decodeResponse(buildResponse({ rcode: RCODE.NXDOMAIN }), 0x1234).rcode === RCODE.NXDOMAIN, 'NXDOMAIN is reported, not thrown')
57throws(() => decodeResponse(buildResponse({ id: 0x9999 }), 0x1234), 'a response for a different query id is rejected')
58
59// ── malformed input: this parses bytes off the network ───────────────────────────────────────────
60throws(() => decodeResponse(Uint8Array.from([0, 1, 2]), null), 'a response shorter than a header is rejected')
61throws(() => decodeResponse('not bytes', null), 'a non-Uint8Array is rejected')
62{
63 // rdlength claims more bytes than the message holds
64 const bad = buildResponse()
65 bad[bad.length - 3 - 'did=did:plc:abc'.length] = 0xff
66 throws(() => decodeResponse(bad, 0x1234), 'an rdlength past the end of the response is rejected')
67}
68{
69 // a compression pointer that points at itself
70 const loop = Uint8Array.from([0, 1, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xc0, 12])
71 throws(() => decodeResponse(loop, 1), 'a self-referential compression pointer does not hang')
72}
73
74// ── framing ──────────────────────────────────────────────────────────────────────────────────────
75const f = frame(Uint8Array.from([1, 2, 3]))
76ok(f.length === 5 && f[0] === 0 && f[1] === 3, 'TCP framing prefixes a two-byte length')
77
78// ── transport, against a fake socket ─────────────────────────────────────────────────────────────
79function fakeSocket (script) {
80 const handlers = {}
81 const sock = {
82 on (ev, fn) { (handlers[ev] ||= []).push(fn); return sock },
83 write () { setTimeout(() => script(handlers), 0) },
84 destroy () { sock.destroyed = true }
85 }
86 setTimeout(() => (handlers.connect || []).forEach((f) => f()), 0)
87 return sock
88}
89const emit = (handlers, ev, arg) => (handlers[ev] || []).forEach((f) => f(arg))
90
91const resolveOk = createTcpResolveTxt({
92 connect: () => fakeSocket((h) => emit(h, 'data', frame(buildResponse({ txt: ['did=did:plc:zz'] })))),
93 servers: ['127.0.0.53'],
94 nextId: () => 0x1234
95})
96ok((await resolveOk('_atproto.x.com'))[0][0] === 'did=did:plc:zz', 'a whole response in one chunk resolves')
97
98const resolveSplit = createTcpResolveTxt({
99 connect: () => fakeSocket((h) => {
100 const full = frame(buildResponse({ txt: ['did=did:plc:split'] }))
101 emit(h, 'data', full.subarray(0, 3))
102 setTimeout(() => emit(h, 'data', full.subarray(3)), 0)
103 }),
104 servers: ['127.0.0.53'],
105 nextId: () => 0x1234
106})
107ok((await resolveSplit('_atproto.x.com'))[0][0] === 'did=did:plc:split', 'a response split across TCP chunks is reassembled')
108
109const resolveClosed = createTcpResolveTxt({
110 connect: () => fakeSocket((h) => emit(h, 'close')),
111 servers: ['127.0.0.53']
112})
113ok(await resolveClosed('_atproto.x.com').then(() => false, () => true), 'a connection closed mid-response rejects rather than hanging')
114
115const resolveTimeout = createTcpResolveTxt({
116 connect: () => fakeSocket(() => {}), // accepts, never answers
117 servers: ['127.0.0.53'],
118 timeoutMs: 20
119})
120// The timeout timer is deliberately unref'd so a pending lookup never holds the daemon open, which
121// also means node's loop would drain before it fires. This keep-alive is the test's problem, not the
122// client's.
123const keepAlive = setInterval(() => {}, 5)
124ok(await resolveTimeout('_atproto.x.com').then(() => false, (e) => /timed out/.test(e.message)), 'a resolver that never answers times out')
125clearInterval(keepAlive)
126
127console.log('\n' + pass + ' assertions passed')