From 63e9b31c92d3835d92b3581ab0aff2959adf8c7c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 16 Sep 2026 21:28:01 +0200 Subject: [PATCH] fix(gateway): restore signed-records + TLD-beacon endpoints lost in the SPA deploy The 17:23 UTC deploy today pushed the repo copy of the gateway over the VPS, but that copy predated the signed-records work (/api/records, /api/dns, TLD_BEACON-sourced /api/tlds, holdings with tlds/wallet/chain), which had only ever lived on the VPS. Three-way merge of that version with the SPA-fallback change, plus the resolver's NFT-owner tracking that the records routes verify against. The repo is now the only deploy source. --- src/gateway/public-gateway.mjs | 425 +++++++++++++++++++++++++++++++-- src/lib/resolver-web.js | 33 ++- 2 files changed, 432 insertions(+), 26 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 4bf0dca..2d028ff 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -14,12 +14,53 @@ import http from "http"; import https from "https"; import tls from "tls"; import { AwsClient } from "aws4fetch"; +// libauth is present so the next drop's signature-verify path is a +// contained edit (no dep install between now and then). Only utf8ToBin +// and sha256 are used today; the rest are named here so the imports +// are visible from the top of the file. +import { + utf8ToBin, base64ToBin, sha256, ripemd160, + secp256k1, encodeCashAddress, +} from "@bitauth/libauth"; import WebSocket from "ws"; // resolver-web.js lives at ../lib/, not alongside this file — the previous // `./resolver-web.js` import was broken (file was moved into lib/ but this // import wasn't updated). Fix while touching the file. -import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM } from "./resolver-web.js"; +import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM, TLD_BEACON_SCRIPTHASH } from "./resolver-web.js"; import { pickRecord, isSubdomainOf } from "./record-picker.js"; +import { buildTldSnapshot } from "./tld-index.js"; + +// TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum, +// verbose-fetching every tx, and passing history+txs to buildTldSnapshot. +// This is the AUTHORITATIVE source for the per-TLD-NFT registry — the old +// `tlds.bch` root NFT is legacy and no longer authoritative (see +// Decentralized.DNS/DESIGN-tld-registry.md §9). Cached 30s. +let _tldSnap = { at: 0, snap: null }; +async function getTldSnapshot() { + if (_tldSnap.snap && Date.now() - _tldSnap.at < 30000) return _tldSnap.snap; + const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; + const candidates = CHIPNET_ELECTRUM + .map((s) => (typeof s === "string" ? { url: s } : s)) + .filter((s) => !BNS_INDEXER_URLS.test(s.url)); + let el; + for (const s of candidates) { + try { el = await connectAny([s], WebSocket); break; } catch { /* try next */ } + } + if (!el) throw new Error("no full electrum reachable"); + try { + const history = typeof el.unionHistory === "function" + ? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history + : await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]); + const txs = new Map(); + for (const { tx_hash } of history) { + try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); } + catch { /* unreadable — skip */ } + } + const snap = buildTldSnapshot({ history, txs }); + _tldSnap = { at: Date.now(), snap }; + return snap; + } finally { try { el.close?.(); } catch {} } +} // Full-index cache (rebuilt every 30s) for fast availability lookups. let _idx = { at: 0, index: null }; @@ -66,6 +107,34 @@ const aws = new AwsClient({ service: "s3", region: "us-east-1", }); +// In-memory highest-seen seq per name, used by both the POST (records +// write) and GET (dns read) sides to reject rollback of a signed +// manifest. Loses state on gateway restart — that's acceptable in v1 +// since Sia's newer-writer-wins covers the fresh state, and a restart +// window is a narrow attack surface. A future v2 can persist this to +// disk alongside the beacon snapshot. +const _dnsSeqCache = new Map(); + +// ---------- signed records manifest helpers ---------- +// canonicalize(): recursively sort object keys, drop null/undefined so +// signable bytes are reproducible cross-implementation. Arrays keep order. +function canonicalize(x) { + if (x === null || x === undefined) return undefined; + if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined); + if (typeof x === "object") { + const out = {}; + for (const k of Object.keys(x).sort()) { + const v = canonicalize(x[k]); + if (v !== undefined) out[k] = v; + } + return out; + } + return x; +} +function withoutKey(obj, k) { + const { [k]: _drop, ...rest } = obj; + return rest; +} const cache = new Map(); async function resolve(host) { const c = cache.get(host); @@ -446,8 +515,12 @@ const server = http.createServer(async (req, res) => { res.setHeader("access-control-allow-origin", "*"); res.setHeader("content-type", "application/json"); try { - const idx = await getIndex(); - const observed = new Map(); // tld -> count of names + // Authoritative TLD list: walk TLD_BEACON, each per-TLD NFT (TREG) is + // its own first-class registration. Legacy `tlds.bch` root record is + // still used only to derive `advertised` (which TLDs the operator + // wants shops to promote). + const [snap, idx] = await Promise.all([getTldSnapshot(), getIndex()]); + const observed = new Map(); // tld -> count of registered names for (const key of idx.keys()) { const dot = key.indexOf("."); const tld = dot < 0 ? "bch" : key.slice(dot + 1); @@ -456,26 +529,51 @@ const server = http.createServer(async (req, res) => { const anchor = idx.get("tlds.bch"); const advertisedRaw = (anchor?.records?.tlds ?? "").trim(); const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : []; - const known = new Set([...advertised, ...observed.keys()]); // include_hidden=1 returns every TLD (used by the admin panel); // otherwise HIDDEN_TLDS are filtered from the public listing. const url = new URL(req.url, "http://x"); const includeHidden = url.searchParams.get("include_hidden") === "1"; - const tlds = [...known].sort() - .filter((tld) => includeHidden || !HIDDEN_TLDS.has(tld)) - .map((tld) => ({ - tld, - registered_count: observed.get(tld) || 0, - in_advertised: advertised.includes(tld), - hidden: HIDDEN_TLDS.has(tld) || undefined, - })); + // Each row carries the owner's on-chain policy (`records` from the + // latest TREG/TUPD) so shops can price and gate without walking the + // beacon themselves: + // price_usd — flat USD price the owner set for every name under + // the TLD (records.price); null = tier pricing + // hidden — off the public listing. hidden_by tells why: + // "owner" (records.hidden=1, the owner switched the + // TLD off) or "operator" (HIDDEN_TLDS file). + // policy — records.policy (open | frozen | covenant), default open + const ownerHidden = (rec) => rec.hidden === 1 || rec.hidden === true || rec.hidden === "1"; + const priceOf = (rec) => (typeof rec.price === "number" && Number.isFinite(rec.price) && rec.price >= 0 ? rec.price : null); + const all = [...snap.tlds] + .sort((a, b) => a.tld.localeCompare(b.tld)) + .map((t) => { + const rec = t.records && typeof t.records === "object" ? t.records : {}; + const byOwner = ownerHidden(rec); + const byOperator = HIDDEN_TLDS.has(t.tld); + return { + tld: t.tld, + registered_count: observed.get(t.tld) || 0, + in_advertised: advertised.includes(t.tld), + hidden: (byOwner || byOperator) || undefined, + hidden_by: byOwner ? "owner" : byOperator ? "operator" : undefined, + price_usd: priceOf(rec), + policy: typeof rec.policy === "string" ? rec.policy : "open", + records: rec, + owner: t.owner ?? null, + category: t.category, + reg_height: t.regHeight ?? null, + updated_txid: t.updatedTxid ?? null, + }; + }); + const tlds = all.filter((t) => includeHidden || !t.hidden); res.writeHead(200); res.end(JSON.stringify({ - source: "tlds.bch", - height: anchor?.height ?? null, - updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null, + source: "TLD_BEACON", + height: snap.asOfHeight ?? null, + updated_txid: snap.asOfTxid ?? null, + root: snap.root ?? null, advertised, - hidden_count: HIDDEN_TLDS.size, + hidden_count: all.filter((t) => t.hidden).length, tlds, }, null, 2)); } catch (e) { @@ -595,6 +693,10 @@ const server = http.createServer(async (req, res) => { if (req.url === "/api/holdings" || req.url.startsWith("/api/holdings/") || req.url.startsWith("/api/holdings?")) { res.setHeader("access-control-allow-origin", "*"); res.setHeader("content-type", "application/json"); + res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); + res.setHeader("access-control-max-age", "86400"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } let scripthashes = []; if (req.method === "POST") { try { @@ -633,13 +735,30 @@ const server = http.createServer(async (req, res) => { return; } try { - const utxoLists = await Promise.all(scripthashes.map((sh) => - el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => []) - )); + // Single round-trip: fetch all UTXOs + the chain head in parallel. + // headers.subscribe returns the current tip on first call; keeping the + // socket-level subscription is fine because we close the electrum + // connection right after in the `finally` block. + const [utxoLists, tipHeader] = await Promise.all([ + Promise.all(scripthashes.map((sh) => + el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => []) + )), + el.call("blockchain.headers.subscribe", []).catch(() => null), + ]); const categories = new Set(); + let sats = 0n; for (const list of utxoLists) { - for (const u of list) if (u.token_data?.nft) categories.add(u.token_data.category); + for (const u of list) { + // Sum spendable balance across every watched address. + const v = u.value; + if (typeof v === "number") sats += BigInt(v); + else if (typeof v === "string") sats += BigInt(v); + if (u.token_data?.nft) categories.add(u.token_data.category); + } } + // Cross-join against the name index (REG NFTs) and the TLD snapshot + // (TREG NFTs). Both share the same "category → entry" shape; the + // wallet just holds one or the other. const idx = await getIndex(); const names = []; for (const [key, entry] of idx) { @@ -658,9 +777,39 @@ const server = http.createServer(async (req, res) => { }); } names.sort((a, b) => a.name.localeCompare(b.name)); + // TLDs — walk the TLD_BEACON snapshot (already cached by + // getTldSnapshot) and pick out categories owned by this wallet. + // Silent on a snapshot fetch failure — never let a beacon hiccup + // break the names response. + const tlds = []; + try { + const snap = await getTldSnapshot(); + for (const t of snap.tlds ?? []) { + if (!categories.has(t.category)) continue; + tlds.push({ + tld: t.tld, + category: t.category, + records: t.records ?? null, + reg_height: t.regHeight ?? null, + reg_txid: t.regTxid ?? null, + updated_txid: t.updatedTxid ?? null, + owner: t.owner ?? null, + }); + } + tlds.sort((a, b) => a.tld.localeCompare(b.tld)); + } catch { /* leave tlds empty */ } + const wallet = { + sats: sats.toString(), // BigInt-safe wire format + utxo_count: utxoLists.reduce((n, l) => n + l.length, 0), + }; + const chain = tipHeader && typeof tipHeader.height === "number" + ? { height: tipHeader.height } + : null; const body = req.method === "POST" - ? { count: names.length, scripthashes_queried: scripthashes.length, names } - : { scripthash: scripthashes[0], count: names.length, names }; + ? { count: names.length, tld_count: tlds.length, scripthashes_queried: scripthashes.length, + names, tlds, wallet, chain } + : { scripthash: scripthashes[0], count: names.length, tld_count: tlds.length, + names, tlds, wallet, chain }; res.writeHead(200); res.end(JSON.stringify(body, null, 2)); } catch (e) { @@ -686,12 +835,244 @@ const server = http.createServer(async (req, res) => { res.end(JSON.stringify({ name: key, tld, registered: !!entry, records: entry?.records ?? null, category: entry?.category ?? null, + owner: entry?.owner ?? null, })); } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); } return; } + // --- signed records manifest --- + // GET /api/records/ -> reads the current signed manifest from + // the name's Sia bucket at _records.json. + // Public. See DESIGN-signed-records-manifest.md. + // POST /api/records/ body = manifest JSON, verifies: + // (1) sig matches current on-chain NFT owner, + // (2) seq strictly greater than current seq, + // (3) name field matches path, + // then uploads to Sia at _records.json. + if (req.url.startsWith("/api/records/")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); + res.setHeader("access-control-max-age", "86400"); + res.setHeader("content-type", "application/json"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + const raw = decodeURIComponent(req.url.slice("/api/records/".length).split("?")[0]); + let name; + try { name = normalizeName(raw); } + catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; } + // Where the manifest lives — same Sia bucket the on-chain s3 record points at. + // If the name has no s3 pointer, records-manifest can't be published for it. + const entry = (await getIndex()).get(name); + if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; } + const s3Base = entry.records?.s3; + if (!s3Base) { + res.writeHead(409); + res.end(JSON.stringify({ error: `no s3 pointer on chain — set records.s3 first, then records-manifest has somewhere to live` })); + return; + } + const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json"; + // ---- GET ---- + if (req.method === "GET") { + try { + const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`); + if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest yet", name })); return; } + if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; } + const body = await up.text(); + res.writeHead(200); res.end(body); + } catch (e) { + res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); + } + return; + } + // ---- POST ---- + if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "GET or POST" })); return; } + if (!entry.owner) { + res.writeHead(503); + res.end(JSON.stringify({ error: "gateway index has no owner recorded for this name yet — retry after next index refresh (30s)" })); + return; + } + try { + // Read body (128 KB cap). + const chunks = []; + let total = 0; + for await (const c of req) { + total += c.length; + if (total > 128 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "manifest > 128 KB" })); return; } + chunks.push(c); + } + const bodyText = Buffer.concat(chunks).toString("utf8"); + let manifest; + try { manifest = JSON.parse(bodyText); } + catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; } + // Schema checks. + if (manifest.v !== 1) { res.writeHead(400); res.end(JSON.stringify({ error: "unsupported v (expected 1)" })); return; } + if (manifest.name !== name) { res.writeHead(400); res.end(JSON.stringify({ error: `name field "${manifest.name}" does not match path "${name}"` })); return; } + if (typeof manifest.seq !== "number" || !Number.isFinite(manifest.seq) || manifest.seq < 0) { + res.writeHead(400); res.end(JSON.stringify({ error: "seq must be a non-negative integer" })); return; + } + if (typeof manifest.sig !== "string" || !manifest.sig) { + res.writeHead(400); res.end(JSON.stringify({ error: "sig required (base64 BCH message signature, 65 bytes)" })); return; + } + // Seq monotonicity — read the current manifest if any. + try { + const cur = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`); + if (cur.ok) { + const cj = JSON.parse(await cur.text()); + if (typeof cj.seq === "number" && manifest.seq <= cj.seq) { + res.writeHead(409); res.end(JSON.stringify({ error: `stale seq ${manifest.seq} <= current ${cj.seq}` })); return; + } + } + } catch { /* first write */ } + // Signature verification. The signable payload is the manifest + // WITHOUT the sig field, canonicalized: sorted keys, no whitespace, + // nulls omitted. See DESIGN-signed-records-manifest.md. + const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig"))); + const digest = sha256.hash(utf8ToBin(canonical)); + let sigBytes; + try { sigBytes = base64ToBin(manifest.sig); } + catch { res.writeHead(400); res.end(JSON.stringify({ error: "sig is not valid base64" })); return; } + if (sigBytes.length !== 65) { + res.writeHead(400); res.end(JSON.stringify({ error: `sig must decode to 65 bytes (got ${sigBytes.length})` })); return; + } + // BCH message-signature envelope: [flag, r|s]. flag = 27+recovery + // (+4 if compressed pubkey). Compressed is the standard. + const flag = sigBytes[0]; + const recovery = (flag - 27) & 3; + const compactSig = sigBytes.slice(1); + const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest); + if (typeof recovered === "string") { + res.writeHead(400); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; + } + // Derive p2pkh CashAddress from recovered pubkey, using the same + // prefix as the on-chain owner address (chipnet = "bchtest", + // mainnet = "bitcoincash"). The prefix comes from the owner + // string itself so we don't have to hardcode chain. + const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest"; + const pubkeyHash = ripemd160.hash(sha256.hash(recovered)); + const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash }); + // libauth returns { address } in newer versions and a bare string in + // older ones — unwrap either shape. + const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? ""); + if (derivedAddr !== entry.owner) { + res.writeHead(403); + res.end(JSON.stringify({ + error: "signature does not match current on-chain NFT owner", + derived: derivedAddr, + current: entry.owner, + })); + return; + } + // Upload signed blob to Sia at `_records.json`. + const put = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`, { + method: "PUT", + body: Buffer.from(bodyText, "utf8"), + headers: { "content-type": "application/json" }, + }); + if (!put.ok) { + const detail = await put.text().catch(() => ""); + res.writeHead(502); + res.end(JSON.stringify({ error: `sia write failed: ${put.status} ${detail.slice(0, 200)}` })); + return; + } + // Uploaded — bump the highest-seen seq for this name so + // subsequent GET /api/dns/ won't accept a replay under any + // circumstances (Sia's read-consistency + our seq cache both agree). + _dnsSeqCache.set(name, manifest.seq); + res.writeHead(200); + res.end(JSON.stringify({ ok: true, name, seq: manifest.seq, sia_key: manifestKey, bytes: total })); + } catch (e) { + res.writeHead(500); + res.end(JSON.stringify({ error: `records-manifest error: ${e.message}` })); + } + return; + } + // --- resolver-side DNS read (verified) --- + // GET /api/dns/ -> { name, seq, updated_at, dns, verified_owner } + // + // This is the "resolver read" side of the signed-records-manifest flow. + // Fetches the name's _records.json from Sia, verifies: + // (1) sig recovers to the current NFT holder, + // (2) seq is >= the highest we've cached (rejects replays even if + // Sia serves stale data), + // then returns only the verified `dns` block. Callers (mail bridges, + // DNS shims, Theseus, Ariadne) can trust the response without doing + // their own on-chain lookups. + if (req.url.startsWith("/api/dns/")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("access-control-allow-methods", "GET, OPTIONS"); + res.setHeader("access-control-allow-headers", "content-type"); + res.setHeader("access-control-max-age", "86400"); + res.setHeader("content-type", "application/json"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET only" })); return; } + const raw = decodeURIComponent(req.url.slice("/api/dns/".length).split("?")[0]); + let name; + try { name = normalizeName(raw); } + catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; } + const entry = (await getIndex()).get(name); + if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; } + const s3Base = entry.records?.s3; + if (!s3Base) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest — name has no s3 pointer on chain", name })); return; } + if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "gateway index has no owner recorded yet — retry after next index refresh" })); return; } + const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json"; + try { + const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`); + if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest published yet", name })); return; } + if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; } + const bodyText = await up.text(); + let manifest; + try { manifest = JSON.parse(bodyText); } + catch { res.writeHead(502); res.end(JSON.stringify({ error: "manifest on sia is not valid JSON" })); return; } + if (manifest.v !== 1) { res.writeHead(502); res.end(JSON.stringify({ error: `unsupported manifest v=${manifest.v}` })); return; } + if (manifest.name !== name) { res.writeHead(502); res.end(JSON.stringify({ error: "manifest name field does not match request" })); return; } + // Replay defence — reject anything with a seq we've seen a HIGHER + // value of. Newer manifests are always accepted. + const seenSeq = _dnsSeqCache.get(name); + if (seenSeq !== undefined && manifest.seq < seenSeq) { + res.writeHead(502); res.end(JSON.stringify({ error: `stale manifest — seq ${manifest.seq} < seen ${seenSeq}` })); return; + } + // Signature check — recover pubkey from sig+digest, derive + // CashAddress with the owner's prefix, compare to entry.owner. + if (typeof manifest.sig !== "string") { res.writeHead(502); res.end(JSON.stringify({ error: "manifest has no sig" })); return; } + const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig"))); + const digest = sha256.hash(utf8ToBin(canonical)); + let sigBytes; + try { sigBytes = base64ToBin(manifest.sig); } + catch { res.writeHead(502); res.end(JSON.stringify({ error: "sig not valid base64" })); return; } + if (sigBytes.length !== 65) { res.writeHead(502); res.end(JSON.stringify({ error: `sig must be 65 bytes (got ${sigBytes.length})` })); return; } + const flag = sigBytes[0]; + const recovery = (flag - 27) & 3; + const compactSig = sigBytes.slice(1); + const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest); + if (typeof recovered === "string") { res.writeHead(502); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; } + const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest"; + const pubkeyHash = ripemd160.hash(sha256.hash(recovered)); + const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash }); + const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? ""); + if (derivedAddr !== entry.owner) { + res.writeHead(502); + res.end(JSON.stringify({ error: "manifest signature does not match current NFT owner", derived: derivedAddr, current: entry.owner })); + return; + } + // Verified. Cache the seq and hand back the DNS block. + _dnsSeqCache.set(name, manifest.seq); + res.setHeader("Cache-Control", "public, max-age=30"); + res.writeHead(200); + res.end(JSON.stringify({ + name, + seq: manifest.seq, + updated_at: manifest.updated_at ?? null, + dns: manifest.dns ?? {}, + verified_owner: entry.owner, + }, null, 2)); + } catch (e) { + res.writeHead(500); + res.end(JSON.stringify({ error: `dns resolve error: ${e.message}` })); + } + return; + } // --- registrar API: all names sharing a label across every TLD --- // GET /api/labels/